Save modular source projects with conflict checks and recoverable transactions

This commit is contained in:
2026-09-05 22:26:01 +03:00
parent 86bf36bf4f
commit 271f06225f
6 changed files with 1603 additions and 0 deletions
+1
View File
@@ -10,3 +10,4 @@
*.exe *.exe
/model-store/ /model-store/
/.local/ /.local/
.arcadia-save-*/
Generated
+1
View File
@@ -2143,6 +2143,7 @@ dependencies = [
name = "oa-core" name = "oa-core"
version = "0.2.0" version = "0.2.0"
dependencies = [ dependencies = [
"same-file",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
+1
View File
@@ -11,3 +11,4 @@ serde_json.workspace = true
uuid.workspace = true uuid.workspace = true
thiserror.workspace = true thiserror.workspace = true
tempfile = "3" tempfile = "3"
same-file = "1"
+1
View File
@@ -7,6 +7,7 @@ pub mod projection;
pub mod references; pub mod references;
pub mod routing; pub mod routing;
pub mod session; pub mod session;
pub mod source_files;
pub mod source_project; pub mod source_project;
pub mod transitions; pub mod transitions;
pub mod validation; pub mod validation;
File diff suppressed because it is too large Load Diff
+385
View File
@@ -0,0 +1,385 @@
use oa_core::{source_files::*, source_project::*};
use std::{collections::BTreeMap, fs};
fn setup() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; }\n",
)
.unwrap();
fs::create_dir(directory.path().join("parts")).unwrap();
fs::write(
directory.path().join("parts/a.arcadia"),
"// rationale\noa_activity a \"A\" {}\n",
)
.unwrap();
directory
}
#[test]
fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
let directory = setup();
let entry = directory.path().join("project.arcadia");
let snapshot = load_project(&entry, ProjectLimits::default()).unwrap();
assert!(
snapshot
.diagnostics
.iter()
.all(|d| d.diagnostic.severity != oa_core::validation::Severity::Error)
);
let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
assert_ne!(compiled.bundle, snapshot.bundle);
for (path, expected) in &snapshot.expectations {
assert_eq!(
*expected,
DiskExpectation::Bytes(fs::read(directory.path().join(path)).unwrap())
);
}
let saved = save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap();
assert_eq!(saved.bundle, compiled.bundle);
let reopened = load_project(&entry, ProjectLimits::default()).unwrap();
assert_eq!(reopened.bundle, compiled.bundle);
assert_eq!(
compile_bundle(&reopened.reachable_bundle(), ProjectLimits::default())
.unwrap()
.decoded
.project,
compiled.decoded.project
);
assert_eq!(
save_project(&saved, &compiled, ProjectLimits::default())
.unwrap()
.bundle,
compiled.bundle
);
}
#[test]
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
let directory = setup();
let entry = directory.path().join("project.arcadia");
fs::write(&entry, "arcadia 3; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write(
directory.path().join("parts/a.arcadia"),
"oa_activity a \"A\" { invalid",
)
.unwrap();
let snapshot = load_project(&entry, ProjectLimits::default()).unwrap();
assert_eq!(snapshot.bundle.files.len(), 2);
assert_eq!(
snapshot.expectations["missing.arcadia"],
DiskExpectation::Missing
);
assert!(
snapshot
.diagnostics
.iter()
.any(|d| d.path == "project.arcadia"
&& d.diagnostic.line == 2
&& d.diagnostic.code == "missing_include")
);
assert!(
snapshot
.diagnostics
.iter()
.any(|d| d.path == "parts/a.arcadia")
);
assert!(snapshot.bundle.files["parts/a.arcadia"].ends_with("invalid"));
}
#[test]
fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
let directory = setup();
let entry = directory.path().join("project.arcadia");
let original = load_project(&entry, ProjectLimits::default()).unwrap();
fs::write(directory.path().join("parts/a.arcadia"), "// external edit").unwrap();
fs::write(
directory.path().join("new.arcadia"),
"oa_activity b \"B\" {}",
)
.unwrap();
let overlays = BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 3; project p \"P\" { include \"new.arcadia\"; }".into(),
),
(
"parts/a.arcadia".into(),
"// unfinished detached draft\noa_activity".into(),
),
]);
let discovered = discover_missing(&original, &overlays, ProjectLimits::default()).unwrap();
assert_eq!(
discovered.bundle.files["parts/a.arcadia"],
overlays["parts/a.arcadia"]
);
assert_eq!(
discovered.expectations["parts/a.arcadia"],
original.expectations["parts/a.arcadia"]
);
assert!(discovered.bundle.files.contains_key("new.arcadia"));
assert!(
!discovered
.reachable_bundle()
.files
.contains_key("parts/a.arcadia")
);
assert!(
discovered
.diagnostics
.iter()
.all(|d| d.diagnostic.severity != oa_core::validation::Severity::Error)
);
}
#[test]
fn unchanged_dependency_edits_and_deletions_block_save_before_any_write() {
for deletion in [false, true] {
let directory = setup();
let entry = directory.path().join("project.arcadia");
let snapshot = load_project(&entry, ProjectLimits::default()).unwrap();
let compiled =
compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
let snapshot = save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap();
let mut bundle = compiled.bundle.clone();
bundle
.files
.get_mut("project.arcadia")
.unwrap()
.push_str("// changed\n");
let changed = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let child = directory.path().join("parts/a.arcadia");
if deletion {
fs::remove_file(&child).unwrap();
} else {
fs::write(&child, "// another editor").unwrap();
}
assert!(matches!(
save_project(&snapshot, &changed, ProjectLimits::default()),
Err(SourceFileError::Conflict(_))
));
assert_eq!(
fs::read_to_string(&entry).unwrap(),
compiled.bundle.files["project.arcadia"]
);
}
}
#[test]
fn new_destinations_require_explicit_missing_expectations_and_reject_collisions() {
let directory = tempfile::tempdir().unwrap();
let entry = directory.path().join("project.arcadia");
let mut snapshot = new_project(&entry, ProjectLimits::default()).unwrap();
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }".into(),
),
("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert!(matches!(
save_project(&snapshot, &compiled, ProjectLimits::default()),
Err(SourceFileError::UnknownExpectation(_))
));
expect_new_file(&mut snapshot, "child.arcadia", ProjectLimits::default()).unwrap();
fs::write(
directory.path().join("child.arcadia"),
"// pre-existing work",
)
.unwrap();
assert!(matches!(
save_project(&snapshot, &compiled, ProjectLimits::default()),
Err(SourceFileError::Conflict(_))
));
assert!(!entry.exists());
assert!(expect_new_file(&mut snapshot, "child.arcadia", ProjectLimits::default()).is_err());
}
#[test]
fn aggregate_budgets_and_portable_paths_are_checked_before_loading_excess_data() {
let directory = setup();
let entry = directory.path().join("project.arcadia");
let mut limits = ProjectLimits {
max_files: 1,
..ProjectLimits::default()
};
let snapshot = load_project(&entry, limits).unwrap();
assert_eq!(snapshot.bundle.files.len(), 1);
assert!(
snapshot
.diagnostics
.iter()
.any(|d| d.diagnostic.code == "project_limit")
);
limits.max_files = 256;
limits.model.max_source_bytes = fs::metadata(&entry).unwrap().len() as usize + 5;
let snapshot = load_project(&entry, limits).unwrap();
assert_eq!(snapshot.bundle.files.len(), 1);
assert!(
snapshot
.diagnostics
.iter()
.any(|d| d.diagnostic.code == "source_limit")
);
let mut snapshot = new_project(
&directory.path().join("new.arcadia"),
ProjectLimits::default(),
)
.unwrap();
for invalid in [
"../escape.arcadia",
"a\\b.arcadia",
"CON.arcadia",
"/absolute.arcadia",
"a/../b.arcadia",
".arcadia-save-project.arcadia.journal/intrusion.arcadia",
] {
assert!(
expect_new_file(&mut snapshot, invalid, ProjectLimits::default()).is_err(),
"{invalid}"
);
}
}
#[test]
fn physical_hard_link_aliases_are_rejected() {
let directory = setup();
fs::hard_link(
directory.path().join("parts/a.arcadia"),
directory.path().join("alias.arcadia"),
)
.unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
)
.unwrap();
assert!(matches!(
load_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
),
Err(SourceFileError::Path(_))
));
}
#[cfg(unix)]
#[test]
fn symlinks_to_outside_or_inside_project_are_rejected() {
let directory = setup();
let outside = tempfile::tempdir().unwrap();
fs::write(
outside.path().join("outside.arcadia"),
"oa_activity outside \"Outside\" {}",
)
.unwrap();
for destination in [
outside.path().join("outside.arcadia"),
directory.path().join("parts/a.arcadia"),
] {
let link = directory.path().join("alias.arcadia");
std::os::unix::fs::symlink(&destination, &link).unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 3; project p \"P\" { include \"alias.arcadia\"; }",
)
.unwrap();
let loaded = load_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default(),
)
.unwrap();
assert!(
loaded
.diagnostics
.iter()
.any(|d| d.diagnostic.code == "invalid_path")
);
assert!(!loaded.bundle.files.contains_key("alias.arcadia"));
fs::remove_file(&link).unwrap();
}
}
#[test]
fn forged_compiled_model_or_index_cannot_be_saved() {
let directory = setup();
let entry = directory.path().join("project.arcadia");
let snapshot = load_project(&entry, ProjectLimits::default()).unwrap();
let mut compiled =
compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
compiled.decoded.project.name = "forged".into();
assert!(matches!(
save_project(&snapshot, &compiled, ProjectLimits::default()),
Err(SourceFileError::Invalid(_))
));
assert_eq!(
fs::read(&entry).unwrap(),
snapshot.bundle.files["project.arcadia"].as_bytes()
);
}
#[cfg(unix)]
#[test]
fn named_pipes_are_rejected_before_opening_on_load_discovery_and_save() {
let directory = setup();
let entry = directory.path().join("project.arcadia");
let child = directory.path().join("parts/a.arcadia");
let snapshot = load_project(&entry, ProjectLimits::default()).unwrap();
let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
fs::remove_file(&child).unwrap();
assert!(
std::process::Command::new("mkfifo")
.arg(&child)
.status()
.unwrap()
.success()
);
let loaded = load_project(&entry, ProjectLimits::default()).unwrap();
assert!(
loaded
.diagnostics
.iter()
.any(|d| d.diagnostic.code == "invalid_path"
&& d.diagnostic.message.contains("not a regular file"))
);
assert!(!loaded.bundle.files.contains_key("parts/a.arcadia"));
assert!(matches!(
discover_missing(&snapshot, &BTreeMap::new(), ProjectLimits::default()),
Err(SourceFileError::Path(_))
));
assert!(matches!(
save_project(&snapshot, &compiled, ProjectLimits::default()),
Err(SourceFileError::Path(_))
));
assert_eq!(
fs::read_to_string(&entry).unwrap(),
snapshot.bundle.files["project.arcadia"]
);
}
#[cfg(unix)]
#[test]
fn atomic_save_preserves_existing_unix_permissions() {
use std::os::unix::fs::PermissionsExt;
let directory = setup();
let entry = directory.path().join("project.arcadia");
let child = directory.path().join("parts/a.arcadia");
fs::set_permissions(&entry, fs::Permissions::from_mode(0o664)).unwrap();
fs::set_permissions(&child, fs::Permissions::from_mode(0o640)).unwrap();
let snapshot = load_project(&entry, ProjectLimits::default()).unwrap();
let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap();
assert_eq!(
fs::metadata(&entry).unwrap().permissions().mode() & 0o777,
0o664
);
assert_eq!(
fs::metadata(&child).unwrap().permissions().mode() & 0o777,
0o640
);
}