Skip to main content

buntrail_engine/
lib.rs

1//! Authoritative document and workspace orchestration.
2//!
3//! See the repository README for usage, prerequisites and platform limitations.
4//! Authoritative documents with worker-routed local and remote services.
5mod document;
6#[cfg(unix)]
7pub mod environment;
8mod jobs;
9mod language;
10#[cfg(unix)]
11mod recovery;
12#[cfg(unix)]
13pub mod remote;
14#[cfg(all(unix, test))]
15#[path = "test_support/remote.rs"]
16mod remote_test_support;
17#[cfg(unix)]
18mod runtime;
19#[cfg(unix)]
20mod save;
21#[cfg(unix)]
22mod worker;
23use buntrail_core::{
24    Snapshot,
25    contracts::{BufferRange, EditResult, EditTransaction},
26};
27use buntrail_services::Services;
28use document::Document;
29use serde_json::{Value, json};
30use std::collections::HashMap;
31use std::sync::{Arc, Mutex, MutexGuard};
32
33#[derive(Debug)]
34pub struct Failure {
35    pub code: &'static str,
36    pub message: String,
37}
38type Result<T> = std::result::Result<T, Failure>;
39fn fail(code: &'static str, message: impl Into<String>) -> Failure {
40    Failure {
41        code,
42        message: message.into(),
43    }
44}
45fn string<'a>(r: &'a Value, key: &str) -> Result<&'a str> {
46    r.get(key)
47        .and_then(Value::as_str)
48        .ok_or_else(|| fail("invalid_request", format!("{key} must be a string")))
49}
50fn number(r: &Value, key: &str) -> Result<u64> {
51    r.get(key).and_then(Value::as_u64).ok_or_else(|| {
52        fail(
53            "invalid_request",
54            format!("{key} must be a nonnegative integer"),
55        )
56    })
57}
58
59#[derive(Default)]
60struct Documents {
61    next_id: u64,
62    open: HashMap<u64, Document>,
63}
64
65struct AnalysisInput {
66    id: u64,
67    snapshot: Snapshot,
68    association: u64,
69    path: Option<String>,
70    language: String,
71}
72pub struct Engine {
73    #[cfg(unix)]
74    runtime: Arc<runtime::Runtime>,
75    jobs: jobs::Jobs,
76    documents: Arc<Mutex<Documents>>,
77    services: Arc<Services>,
78    language: buntrail_language::LanguageAdapter,
79}
80impl Default for Engine {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85impl Engine {
86    pub fn new() -> Self {
87        let services = Arc::new(Services::new());
88        let documents = Arc::new(Mutex::new(Documents::default()));
89        Self {
90            #[cfg(unix)]
91            runtime: Arc::new(runtime::Runtime::new(services.clone(), documents.clone())),
92            jobs: jobs::Jobs::default(),
93            documents,
94            services,
95            language: buntrail_language::LanguageAdapter::default(),
96        }
97    }
98    fn documents(&self) -> Result<MutexGuard<'_, Documents>> {
99        self.documents.lock().map_err(|_| {
100            fail(
101                "engine_failed",
102                "Engine state is poisoned; preserve recovery data and reopen",
103            )
104        })
105    }
106    pub fn call(&self, request: Value) -> Value {
107        match self.dispatch(&request) {
108            Ok(value) => json!({"ok":true,"value":value}),
109            Err(error) => json!({"ok":false,"error":{"code":error.code,"message":error.message}}),
110        }
111    }
112    fn dispatch(&self, r: &Value) -> Result<Value> {
113        let command = string(r, "command")?;
114        #[cfg(unix)]
115        let _admission = if command.starts_with("document.") {
116            Some(self.runtime.document_admission()?)
117        } else {
118            None
119        };
120        #[cfg(unix)]
121        if command.starts_with("remote.")
122            || command.starts_with("terminal.")
123            || command == "search"
124            || command.starts_with("git")
125            || command.starts_with("fs.")
126            || command.starts_with("workspace.")
127            || command.starts_with("preferences.")
128            || command == "capabilities"
129        {
130            return self.runtime.call(r);
131        }
132        match command {
133            "service.start" => {
134                let request = r
135                    .get("request")
136                    .filter(|v| v.is_object())
137                    .ok_or_else(|| fail("invalid_request", "request must be an object"))?
138                    .clone();
139                let operation = string(&request, "command")?;
140                if operation.starts_with("document.") || operation.starts_with("service.") {
141                    return Err(fail(
142                        "invalid_request",
143                        "Jobs accept service operations only",
144                    ));
145                }
146                let services = self.services.clone();
147                #[cfg(unix)]
148                let runtime = self.runtime.clone();
149                let id = self.jobs.start(move |job| {
150                    #[cfg(unix)]
151                    if runtime.stream_job(&request, job)? {
152                        return Ok(());
153                    }
154                    let value = job.run(Box::pin(async {
155                        #[cfg(unix)]
156                        if request["command"].as_str().is_some_and(|c| {
157                            c.starts_with("remote.")
158                                || c.starts_with("terminal.")
159                                || c == "search"
160                                || c.starts_with("git")
161                                || c.starts_with("fs.")
162                                || c.starts_with("workspace.")
163                                || c.starts_with("preferences.")
164                                || c == "capabilities"
165                        }) {
166                            return runtime
167                                .call_with_cancellation(&request, Some(job.cancellation()));
168                        }
169                        services
170                            .call(&request)
171                            .map_err(|e| fail("service_error", e))
172                    }))?;
173                    job.emit(value)
174                })?;
175                return Ok(json!({"id":id}));
176            }
177            "service.poll" => return self.jobs.get(number(r, "id")?)?.poll(),
178            "service.cancel" => {
179                self.jobs.get(number(r, "id")?)?.cancel()?;
180                return Ok(Value::Null);
181            }
182            "service.release" => {
183                self.jobs.release(number(r, "id")?)?;
184                return Ok(Value::Null);
185            }
186            _ => {}
187        }
188        #[cfg(unix)]
189        match command {
190            "document.recovery" => {
191                return self.checkpoint_recovery(
192                    number(r, "id")?,
193                    r.get("expected_owner")
194                        .map(|_| number(r, "expected_owner"))
195                        .transpose()?,
196                );
197            }
198            "document.discard_recovery" => return self.discard_document_recovery(number(r, "id")?),
199            "document.inspect_save" => return self.inspect_save(number(r, "id")?),
200            "document.close" => return self.close_document(number(r, "id")?, r["discard"] == true),
201            _ => {}
202        }
203        if command == "document.save" {
204            return self.save(number(r, "id")?);
205        }
206        if command == "document.analyze" || command == "document.editor_analyze" {
207            let id = number(r, "id")?;
208            let requested = if command == "document.analyze" {
209                Some(string(r, "language")?)
210            } else {
211                None
212            };
213            let input = self.capture_analysis(id, requested)?;
214            let result =
215                language::analyze(&self.language, id, input.snapshot.clone(), &input.language)?;
216            self.validate_analysis(&input)?;
217            return Ok(result);
218        }
219        if !command.starts_with("document.") {
220            return self
221                .services
222                .call(r)
223                .map_err(|message| fail("service_error", message));
224        }
225        let mut documents = self.documents()?;
226        if command == "document.open" {
227            let text = string(r, "text")?;
228            if text.len() > 64 * 1024 * 1024 {
229                return Err(fail("too_large", "Document exceeds 64 MiB"));
230            }
231            let path = r
232                .get("path")
233                .filter(|p| !p.is_null())
234                .map(|_| string(r, "path").map(str::to_owned))
235                .transpose()?;
236            let remote = r
237                .get("remote")
238                .filter(|v| !v.is_null())
239                .map(|_| string(r, "remote").map(str::to_owned))
240                .transpose()?;
241            let version = r
242                .get("version")
243                .filter(|v| !v.is_null())
244                .map(|_| string(r, "version").map(str::to_owned))
245                .transpose()?;
246            if path.is_some() && version.is_none() {
247                return Err(fail(
248                    "invalid_request",
249                    "A bound document requires its observed file version",
250                ));
251            }
252            let persistent = r
253                .get("persistent_id")
254                .filter(|v| !v.is_null())
255                .map(|v| {
256                    serde_json::from_value::<buntrail_core::contracts::DocumentID>(v.clone())
257                        .map_err(|e| fail("invalid_request", e.to_string()))
258                })
259                .transpose()?;
260            if persistent.is_some()
261                && documents
262                    .open
263                    .values()
264                    .any(|d| d.persistent_id == persistent)
265            {
266                return Err(fail("already_open", "Persistent document is already open"));
267            }
268            let id = documents
269                .next_id
270                .checked_add(1)
271                .ok_or_else(|| fail("exhausted", "Document IDs exhausted"))?;
272            documents.next_id = id;
273            let mut document = Document::new(text, path, version, remote);
274            if let Some(source) = r.get("encoding_source").filter(|v| !v.is_null()) {
275                let source = source
276                    .as_str()
277                    .ok_or_else(|| fail("invalid_request", "encoding_source must be a string"))?;
278                #[cfg(unix)]
279                {
280                    Services::file_binding(source).map_err(|e| fail("invalid_request", e))?;
281                    document.encoding_source = Some(source.to_owned());
282                }
283                #[cfg(not(unix))]
284                return Err(fail(
285                    "unavailable",
286                    "Recovered codec metadata requires typed file services on this platform",
287                ));
288            }
289            document.persistent_id = persistent;
290            let response = document.state(id);
291            documents.open.insert(id, document);
292            return Ok(response);
293        }
294        let id = number(r, "id")?;
295        let document = documents
296            .open
297            .get_mut(&id)
298            .ok_or_else(|| fail("not_found", "Document is no longer open"))?;
299        #[cfg(unix)]
300        if matches!(
301            command,
302            "document.bind" | "document.rebind" | "document.reload"
303        ) && (document.closing || document.pending_save.is_some())
304        {
305            return Err(fail(
306                "save_pending",
307                "Resolve the pending save or recovery cleanup before changing bindings",
308            ));
309        }
310        match command {
311            "document.get" => Ok(document.state(id)),
312            #[cfg(unix)]
313            "document.reconnect" => {
314                if document.saving || document.closing {
315                    return Err(fail(
316                        "save_pending",
317                        "Wait for the active save or cleanup before reconnecting",
318                    ));
319                }
320                if document.remote.as_deref() != Some(string(r, "expected_remote")?) {
321                    return Err(fail(
322                        "stale_revision",
323                        "Document view changed while reconnecting",
324                    ));
325                }
326                let remote = string(r, "remote")?;
327                let token = document
328                    .version
329                    .as_deref()
330                    .filter(|v| v.starts_with("bt2:"))
331                    .or(document.encoding_source.as_deref())
332                    .ok_or_else(|| {
333                        fail(
334                            "wrong_target",
335                            "Reopen legacy documents through the verified environment",
336                        )
337                    })?;
338                let original =
339                    Services::file_binding(token).map_err(|e| fail("wrong_target", e))?;
340                if original.file.target != self.runtime.verified_target(remote)? {
341                    return Err(fail(
342                        "wrong_target",
343                        "Reconnected target differs from the original file",
344                    ));
345                }
346                document.recovery_owner = document
347                    .recovery_owner
348                    .checked_add(1)
349                    .ok_or_else(|| fail("exhausted", "Recovery ownership exhausted"))?;
350                document.remote = Some(remote.to_owned());
351                Ok(document.state(id))
352            }
353            "document.rebind" => {
354                if document.saving {
355                    return Err(fail("save_pending", "Wait for the pending save"));
356                }
357                let remote = r
358                    .get("remote")
359                    .filter(|v| !v.is_null())
360                    .map(|_| string(r, "remote"))
361                    .transpose()?;
362                if document.path.as_deref() != Some(string(r, "expected_path")?)
363                    || document.remote.as_deref() != remote
364                {
365                    return Err(fail(
366                        "wrong_file",
367                        "The file or environment no longer matches",
368                    ));
369                }
370                #[cfg(unix)]
371                let observed = if let Some(observed) = r.get("version").and_then(Value::as_str) {
372                    let previous = document
373                        .version
374                        .as_deref()
375                        .ok_or_else(|| fail("wrong_file", "File binding is missing"))?;
376                    if string(r, "expected_version")? != previous {
377                        return Err(fail("stale_version", "File binding changed while moving"));
378                    }
379                    Some(
380                        Services::rebound_version(previous, observed, string(r, "path")?)
381                            .map_err(|message| fail("wrong_file", message))?,
382                    )
383                } else {
384                    None
385                };
386                #[cfg(unix)]
387                self.rotate_recovery(document)?;
388                #[cfg(unix)]
389                if let Some(observed) = observed {
390                    document.version = Some(observed);
391                }
392                document.path = Some(string(r, "path")?.to_owned());
393                Ok(document.state(id))
394            }
395            "document.status" => Ok(document.metadata(id)),
396            "document.reload" => {
397                if number(r, "revision")? != document.buffer.revision() {
398                    return Err(fail(
399                        "stale_revision",
400                        "Document changed while reading the file",
401                    ));
402                }
403                if document.saving {
404                    return Err(fail("save_pending", "Wait for the pending save"));
405                }
406                if document.buffer.checkpoint() != document.saved_state {
407                    return Err(fail(
408                        "dirty_document",
409                        "External changes cannot replace unsaved work",
410                    ));
411                }
412                let remote = r
413                    .get("remote")
414                    .filter(|v| !v.is_null())
415                    .map(|_| string(r, "remote"))
416                    .transpose()?;
417                if document.path.as_deref() != Some(string(r, "path")?)
418                    || document.remote.as_deref() != remote
419                {
420                    return Err(fail(
421                        "wrong_file",
422                        "The file or environment no longer matches",
423                    ));
424                }
425                if document.version.as_deref() != Some(string(r, "expected_version")?) {
426                    return Err(fail(
427                        "stale_version",
428                        "The file binding changed while reading",
429                    ));
430                }
431                let version = string(r, "version")?.to_owned();
432                let text = string(r, "text")?;
433                document.edit(&json!({"revision":document.buffer.revision(),"start":0,"end":document.buffer.text().encode_utf16().count(),"text":text}), id)?;
434                document.version = Some(version);
435                document.saved_state = document.buffer.checkpoint();
436                Ok(document.state(id))
437            }
438            "document.bind" => {
439                if document.buffer.has_composition() {
440                    return Err(fail("composition", "Resolve composition before binding"));
441                }
442                if number(r, "revision")? != document.buffer.revision() {
443                    return Err(fail(
444                        "stale_revision",
445                        "Document changed after the file snapshot was created",
446                    ));
447                }
448                if document.saving {
449                    return Err(fail("save_pending", "Wait for the pending save"));
450                }
451                let path = string(r, "path")?.to_owned();
452                let version = string(r, "version")?.to_owned();
453                let remote = r
454                    .get("remote")
455                    .filter(|value| !value.is_null())
456                    .map(|_| string(r, "remote").map(str::to_owned))
457                    .transpose()?;
458                #[cfg(unix)]
459                self.rotate_recovery(document)?;
460                document.buffer.end_typing();
461                document.path = Some(path);
462                document.version = Some(version);
463                document.remote = remote;
464                document.saved_state = document.buffer.checkpoint();
465                Ok(document.state(id))
466            }
467            "document.edit" => document.edit(r, id),
468            "document.undo" => document.history(id, true),
469            "document.redo" => document.history(id, false),
470            #[cfg(not(unix))]
471            "document.close" => {
472                if document.saving {
473                    return Err(fail("save_pending", "Wait for the pending save"));
474                }
475                if document.buffer.checkpoint() != document.saved_state
476                    && r.get("discard").and_then(Value::as_bool) != Some(true)
477                {
478                    return Err(fail(
479                        "dirty_document",
480                        "Save changes or explicitly discard before closing",
481                    ));
482                }
483                documents.open.remove(&id);
484                Ok(Value::Null)
485            }
486            _ => Err(fail(
487                "unknown_command",
488                format!("Unknown command: {command}"),
489            )),
490        }
491    }
492    fn capture_analysis(&self, id: u64, requested: Option<&str>) -> Result<AnalysisInput> {
493        let (snapshot, association, path, override_id) = {
494            let documents = self.documents()?;
495            let document = documents
496                .open
497                .get(&id)
498                .ok_or_else(|| fail("not_found", "Document is no longer open"))?;
499            (
500                document.buffer.snapshot(),
501                document.language_generation,
502                document.path.clone(),
503                document.language.clone(),
504            )
505        };
506        let language = if let Some(requested) = requested {
507            requested.to_owned()
508        } else {
509            if snapshot.text.len() > 1024 * 1024 {
510                return Err(fail(
511                    "analysis_limit",
512                    "Structural analysis is disabled above 1 MiB; text remains editable",
513                ));
514            }
515            let first_line = snapshot
516                .text
517                .lines()
518                .next()
519                .map(|line| line.to_string())
520                .unwrap_or_default();
521            buntrail_language::registry::select(
522                path.as_deref().unwrap_or(""),
523                &first_line,
524                override_id.as_deref(),
525            )
526            .into()
527        };
528        Ok(AnalysisInput {
529            id,
530            snapshot,
531            association,
532            path,
533            language,
534        })
535    }
536    fn validate_analysis(&self, input: &AnalysisInput) -> Result<()> {
537        let documents = self.documents()?;
538        let current = documents
539            .open
540            .get(&input.id)
541            .ok_or_else(|| fail("not_found", "Document is no longer open"))?;
542        if current.buffer.revision() != input.snapshot.revision
543            || current.language_generation != input.association
544            || current.path != input.path
545        {
546            return Err(fail(
547                "stale_revision",
548                "Document or language changed during analysis",
549            ));
550        }
551        Ok(())
552    }
553    pub fn set_language(&self, id: u64, language: Option<String>) -> Result<()> {
554        if let Some(name) = &language {
555            language::canonical(name)?;
556        }
557        let mut documents = self.documents()?;
558        let document = documents
559            .open
560            .get_mut(&id)
561            .ok_or_else(|| fail("not_found", "Document is no longer open"))?;
562        document.language = language;
563        document.language_generation = document
564            .language_generation
565            .checked_add(1)
566            .ok_or_else(|| fail("exhausted", "Language generation exhausted"))?;
567        Ok(())
568    }
569    pub fn snapshot(&self, id: u64) -> Result<Snapshot> {
570        self.documents()?
571            .open
572            .get(&id)
573            .map(|d| d.buffer.snapshot())
574            .ok_or_else(|| fail("not_found", "Document is no longer open"))
575    }
576    pub fn transaction(
577        &self,
578        id: u64,
579        transaction: EditTransaction,
580        selection: Option<BufferRange>,
581        typing: u32,
582    ) -> Result<EditResult> {
583        self.documents()?
584            .open
585            .get_mut(&id)
586            .ok_or_else(|| fail("not_found", "Document is no longer open"))?
587            .transaction(transaction, selection, typing)
588    }
589    #[cfg(not(unix))]
590    fn save(&self, id: u64) -> Result<Value> {
591        self.save_with(id, |write, remote| {
592            match remote {
593                Some(remote) => self
594                    .services
595                    .call(&json!({"command":"remote.call","id":remote,"request":write})),
596                None => self.services.call(&write),
597            }
598            .map_err(|message| fail("save_failed", message))
599        })
600    }
601    #[cfg(not(unix))]
602    fn save_with(
603        &self,
604        id: u64,
605        writer: impl FnOnce(Value, Option<String>) -> Result<Value>,
606    ) -> Result<Value> {
607        let (path, version, remote, text, state) = {
608            let mut documents = self.documents()?;
609            let document = documents
610                .open
611                .get_mut(&id)
612                .ok_or_else(|| fail("not_found", "Document is no longer open"))?;
613            if document.saving {
614                return Err(fail("save_pending", "A save is already in progress"));
615            }
616            let path = document
617                .path
618                .clone()
619                .ok_or_else(|| fail("unbound_document", "Choose a file before saving"))?;
620            let version = document
621                .version
622                .clone()
623                .ok_or_else(|| fail("invalid_request", "Observed file version is missing"))?;
624            if document.buffer.has_composition() {
625                return Err(fail("composition", "Resolve composition before saving"));
626            }
627            document.buffer.end_typing();
628            document.saving = true;
629            (
630                path,
631                version,
632                document.remote.clone(),
633                document
634                    .buffer
635                    .confirmed_snapshot()
636                    .map_err(document::core_error)?
637                    .text,
638                document.buffer.checkpoint(),
639            )
640        };
641        // The document mutex is released before filesystem work so typing never waits on disk.
642        let write =
643            json!({"command":"fs.write","path":path,"version":version,"text":text.to_string()});
644        let response = writer(write, remote);
645        let mut documents = self.documents()?;
646        let document = documents
647            .open
648            .get_mut(&id)
649            .ok_or_else(|| fail("not_found", "Document closed during save"))?;
650        document.saving = false;
651        let result = response?;
652        document.version = Some(string(&result, "version")?.to_owned());
653        document.saved_state = state;
654        Ok(document.state(id))
655    }
656}
657
658#[cfg(test)]
659mod publication_tests {
660    use super::*;
661    #[test]
662    fn captured_analysis_rejects_changed_revision_language_and_closed_document() {
663        let engine = Engine::new();
664        let opened = engine.call(json!({"command":"document.open","text":"function hello() {}"}));
665        let id = opened["value"]["id"].as_u64().unwrap();
666        let input = engine.capture_analysis(id, Some("js")).unwrap();
667        engine.set_language(id, Some("typescript".into())).unwrap();
668        assert_eq!(
669            engine.validate_analysis(&input).unwrap_err().code,
670            "stale_revision"
671        );
672        let input = engine.capture_analysis(id, Some("js")).unwrap();
673        engine.call(
674            json!({"command":"document.edit","id":id,"revision":0,"start":0,"end":0,"text":"//\n"}),
675        );
676        assert_eq!(
677            engine.validate_analysis(&input).unwrap_err().code,
678            "stale_revision"
679        );
680        let input = engine.capture_analysis(id, Some("js")).unwrap();
681        engine.call(json!({"command":"document.close","id":id,"discard":true}));
682        assert_eq!(
683            engine.validate_analysis(&input).unwrap_err().code,
684            "not_found"
685        );
686    }
687}
688
689#[cfg(test)]
690mod concurrent_save_tests {
691    use super::*;
692    #[test]
693    fn edit_between_capture_and_real_write_saves_only_submitted_checkpoint() {
694        let directory = tempfile::tempdir().unwrap();
695        let path = directory.path().join("concurrent.txt");
696        std::fs::write(&path, "old").unwrap();
697        let engine = Engine::new();
698        let read = engine.call(json!({"command":"fs.read","path":path}));
699        let open=engine.call(json!({"command":"document.open","text":"old","path":path,"version":read["value"]["version"]}));
700        let id = open["value"]["id"].as_u64().unwrap();
701        engine.call(json!({"command":"document.edit","id":id,"revision":0,"start":0,"end":3,"text":"submitted"}));
702        let result=engine.save_with(id,|write,route|{
703    assert!(route.is_none());assert_eq!(write["text"],"submitted");
704    let edit=engine.call(json!({"command":"document.edit","id":id,"revision":1,"start":9,"end":9,"text":" later"}));assert_eq!(edit["ok"],true);
705    engine.services.call(&write).map_err(|message|fail("save_failed",message))
706  }).unwrap();
707        assert_eq!(result["text"], "submitted later");
708        assert_eq!(result["dirty"], true);
709        assert_eq!(std::fs::read_to_string(&path).unwrap(), "submitted");
710        let undo = engine.call(json!({"command":"document.undo","id":id}));
711        assert_eq!(undo["value"]["text"], "submitted");
712        assert_eq!(undo["value"]["dirty"], false);
713    }
714}
715
716#[cfg(all(test, unix))]
717mod recovery_save_tests {
718    use super::*;
719    use buntrail_core::contracts::{
720        MutationResult, ObservedState, OperationReceipt, OperationState, ReconciliationEvidence,
721    };
722    use std::os::unix::fs::MetadataExt;
723    fn value(engine: &Engine, request: Value) -> Value {
724        let result = engine.call(request);
725        assert_eq!(result["ok"], true, "{result}");
726        result["value"].clone()
727    }
728    fn bound(engine: &Engine, path: &std::path::Path) -> u64 {
729        std::fs::write(path, "old").unwrap();
730        let read = value(engine, json!({"command":"fs.read","path":path}));
731        value(
732            engine,
733            json!({"command":"document.open","path":path,"version":read["version"],"text":"old"}),
734        )["id"]
735            .as_u64()
736            .unwrap()
737    }
738    fn draft(engine: &Engine, path: &std::path::Path) -> Value {
739        use std::os::unix::ffi::OsStrExt;
740        let expected = serde_json::to_value(path.as_os_str().as_bytes()).unwrap();
741        let entries = value(engine, json!({"command":"recovery.list"}));
742        let entry = entries["entries"]
743            .as_array()
744            .unwrap()
745            .iter()
746            .find(|v| v["file"]["path"]["PosixBytes"] == expected)
747            .unwrap();
748        value(
749            engine,
750            json!({"command":"recovery.read","document":entry["document"]}),
751        )
752    }
753    #[test]
754    fn pending_snapshot_is_durable_before_write_and_later_edits_stay_recoverable() {
755        let root = tempfile::tempdir().unwrap();
756        let path = root.path().join("file");
757        let engine = Engine::new();
758        let id = bound(&engine, &path);
759        value(
760            &engine,
761            json!({"command":"document.edit","id":id,"revision":0,"start":0,"end":3,"text":"submitted"}),
762        );
763        engine.save_with(id,|write,_| {
764            let saved=draft(&engine,&path); assert_eq!(saved["text"],"submitted"); assert!(saved["pending_operation"].is_array());
765            assert_eq!(std::fs::read(&path).unwrap(),b"old");
766            value(&engine,json!({"command":"document.edit","id":id,"revision":1,"start":0,"end":9,"text":"later"}));
767            engine.services.save_document(&write).map_err(|e|fail("save_failed",e))
768        }).unwrap();
769        assert_eq!(std::fs::read(&path).unwrap(), b"submitted");
770        assert_eq!(
771            value(&engine, json!({"command":"document.get","id":id}))["dirty"],
772            true
773        );
774        let saved = draft(&engine, &path);
775        assert_eq!(saved["text"], "later");
776        assert!(saved["pending_operation"].is_null());
777    }
778    #[test]
779    fn lost_acknowledgement_is_inspected_without_replaying_save() {
780        let root = tempfile::tempdir().unwrap();
781        let path = root.path().join("file");
782        let engine = Engine::new();
783        let id = bound(&engine, &path);
784        value(
785            &engine,
786            json!({"command":"document.edit","id":id,"revision":0,"start":0,"end":3,"text":"submitted"}),
787        );
788        let error = engine
789            .save_with(id, |write, _| {
790                let mut response = engine
791                    .services
792                    .save_document(&write)
793                    .map_err(|e| fail("save_failed", e))?;
794                let mut receipt: OperationReceipt =
795                    serde_json::from_value(response["receipt"].clone()).unwrap();
796                let OperationState::Confirmed(MutationResult::Saved(saved)) = receipt.state else {
797                    panic!("real save must complete")
798                };
799                receipt.state = OperationState::Unknown(ReconciliationEvidence {
800                    target: saved.version.target,
801                    observed: ObservedState::Unobserved,
802                    explanation: "dropped acknowledgement".into(),
803                });
804                response["receipt"] = serde_json::to_value(receipt).unwrap();
805                Ok(response)
806            })
807            .unwrap_err();
808        assert_eq!(error.code, "save_uncertain");
809        let inode = std::fs::metadata(&path).unwrap().ino();
810        assert_eq!(
811            engine.call(json!({"command":"document.save","id":id}))["error"]["code"],
812            "save_uncertain"
813        );
814        assert_eq!(
815            engine.call(json!({"command":"document.close","id":id,"discard":true}))["error"]["code"],
816            "save_uncertain"
817        );
818        let inspected = value(&engine, json!({"command":"document.inspect_save","id":id}));
819        assert_eq!(inspected["dirty"], false);
820        assert!(inspected["pending_operation"].is_null());
821        assert_eq!(std::fs::metadata(&path).unwrap().ino(), inode);
822        assert_eq!(std::fs::read(&path).unwrap(), b"submitted");
823    }
824    #[test]
825    fn mismatched_saved_binding_remains_uncertain() {
826        let root = tempfile::tempdir().unwrap();
827        let path = root.path().join("file");
828        let other = root.path().join("other");
829        let engine = Engine::new();
830        let id = bound(&engine, &path);
831        std::fs::write(&other, "other").unwrap();
832        let foreign = value(&engine, json!({"command":"fs.read","path":other}))["version"].clone();
833        value(
834            &engine,
835            json!({"command":"document.edit","id":id,"revision":0,"start":0,"end":3,"text":"submitted"}),
836        );
837        let result = engine.save_with(id, |write, _| {
838            let mut response = engine
839                .services
840                .save_document(&write)
841                .map_err(|e| fail("save_failed", e))?;
842            response["version"] = foreign;
843            Ok(response)
844        });
845        assert!(
846            result.is_err(),
847            "a confirmed receipt cannot adopt another file's token"
848        );
849        assert_eq!(
850            value(&engine, json!({"command":"document.get","id":id}))["dirty"],
851            true
852        );
853        assert_eq!(std::fs::read(&other).unwrap(), b"other");
854    }
855}