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