//! 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; struct HistoryEntry { snapshot: Arc, bytes: usize, } 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 { current: Arc, past: Vec, future: Vec, current_bytes: usize, saved: Arc, revision: u64, limits: ProjectLimits, } 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 { current, current_bytes, past: Vec::new(), future: Vec::new(), saved: Arc::new(bundle), revision: 0, limits, }) } pub fn project(&self) -> &Project { &self.current.decoded.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.bundle() != self.saved.as_ref() } 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 = Arc::new(snapshot.bundle.clone()); } 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) } 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); 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); true } fn commit(&mut self, compiled: CompiledSourceProject) -> bool { if compiled.bundle == self.current.bundle { return false; } let bytes = estimated_bytes(&compiled); self.past.push(HistoryEntry { snapshot: std::mem::replace(&mut self.current, Arc::new(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); true } } #[cfg(test)] mod tests { use super::*; use oa_core::{Kind, Layer}; fn document() -> Document { Document::from_project(Project::empty(), ProjectLimits::default()).unwrap() } 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)); } }