1use std::any::{Any, TypeId};
21use std::collections::HashMap;
22use std::rc::Rc;
23
24use tilo_resource::{Doc, Invoke, Resource};
25use ui_core::history_mode::HistoryDefinition;
26use ui_core::history_mode::checked_files::FileHistorySource;
27
28use super::surface::ResourceSelection;
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum RunMode {
33 Live,
36 Past,
39 Capture,
43}
44
45impl RunMode {
46 pub fn is_live(self) -> bool {
48 self == Self::Live
49 }
50}
51
52#[derive(Default)]
54pub(crate) struct Fixtures {
55 values: HashMap<TypeId, Rc<dyn Any>>,
56 collaborators: Vec<(String, ResourceSelection)>,
57 content: Option<tilo_content::ContentStore>,
58 _directory: Option<tempfile::TempDir>,
60}
61
62#[derive(Clone)]
64pub(crate) struct Run {
65 pub(crate) mode: RunMode,
66 pub(crate) fixtures: Rc<Fixtures>,
67 pub(crate) launched: Rc<Launched>,
69 pub(crate) private: bool,
71 pub(crate) file_history: Option<(FileHistorySource, &'static HistoryDefinition)>,
75 pub(crate) people: super::People,
77 pub(crate) opener: Option<super::launch::OpenHere>,
79 pub(crate) driven: bool,
83 pub(crate) workspace:
86 Option<ui_core::arriving::Arriving<(), ui_core::workspace_sync::WorkspaceUnreachable>>,
87}
88
89#[derive(Default)]
93pub(crate) struct Launched {
94 pub(crate) record: Option<ui_core::record::RecordId>,
96 pub(crate) arguments: Vec<String>,
97 pub(crate) options: Vec<ui_core::application::LaunchOption>,
98 pub(crate) file: Option<ui_core::application::OpenFile>,
99 pub(crate) handoff: Option<ui_core::application::PlaybackHandoff>,
100 pub(crate) lent: Lent,
102 pub(crate) reveal: std::cell::RefCell<Option<String>>,
107}
108
109#[derive(Default)]
113pub(crate) struct Lent(std::cell::RefCell<Vec<Box<dyn Any>>>);
114
115impl Lent {
116 pub(crate) fn new(services: Vec<Box<dyn Any>>) -> Self {
117 Self(std::cell::RefCell::new(services))
118 }
119
120 pub(crate) fn take<T: 'static>(&self) -> Option<T> {
122 let mut services = self.0.borrow_mut();
123 let index = services.iter().position(|service| service.is::<T>())?;
124 services
125 .remove(index)
126 .downcast::<T>()
127 .ok()
128 .map(|service| *service)
129 }
130}
131
132impl Run {
133 pub(crate) fn live(content: Option<tilo_content::ContentStore>) -> Self {
134 Self {
135 mode: RunMode::Live,
136 fixtures: Rc::new(Fixtures {
137 content,
138 ..Fixtures::default()
139 }),
140 launched: Rc::default(),
141 private: false,
142 file_history: None,
143 people: super::People::new(None),
144 opener: None,
145 driven: false,
146 workspace: None,
147 }
148 }
149
150 pub(crate) fn in_private(mut self, private: bool) -> Self {
152 self.private = private;
153 self
154 }
155
156 pub(crate) fn showing_file_history(
158 mut self,
159 file_history: Option<(FileHistorySource, &'static HistoryDefinition)>,
160 ) -> Self {
161 self.file_history = file_history;
162 self
163 }
164
165 pub(crate) fn past() -> Self {
166 Self {
167 mode: RunMode::Past,
168 fixtures: Rc::default(),
169 launched: Rc::default(),
170 private: false,
171 file_history: None,
172 people: super::People::new(None),
173 opener: None,
174 driven: false,
175 workspace: None,
176 }
177 }
178
179 pub(crate) fn driven_by_drive(mut self, driven: bool) -> Self {
182 self.driven = driven;
183 self
184 }
185
186 pub(crate) fn awaiting_workspace(
189 mut self,
190 workspace: Option<
191 ui_core::arriving::Arriving<(), ui_core::workspace_sync::WorkspaceUnreachable>,
192 >,
193 ) -> Self {
194 self.workspace = workspace;
195 self
196 }
197
198 pub(crate) fn opening_with(mut self, opener: Option<super::launch::OpenHere>) -> Self {
200 self.opener = opener;
201 self
202 }
203
204 pub(crate) fn open_here(
206 &self,
207 arguments: &[String],
208 ) -> Result<ui_core::application::ReplacementOutcome, String> {
209 Ok(self
210 .opener
211 .as_ref()
212 .ok_or_else(|| "this window shows the record it was opened on".to_owned())?
213 .open(arguments))
214 }
215
216 pub(crate) fn for_participant(mut self, me: Option<&str>) -> Self {
217 self.people = super::People::new(me);
218 self
219 }
220
221 pub(crate) fn launched(mut self, launched: Launched) -> Self {
223 self.launched = Rc::new(launched);
224 self
225 }
226
227 pub(crate) fn launch(&self) -> super::launch::LaunchInput<'_> {
228 super::launch::LaunchInput::new(&self.launched.arguments, self.launched.file.as_ref())
229 .with_content(self.fixtures.content.as_ref())
230 .with_handoff(self.launched.handoff.as_ref())
231 .with_options(&self.launched.options)
232 }
233
234 pub(crate) fn fixture<T: 'static>(&self) -> Option<Rc<T>> {
235 let value = self.fixtures.values.get(&TypeId::of::<T>())?;
236 Rc::clone(value).downcast::<T>().ok()
237 }
238 pub(crate) fn content(&self) -> Option<tilo_content::ContentStore> {
239 self.fixtures.content.clone()
240 }
241
242 pub(crate) fn collaborators(&self) -> &[(String, ResourceSelection)] {
244 &self.fixtures.collaborators
245 }
246}
247
248#[derive(Clone, Default)]
251pub(crate) struct Answers(HashMap<String, serde_json::Value>);
252
253impl Answers {
254 fn key(target: &tilo_actions::remote::RemoteActionTarget) -> String {
255 format!(
256 "{}/{}/{}",
257 target.provider, target.address.resource, target.address.action
258 )
259 }
260
261 pub(crate) fn get(
263 &self,
264 target: &tilo_actions::remote::RemoteActionTarget,
265 ) -> Option<&serde_json::Value> {
266 self.0.get(&Self::key(target))
267 }
268}
269
270struct Version<R: Resource> {
272 author: String,
273 at_ms: i64,
274 invoke: Invoke<R>,
275}
276
277pub struct Fixture<R: Resource> {
281 scenario: Option<String>,
282 person: String,
286 values: HashMap<TypeId, Rc<dyn Any>>,
287 start: Option<Start>,
289 history: Vec<Version<R>>,
290 collaborators: Vec<(String, ResourceSelection)>,
291 content: Option<tilo_content::ContentStore>,
292 directory: Option<tempfile::TempDir>,
293}
294
295type Start = Box<dyn Fn(&mut Doc) -> Result<(), String>>;
297
298impl<R: Resource> Fixture<R> {
299 pub(crate) fn new(
300 scenario: Option<&str>,
301 person: &str,
302 content: Option<tilo_content::ContentStore>,
303 ) -> Self {
304 Self {
305 scenario: scenario.map(str::to_owned),
306 person: person.to_owned(),
307 values: HashMap::new(),
308 start: None,
309 history: Vec::new(),
310 collaborators: Vec::new(),
311 content,
312 directory: None,
313 }
314 }
315
316 pub fn scenario(&self) -> Option<&str> {
318 self.scenario.as_deref()
319 }
320
321 pub fn person(&self) -> &str {
326 &self.person
327 }
328
329 pub fn provide<T: 'static>(&mut self, value: T) {
333 self.values.insert(TypeId::of::<T>(), Rc::new(value));
334 }
335
336 pub fn answer(
340 &mut self,
341 target: &tilo_actions::remote::RemoteActionTarget,
342 value: &impl serde::Serialize,
343 ) {
344 let mut answers = self
345 .values
346 .remove(&TypeId::of::<Answers>())
347 .and_then(|answers| answers.downcast::<Answers>().ok())
348 .map(|answers| (*answers).clone())
349 .unwrap_or_default();
350 answers.0.insert(
351 Answers::key(target),
352 serde_json::to_value(value).unwrap_or_default(),
353 );
354 self.provide(answers);
355 }
356
357 pub fn directory(&mut self) -> std::io::Result<&std::path::Path> {
360 if self.directory.is_none() {
361 self.directory = Some(
362 tempfile::Builder::new()
363 .prefix("tilo-scenario-")
364 .tempdir()?,
365 );
366 }
367 match &self.directory {
368 Some(directory) => Ok(directory.path()),
369 None => Err(std::io::Error::other("no scenario directory")),
370 }
371 }
372
373 pub fn content(&mut self) -> &tilo_content::ContentStore {
378 self.content
379 .get_or_insert_with(tilo_content::ContentStore::memory)
380 }
381
382 pub fn start(&mut self, state: R::State)
392 where
393 R::State: tilo_resource::Fields,
394 {
395 self.start = Some(Box::new(move |doc| {
396 let root = ui_core::automerge::ROOT;
397 let before = <R::State as tilo_resource::Fields>::read_fields(doc, &root)
398 .map_err(|error| format!("read the record: {error}"))?;
399 tilo_resource::Fields::write_changed_fields(
400 &state,
401 &before,
402 doc,
403 &root,
404 &tilo_resource::Delta::default(),
405 )
406 .map_err(|error| format!("write the record's start: {error}"))?;
407 doc.commit_with(
408 ui_core::automerge::transaction::CommitOptions::default()
409 .with_message(tilo_actions::FrameworkChange::Genesis.change_message()),
410 );
411 Ok(())
412 }));
413 }
414
415 pub fn version(&mut self, author: &str, at_ms: i64, invoke: Invoke<R>) {
421 self.history.push(Version {
422 author: author.to_owned(),
423 at_ms,
424 invoke,
425 });
426 }
427
428 pub fn collaborator(&mut self, name: &str, at: ResourceSelection) {
432 self.collaborators.push((name.to_owned(), at));
433 }
434
435 pub(crate) fn write_history(
437 &self,
438 doc: &mut Doc,
439 registry: &tilo_actions::ActionRegistry,
440 ) -> Result<(), String> {
441 if let Some(start) = &self.start {
442 start(doc)?;
443 }
444 for version in &self.history {
445 write_version::<R>(doc, registry, version)?;
446 }
447 Ok(())
448 }
449
450 pub(crate) fn has_history(&self) -> bool {
451 self.start.is_some() || !self.history.is_empty()
452 }
453
454 pub(crate) fn into_run(self) -> Run {
456 capture_run(Fixtures {
457 values: self.values,
458 collaborators: self.collaborators,
459 content: self.content,
460 _directory: self.directory,
461 })
462 }
463
464 pub(crate) fn lent_content(&self) -> Option<&tilo_content::ContentStore> {
466 self.content.as_ref()
467 }
468}
469
470#[inline(never)]
473fn capture_run(fixtures: Fixtures) -> Run {
474 Run {
475 mode: RunMode::Capture,
476 fixtures: Rc::new(fixtures),
477 launched: Rc::default(),
478 private: false,
479 file_history: None,
480 people: super::People::new(None),
481 opener: None,
482 driven: false,
483 workspace: None,
484 }
485}
486
487fn write_version<R: Resource>(
490 doc: &mut Doc,
491 registry: &tilo_actions::ActionRegistry,
492 version: &Version<R>,
493) -> Result<(), String> {
494 use tilo_actions::Action as _;
495 let action = version.invoke.build(version.at_ms)?;
496 let encoded = tilo_resource::encode::<R>(&action).map_err(|error| error.to_string())?;
497 let mut context = tilo_actions::ActionContext::for_resource(R::KIND, R::KIND, R::LABEL)
498 .with_change(action.change_kind())
499 .with_host_invoker(version.author.clone(), None);
500 if let Some(described) = action.describe(doc).or_else(|| {
501 tilo_actions::Described::of_published(
502 &encoded,
503 tilo_actions::ActionKey::new("", R::KIND, version.invoke.id),
504 )
505 }) {
506 context = context.with_described(described);
507 }
508 let envelope = tilo_actions::ActionEnvelope {
509 id: tilo_actions::ActionId::new(),
510 action: encoded,
511 base_heads: doc.get_heads().iter().map(|head| head.0).collect(),
512 timestamp: version.at_ms.div_euclid(1000),
513 locus: action.locus(),
514 actor: Some(version.author.as_bytes().to_vec()),
515 context: Some(context),
516 };
517 tilo_actions::replay(doc, &envelope, registry).map_err(|error| error.to_string())
518}