Skip to main content

buntrail_engine/
lib.rs

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