//! Accepted graphical and source state, committed and restored together. //! Editor drafts and filesystem receipts belong to their respective adapters. use oa_core::{ Command, Id, ModelError, Project, language, prepare_command, source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic}, validation::Severity, }; use std::{fmt, sync::Arc}; const HISTORY_LIMIT: usize = 100; const HISTORY_BYTES: usize = 32 * 1024 * 1024; #[path = "document_alias_rename.rs"] mod alias_rename; pub use alias_rename::PreparedAliasRename; struct HistoryEntry { snapshot: Arc, bytes: usize, } struct RelocatedHistory { current: Arc, past: Vec, future: Vec, } fn estimated_bytes(compiled: &CompiledSourceProject) -> usize { compiled .bundle .files .values() .fold(0usize, |sum, text| sum.saturating_add(text.len())) .saturating_add( serde_json::to_vec(&compiled.decoded.project).map_or(usize::MAX, |bytes| bytes.len()), ) } #[derive(Debug)] pub enum DocumentError { Model(ModelError), Source(Vec), } impl DocumentError { pub fn diagnostics(&self) -> &[SourceDiagnostic] { match self { Self::Model(_) => &[], Self::Source(diagnostics) => diagnostics, } } } impl fmt::Display for DocumentError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Model(error) => error.fmt(formatter), Self::Source(diagnostics) => { if let Some(first) = diagnostics.first() { write!( formatter, "{}:{}:{}: {}", first.path, first.diagnostic.line, first.diagnostic.column, first.diagnostic.message ) } else { formatter.write_str("Source validation failed.") } } } } } impl std::error::Error for DocumentError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Model(error) => Some(error), Self::Source(_) => None, } } } impl From for DocumentError { fn from(error: ModelError) -> Self { Self::Model(error) } } impl From> for DocumentError { fn from(diagnostics: Vec) -> Self { Self::Source(diagnostics) } } pub struct Document { // Distinguishes equal accepted snapshots belonging to different documents. alias_owner: Arc<()>, current: Arc, past: Vec, future: Vec, current_bytes: usize, saved: Option>, revision: u64, view_index: std::cell::OnceCell, limits: ProjectLimits, recovered_unsaved: bool, } impl Document { /// A valid generated workspace starts clean, like a new semantic Session. pub fn from_project(project: Project, limits: ProjectLimits) -> Result { oa_core::validate_candidate(&project)?; let document = Self::from_bundle(source_project::format_bundle(&project), limits)?; if document.project() != &project { return Err(DocumentError::Model(ModelError( "Generated source does not reproduce the supplied project.".into(), ))); } Ok(document) } /// Retain the exact input as the saved baseline. If compilation inserts /// missing IDs, those accepted source changes still need saving. pub fn from_bundle(bundle: SourceBundle, limits: ProjectLimits) -> Result { let current = Arc::new(source_project::compile_bundle(&bundle, limits)?); let current_bytes = estimated_bytes(¤t); Ok(Self { alias_owner: Arc::new(()), current, current_bytes, past: Vec::new(), future: Vec::new(), saved: Some(Arc::new(bundle)), revision: 0, view_index: Default::default(), limits, recovered_unsaved: false, }) } pub fn project(&self) -> &Project { &self.current.decoded.project } /// Semantic reachability is shared by every diagram at this accepted revision. pub fn view_index(&self) -> &oa_core::view_options::ViewIndex { self.view_index .get_or_init(|| oa_core::view_options::ViewIndex::new(self.project())) } pub fn bundle(&self) -> &SourceBundle { &self.current.bundle } pub fn compiled(&self) -> &CompiledSourceProject { &self.current } pub fn revision(&self) -> u64 { self.revision } pub fn dirty(&self) -> bool { self.recovered_unsaved || self.saved.as_deref() != Some(self.bundle()) } /// Recovered work is a detached copy until explicitly saved by its user. pub fn mark_recovered(&mut self) { self.recovered_unsaved = true; } pub fn can_undo(&self) -> bool { !self.past.is_empty() } pub fn can_redo(&self) -> bool { !self.future.is_empty() } /// Capture this before asynchronous saving. Completion must return the same /// snapshot, rather than marking whatever is current at completion as saved. pub fn snapshot(&self) -> Arc { self.current.clone() } /// Records the bytes actually saved, even when later edits remain current. /// The I/O adapter owns document identity and save completion ordering. pub fn mark_saved(&mut self, snapshot: Arc) { self.saved = Some(Arc::new(snapshot.bundle.clone())); self.recovered_unsaved = false; } /// Bind an installed or recovered repair destination to source read there. /// These bytes may be invalid or differ from the retained accepted model, /// but must replace the previous disk baseline. Accepted history is retained; /// applying or undoing to old saved content does not save this destination. pub(crate) fn bind_repair_baseline(&mut self, source: SourceBundle) { self.saved = Some(Arc::new(source)); } /// Recovery changed the current destination, but its source could not be /// reloaded. No accepted or historical snapshot is known to be saved there. pub(crate) fn invalidate_saved_baseline(&mut self) { self.saved = None; } /// Check a source Save as before writing any files. A retained included file /// must never be displaced by the new entry, including on a later Undo. pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> { if entry == self.bundle().entry { return Ok(()); } self.relocated_history(entry).map(|_| ()) } /// Adopt a successfully saved source entry without recording an engineering /// edit or clearing Redo. The caller still marks the exact receipt as saved. pub fn adopt_saved_entry( &mut self, saved: &CompiledSourceProject, revision: u64, ) -> Result<(), DocumentError> { if revision != self.revision { return Err( ModelError("Newer edits retain the current source location.".into()).into(), ); } let RelocatedHistory { current, past, future, } = self.relocated_history(&saved.bundle.entry)?; if current.bundle != saved.bundle || current.decoded.project != saved.decoded.project || current.index != saved.index { return Err(ModelError( "The save receipt changes more than the source entry location.".into(), ) .into()); } if current.bundle != self.current.bundle { self.current_bytes = estimated_bytes(¤t); self.current = current; self.past = past; self.future = future; // Source-position readers must reject their old revision even though // changing the save location does not create an Undo transaction. self.revision = self.revision.saturating_add(1); self.view_index.take(); } Ok(()) } fn relocated_history(&self, entry: &str) -> Result { if entry.contains('/') || source_project::resolve_include("", entry).as_deref() != Ok(entry) { return Err(ModelError("Choose a portable .arcadia entry file name.".into()).into()); } let snapshots = std::iter::once(self.current.as_ref()) .chain(self.past.iter().map(|h| h.snapshot.as_ref())) .chain(self.future.iter().map(|h| h.snapshot.as_ref())); let mut uniform_entry = true; for snapshot in snapshots { uniform_entry &= snapshot.bundle.entry == self.bundle().entry; if snapshot.bundle.files.keys().any(|path| { path != &snapshot.bundle.entry && path.to_lowercase() == entry.to_lowercase() }) { return Err(ModelError(format!( "The entry name '{entry}' is used by an included file in this document or its Undo/Redo history. Choose another entry name." )).into()); } } let relocate = |snapshot: &Arc| -> Result, DocumentError> { if snapshot.bundle.entry == entry { return Ok(snapshot.clone()); } let mut bundle = snapshot.bundle.clone(); let text = bundle .files .remove(&bundle.entry) .ok_or_else(|| ModelError("The accepted source entry is missing.".into()))?; bundle.entry = entry.to_owned(); bundle.files.insert(entry.to_owned(), text); let compiled = source_project::compile_bundle(&bundle, self.limits)?; if compiled.bundle != bundle || compiled.decoded.project != snapshot.decoded.project { return Err(ModelError( "Changing the entry location would change the model or authored source.".into(), ) .into()); } Ok(Arc::new(compiled)) }; let current = relocate(&self.current)?; let history = |entries: &[HistoryEntry]| { entries .iter() .map(|old| { // Different entry names in history are authored source changes. // Keep those snapshots exact instead of collapsing real edits. let snapshot = if uniform_entry { relocate(&old.snapshot)? } else { old.snapshot.clone() }; Ok(HistoryEntry { bytes: estimated_bytes(&snapshot), snapshot, }) }) .collect::, DocumentError>>() }; Ok(RelocatedHistory { current, past: history(&self.past)?, future: history(&self.future)?, }) } pub fn execute(&mut self, command: Command) -> Result, DocumentError> { let candidate = prepare_command(self.project(), &command)?; // Equal model values can still carry different record provenance, such // as deleting and recreating an identical record with a reused UUID. // Only the resulting model/source pair determines whether this is a no-op. let compiled = source_project::update_bundle_with_identity_map( &self.current, &candidate.project, &candidate.identity_map, self.limits, )?; self.commit(compiled); Ok(candidate.selected) } #[cfg(test)] pub fn apply_bundle(&mut self, bundle: SourceBundle) -> Result { let compiled = source_project::compile_bundle(&bundle, self.limits)?; Ok(self.commit(compiled)) } /// Public compiled fields are convenient data, not a validation capability. /// Rebuild diagnostics/index and reject a stale or altered model/index pair. pub fn apply_compiled( &mut self, compiled: CompiledSourceProject, ) -> Result { let checked = source_project::compile_bundle(&compiled.bundle, self.limits)?; if checked.bundle != compiled.bundle || checked.decoded.project != compiled.decoded.project || checked.index != compiled.index { return Err(DocumentError::Source(vec![SourceDiagnostic { path: compiled.bundle.entry, diagnostic: language::Diagnostic { severity: Severity::Error, code: "stale_source_index".into(), object_id: None, field: None, line: 1, column: 1, message: "Compile the current documents before applying this source snapshot." .into(), span: language::Span::default(), }, related: Vec::new(), }])); } Ok(self.commit(checked)) } pub fn undo(&mut self) -> bool { let Some(previous) = self.past.pop() else { return false; }; self.future.push(HistoryEntry { snapshot: std::mem::replace(&mut self.current, previous.snapshot), bytes: std::mem::replace(&mut self.current_bytes, previous.bytes), }); self.revision = self.revision.saturating_add(1); self.view_index.take(); true } pub fn redo(&mut self) -> bool { let Some(next) = self.future.pop() else { return false; }; self.past.push(HistoryEntry { snapshot: std::mem::replace(&mut self.current, next.snapshot), bytes: std::mem::replace(&mut self.current_bytes, next.bytes), }); self.revision = self.revision.saturating_add(1); self.view_index.take(); true } fn commit(&mut self, compiled: CompiledSourceProject) -> bool { if compiled.bundle == self.current.bundle { return false; } let bytes = estimated_bytes(&compiled); self.commit_snapshot(Arc::new(compiled), bytes) } // Only validated internal paths supply a changed snapshot and its exact // estimate. The ordinary commit wrapper checks bundle inequality; the alias // path checks its private changed flag and exact accepted-snapshot authority. // Alias preparation shares this algorithm while retaining the live save and // history state, and does not clone/recompile the candidate on adoption. fn commit_snapshot(&mut self, compiled: Arc, bytes: usize) -> bool { self.past.push(HistoryEntry { snapshot: std::mem::replace(&mut self.current, compiled), bytes: std::mem::replace(&mut self.current_bytes, bytes), }); self.future.clear(); let mut retained = self.past.iter().fold(self.current_bytes, |sum, entry| { sum.saturating_add(entry.bytes) }); while !self.past.is_empty() && (self.past.len() > HISTORY_LIMIT || retained > HISTORY_BYTES) { retained = retained.saturating_sub(self.past.remove(0).bytes); } self.revision = self.revision.saturating_add(1); self.view_index.take(); true } } #[cfg(test)] #[path = "document_save_as_tests.rs"] mod save_as_tests; #[cfg(test)] mod tests { use super::*; use oa_core::{Kind, Layer}; fn document() -> Document { Document::from_project(Project::empty(), ProjectLimits::default()).unwrap() } #[test] fn cached_view_index_tracks_accepted_creation_undo_and_redo() { use oa_core::{Relation, RelationBody, RelationKind, view_options::ViewOptions}; let mut p = Project::empty(); let source = p .add_element(Kind::Entity, Layer::Oa, "Team", None) .unwrap(); let target = p .add_element(Kind::Actor, Layer::Oa, "Dispatcher", None) .unwrap(); let mut document = Document::from_project(p, ProjectLimits::default()).unwrap(); let id = Id::new_v4(); let options = ViewOptions::default(); let visible = |d: &Document| d.view_index().prepare(Layer::Oa, &options).includes(id); assert!(!visible(&document)); document .execute(Command::CreateRelation(Relation { id, alias: "radio".into(), name: "Radio".into(), description: String::new(), body: RelationBody::new(RelationKind::CommunicationMean, source, target), })) .unwrap(); assert!(visible(&document)); assert!(document.undo()); assert!(!visible(&document)); assert!(document.redo()); assert!(visible(&document)); } fn comment(bundle: &SourceBundle, text: &str) -> SourceBundle { let mut changed = bundle.clone(); changed .files .get_mut(&changed.entry) .unwrap() .push_str(text); changed } #[test] fn creation_selects_the_single_uuid_persisted_in_source() { let mut document = document(); let id = document .execute(Command::AddElement { kind: Kind::Activity, layer: Layer::Oa, name: "Assess incident".into(), parent: None, }) .unwrap() .unwrap(); assert_eq!(document.project().elements.len(), 1); assert_eq!(document.project().elements[&id].name, "Assess incident"); let location = &document.compiled().index.declarations[&id]; let declaration = &document.bundle().files[&location.path][location.span.start..location.span.end]; assert!(declaration.contains(&id.to_string())); let reopened = source_project::compile_bundle(document.bundle(), ProjectLimits::default()).unwrap(); assert_eq!(reopened.decoded.project, *document.project()); assert_eq!(document.revision(), 1); } #[test] fn rejected_compile_command_and_writeback_preserve_state_and_redo() { let bundle = source_project::format_bundle(&Project::empty()); let bytes = bundle.files.values().map(String::len).sum::(); let mut limits = ProjectLimits::default(); limits.model.max_source_bytes = bytes + 100; let mut document = Document::from_bundle(bundle, limits).unwrap(); document .apply_bundle(comment(document.bundle(), "// preserved future\n")) .unwrap(); assert!(document.undo()); let before = document.snapshot(); let revision = document.revision(); let history = (document.past.len(), document.future.len()); let malformed = comment(document.bundle(), "?"); assert!(document.apply_bundle(malformed).is_err()); assert!(document.execute(Command::Delete(Id::new_v4())).is_err()); let error = document .execute(Command::Rename(document.project().id, "x".repeat(200))) .unwrap_err(); assert!(!error.diagnostics().is_empty()); assert!(error.to_string().contains(&document.bundle().entry)); assert!(Arc::ptr_eq(&before, &document.snapshot())); assert_eq!(document.revision(), revision); assert_eq!((document.past.len(), document.future.len()), history); assert!(!document.dirty()); assert!(document.redo()); } #[test] fn comment_and_semantic_history_restore_exact_bytes_and_branch_together() { let mut document = document(); let initial = document.snapshot(); let annotated = comment( document.bundle(), "\r\n// Project rationale: preserve me.\r\n", ); assert!(document.apply_bundle(annotated.clone()).unwrap()); assert_eq!(document.project(), &initial.decoded.project); assert_eq!(document.revision(), 1); document .execute(Command::Rename( document.project().id, "Rescue workspace".into(), )) .unwrap(); let renamed = document.snapshot(); assert!(document.undo()); assert_eq!(document.bundle(), &annotated); assert!(document.undo()); assert_eq!(document.bundle(), &initial.bundle); assert!(!document.dirty()); assert!(document.redo()); assert_eq!(document.bundle(), &annotated); assert!(document.redo()); assert_eq!(document.bundle(), &renamed.bundle); assert!(document.undo()); let branch = comment(document.bundle(), "// A different source-only decision.\n"); assert!(document.apply_bundle(branch.clone()).unwrap()); assert!(!document.can_redo()); assert_eq!(document.project(), &initial.decoded.project); assert!(document.undo()); assert_eq!(document.bundle(), &annotated); assert!(document.redo()); assert_eq!(document.bundle(), &branch); } #[test] fn nested_cross_file_reparent_and_source_edit_share_exact_history() { let bundle = SourceBundle { entry: "project.arcadia".into(), files: [ ( "project.arcadia".into(), "arcadia 3; project rescue \"Rescue\" { include \"oa/source.arcadia\"; include \"oa/target.arcadia\"; }\n".into(), ), ( "oa/source.arcadia".into(), "// Source module\r\noa_activity respond \"Respond\" {\r\n // Assessment rationale follows this activity.\r\n oa_activity assess \"Assess\" {}\r\n}\r\n".into(), ), ( "oa/target.arcadia".into(), "// Destination module\noa_activity coordinate \"Coordinate\" {}\n".into(), ), ].into(), }; let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap(); document.mark_saved(document.snapshot()); let original = document.snapshot(); let id = |alias: &str| { original .decoded .project .elements .values() .find(|element| element.alias == alias) .unwrap() .id }; let child = id("assess"); let destination = id("coordinate"); document .execute(Command::Reparent { element: child, parent: Some(destination), }) .unwrap(); let moved = document.snapshot(); assert_eq!( document.project().elements[&child].parent, Some(destination) ); assert_eq!( document.compiled().index.declarations[&child].path, "oa/target.arcadia" ); assert!( document.bundle().files["oa/target.arcadia"] .contains("// Assessment rationale follows this activity.") ); assert!( !document.bundle().files["oa/source.arcadia"] .contains("// Assessment rationale follows this activity.") ); let edited = comment(document.bundle(), "// Reviewed containment decision.\n"); assert!(document.apply_bundle(edited.clone()).unwrap()); assert_eq!(document.project(), &moved.decoded.project); assert!(document.undo()); assert_eq!(document.bundle(), &moved.bundle); assert!(document.undo()); assert_eq!(document.bundle(), &original.bundle); assert_eq!(document.project(), &original.decoded.project); assert!(!document.dirty()); assert!(document.redo()); assert_eq!(document.bundle(), &moved.bundle); assert!(document.redo()); assert_eq!(document.bundle(), &edited); let accepted = document.snapshot(); let invalid = comment(document.bundle(), "invalid_declaration;"); assert!(document.apply_bundle(invalid).is_err()); assert!(Arc::ptr_eq(&accepted, &document.snapshot())); let reopened = Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap(); assert_eq!(reopened.project(), document.project()); assert_eq!(reopened.bundle(), document.bundle()); assert!(!reopened.dirty()); } #[test] fn recreated_equal_record_does_not_inherit_deleted_source_ownership() { let bundle = SourceBundle { entry: "project.arcadia".into(), files: [ ( "project.arcadia".into(), "arcadia 3; project rescue \"Rescue\" { include \"authored.arcadia\"; }\n" .into(), ), ( "authored.arcadia".into(), "// Rationale for the original record.\noa_activity assess \"Assess\" {}\n" .into(), ), ] .into(), }; let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap(); document.mark_saved(document.snapshot()); let before = document.snapshot(); let element = document.project().elements.values().next().unwrap().clone(); let id = element.id; document .execute(Command::Batch(vec![ Command::Delete(id), Command::CreateElement(element), ])) .unwrap(); assert_eq!(document.project(), &before.decoded.project); assert_ne!( document.compiled().index.declarations[&id].path, "authored.arcadia" ); assert!( document.bundle().files["authored.arcadia"] .contains("// Rationale for the original record.") ); assert!(!document.bundle().files["authored.arcadia"].contains("oa_activity")); assert!(document.dirty()); assert_eq!(document.revision(), 1); let recreated = document.snapshot(); assert!(document.undo()); assert_eq!(document.bundle(), &before.bundle); assert!(!document.dirty()); assert!(document.redo()); assert_eq!(document.bundle(), &recreated.bundle); } #[test] fn asynchronous_save_marks_its_snapshot_without_cleaning_later_edits() { let mut document = document(); document .apply_bundle(comment(document.bundle(), "// Saved rationale.\n")) .unwrap(); let saving = document.snapshot(); document .apply_bundle(comment(document.bundle(), "// Later unsaved rationale.\n")) .unwrap(); document.mark_saved(saving); assert!(document.dirty()); assert!(document.undo()); assert!(!document.dirty()); assert!(document.redo()); assert!(document.dirty()); } #[test] fn no_op_command_or_source_apply_preserves_history_revision_and_redo() { let mut document = document(); assert!(!document.undo()); assert!(!document.redo()); document .apply_bundle(comment(document.bundle(), "// Future edit.\n")) .unwrap(); assert!(document.undo()); let before = document.snapshot(); let revision = document.revision(); document .execute(Command::Rename( document.project().id, document.project().name.clone(), )) .unwrap(); assert!(!document.apply_bundle(document.bundle().clone()).unwrap()); assert!( !document .apply_compiled(document.compiled().clone()) .unwrap() ); assert!(Arc::ptr_eq(&before, &document.snapshot())); assert_eq!(document.revision(), revision); assert!(document.can_redo()); assert!(!document.can_undo()); } #[test] fn altered_compiled_model_or_index_is_rejected_without_mutation() { let mut document = document(); let before = document.snapshot(); let mut forged = document.compiled().clone(); forged.decoded.project.name = "Not in the source".into(); assert!(document.apply_compiled(forged).is_err()); let mut forged = document.compiled().clone(); forged.index.declarations.clear(); let error = document.apply_compiled(forged).unwrap_err(); assert_eq!(error.diagnostics()[0].diagnostic.code, "stale_source_index"); assert!(Arc::ptr_eq(&before, &document.snapshot())); assert_eq!(document.revision(), 0); assert!(!document.can_undo()); } #[test] fn omitted_id_stabilization_remains_dirty_until_exact_source_is_saved() { let bundle = SourceBundle { entry: "project.arcadia".into(), files: [( "project.arcadia".into(), "arcadia 3; project rescue \"Rescue\" {}\n".into(), )] .into(), }; let mut document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap(); assert!(document.dirty()); assert_ne!(document.bundle(), &bundle); let id = document.project().id; assert!(!document.apply_bundle(document.bundle().clone()).unwrap()); assert_eq!(document.project().id, id); document.mark_saved(document.snapshot()); assert!(!document.dirty()); } #[test] fn accepted_history_keeps_the_latest_hundred_complete_snapshots() { let mut document = document(); for change in 0..105 { document .apply_bundle(comment( document.bundle(), &format!("// Change {change}.\n"), )) .unwrap(); } assert_eq!(document.past.len(), HISTORY_LIMIT); let mut undone = 0; while document.undo() { undone += 1; } assert_eq!(undone, HISTORY_LIMIT); assert_eq!( document.bundle().files[&document.bundle().entry] .matches("// Change") .count(), 5 ); while document.redo() {} assert_eq!(document.past.len(), HISTORY_LIMIT); assert_eq!(document.revision(), 305); } #[test] fn invalid_project_is_rejected_before_format_can_normalize_it() { let mut project = Project::empty(); project.schema_version = 17; assert!(Document::from_project(project, ProjectLimits::default()).is_err()); } #[test] fn byte_budget_trims_oldest_history_without_discarding_a_large_current() { let mut document = document(); let rationale = format!("// {}\n", "x".repeat(11 * 1024 * 1024)); document .apply_bundle(comment(document.bundle(), &rationale)) .unwrap(); document .apply_bundle(comment(document.bundle(), "// Second version\n")) .unwrap(); document .apply_bundle(comment(document.bundle(), "// Third version\n")) .unwrap(); assert_eq!(document.past.len(), 1); let before = document.snapshot(); assert!(document.undo()); assert!(!document.can_undo()); assert!(document.redo()); assert!(Arc::ptr_eq(&before, &document.snapshot())); let large = format!("// {}\n", "y".repeat(22 * 1024 * 1024)); document .apply_bundle(comment(document.bundle(), &large)) .unwrap(); assert!(document.current_bytes > HISTORY_BYTES); assert!(!document.can_undo()); assert!(document.bundle().files[&document.bundle().entry].ends_with(&large)); } }