1mod 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 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}