use oa_cli::{LIMITS, Operation, Prepared, prepare}; use oa_core::{Id, Project, source_files, source_project}; use serde_json::Value; use std::{ collections::BTreeMap, fs, path::{Path, PathBuf}, process::Command, }; struct Fixture(PathBuf); impl Fixture { fn new() -> Self { let path = std::env::temp_dir().join(format!("arcadia-cli-test-{}", Id::new_v4())); fs::create_dir(&path).unwrap(); Self(path) } fn put(&self, name: &str, text: &str) { let path = self.0.join(name); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, text).unwrap(); } fn entry(&self) -> PathBuf { self.0.join("project.arcadia") } fn read(&self, name: &str) -> String { fs::read_to_string(self.0.join(name)).unwrap() } fn prepare(&self, op: Operation) -> Prepared { prepare(&self.entry(), op).unwrap_or_else(|report| panic!("{report:#?}")) } fn model(&self) -> Project { let disk = source_files::load_project(&self.entry(), LIMITS).unwrap(); source_project::compile_bundle(&disk.reachable_bundle(), LIMITS) .unwrap() .decoded .project } fn cli(&self, args: &[&str]) -> (i32, Value) { let mut command = Command::new(env!("CARGO_BIN_EXE_arcadia")); command .arg(args[0]) .arg(self.entry()) .args(&args[1..]) .arg("--json"); let output = command.output().unwrap(); assert!( output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr) ); ( output.status.code().unwrap(), serde_json::from_slice(&output.stdout).unwrap(), ) } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } const SIMPLE: &str = "// 雨: authored defaults stay omitted\r\narcadia 7;\r\nproject rescue \"Rescue\" {\r\n oa_entity team \"Team\" {}\r\n}\r\n"; #[test] fn complete_alarm_example_preview_then_write_and_fixed_point() { let fixture = Fixture::new(); let source = include_str!( "../../../harness/experiments/textual-authoring-20260911/alarm-response.arcadia" ); fixture.put("project.arcadia", source); let (code, check) = fixture.cli(&["check"]); assert_eq!(code, 0); assert_eq!(check["diagnostics"], serde_json::json!([])); let (code, preview) = fixture.cli(&["stabilize"]); assert_eq!(code, 0); assert_eq!(preview["identities_to_stabilize"], 40); assert_eq!(preview["status"], "preview"); assert_eq!(fixture.read("project.arcadia"), source); assert!( preview["changes"][0]["diff"] .as_str() .unwrap() .contains("+project alarm_response") ); let (code, written) = fixture.cli(&["stabilize", "--write"]); assert_eq!(code, 0); let saved = fixture.read("project.arcadia"); assert_eq!(written["changes"][0]["after"], saved); let project = fixture.model(); assert_eq!(project.identities().len(), 40); let mut stripped = saved.clone(); for (id, _) in project.identities() { stripped = stripped.replace(&format!(" id \"{id}\""), ""); } assert_eq!(stripped, source, "only identity clauses may be inserted"); let (code, again) = fixture.cli(&["stabilize", "--write"]); assert_eq!(code, 0); assert_eq!(again["changes"], serde_json::json!([])); assert_eq!(again["status"], "unchanged"); assert_eq!(fixture.model(), project); assert_eq!(fixture.read("project.arcadia"), saved); } #[test] fn captured_candidate_exact_write_preserves_crlf_comments_and_noop_metadata() { let fixture = Fixture::new(); fixture.put("project.arcadia", SIMPLE); let candidate = fixture.prepare(Operation::Stabilize); let expected = candidate.report().changes[0].after.clone(); assert_eq!(candidate.report().identities_to_stabilize, 2); assert_eq!(fixture.read("project.arcadia"), SIMPLE); assert_eq!(candidate.write().exit_code, 0); assert_eq!(fixture.read("project.arcadia"), expected); assert!(expected.contains("// 雨: authored defaults stay omitted\r\n")); assert!(!expected.contains("description")); let metadata = fs::metadata(fixture.entry()).unwrap().modified().unwrap(); let report = fixture.prepare(Operation::Stabilize).write(); assert_eq!(report.status, "unchanged"); assert!(!report.written); assert_eq!( fs::metadata(fixture.entry()).unwrap().modified().unwrap(), metadata ); assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1); } #[test] fn alias_rename_across_modules_changes_only_live_tokens_and_retains_all_identity() { let fixture = Fixture::new(); fixture.put("project.arcadia", "arcadia 7; project rescue \"Rescue\" { include \"participants.arcadia\"; include \"work.arcadia\"; }\n"); fixture.put( "participants.arcadia", "// @team is quoted documentation\noa_entity team \"Team @team\" {}\n", ); fixture.put("work.arcadia", "oa_activity assess \"Assess\" {}\noa_activity report \"Report\" {}\nallocation assigned \"Assigned\" { source @team; target @assess; }\ninteraction transfer \"Transfer\" { source @assess; target @report; }\n"); assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0); let before = fixture.model(); let original: BTreeMap<_, _> = ["project.arcadia", "participants.arcadia", "work.arcadia"] .into_iter() .map(|p| (p, fixture.read(p))) .collect(); let team = before .identities() .into_iter() .find(|(_, alias)| *alias == "team") .unwrap() .0; let candidate = fixture.prepare(Operation::RenameAlias { old: "team".into(), new: "rescue_team".into(), }); assert_eq!(candidate.report().changes.len(), 2); assert_eq!(fixture.model(), before, "preview cannot mutate"); assert_eq!(candidate.write().exit_code, 0); let mut expected = before; expected.elements.get_mut(&team).unwrap().alias = "rescue_team".into(); assert_eq!(fixture.model(), expected); assert_eq!(fixture.read("project.arcadia"), original["project.arcadia"]); assert_eq!( fixture.read("participants.arcadia"), original["participants.arcadia"].replacen("oa_entity team ", "oa_entity rescue_team ", 1) ); assert_eq!( fixture.read("work.arcadia"), original["work.arcadia"].replace("source @team;", "source @rescue_team;") ); } #[test] fn invalid_unicode_raw_edit_is_located_and_never_stabilized_or_written() { let fixture = Fixture::new(); let raw = "arcadia 7; project p \"雨\" {\n oa_activity a \"A\" {}\n allocation alloc \"Alloc\" { source @missing; target @a; }\n}\n"; fixture.put("project.arcadia", raw); for command in [ vec!["check"], vec!["stabilize", "--write"], vec!["rename-alias", "a", "b", "--write"], ] { let (code, report) = fixture.cli(&command); assert_eq!(code, 1); assert_eq!(fixture.read("project.arcadia"), raw); let diagnostics = report["diagnostics"].as_array().unwrap(); assert!(!diagnostics.is_empty()); for d in diagnostics { assert_eq!(d["coordinate_space"], "input"); let at = &d["location"]; let start = at["start"].as_u64().unwrap() as usize; assert!(raw.is_char_boundary(start)); assert_eq!( at["line"].as_u64().unwrap() as usize, raw[..start].bytes().filter(|b| *b == b'\n').count() + 1 ); let line = raw[..start] .rsplit_once('\n') .map_or(&raw[..start], |(_, tail)| tail); assert_eq!( at["column"].as_u64().unwrap() as usize, line.chars().count() + 1 ); } } } #[test] fn missing_id_warning_uses_raw_location_and_never_exposes_ephemeral_lint_id() { let fixture = Fixture::new(); let raw = "arcadia 7;\nproject p \"雨\" {\n oa_activity work \"Work\" {}\n}\n"; fixture.put("project.arcadia", raw); let (code, result) = fixture.cli(&["lint"]); assert_eq!(code, 0, "engineering warnings do not reject admission"); let warning = result["diagnostics"] .as_array() .unwrap() .iter() .find(|d| d["code"] == "work_without_behavior_context") .unwrap(); assert_eq!(warning["severity"], "warning"); assert_eq!( warning["location"]["start"], raw.find("oa_activity").unwrap() ); assert_eq!(warning["location"]["line"], 3); assert_eq!(warning["location"]["column"], 5); assert!(warning["object_id"].is_null()); assert_eq!(fixture.read("project.arcadia"), raw); } #[test] fn captured_dependency_change_refuses_all_writes_even_on_noop() { let fixture = Fixture::new(); fixture.put("project.arcadia", SIMPLE); let pending = fixture.prepare(Operation::Stabilize); let external = format!("{SIMPLE}// editor changed this\n"); fixture.put("project.arcadia", &external); let result = pending.write(); assert_eq!(result.exit_code, 3); assert_eq!(result.diagnostics.last().unwrap().code, "external_change"); assert_eq!(fixture.read("project.arcadia"), external); assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0); let noop = fixture.prepare(Operation::Stabilize); assert!(noop.report().changes.is_empty()); let latest = format!("{}// another edit\n", fixture.read("project.arcadia")); fixture.put("project.arcadia", &latest); assert_eq!(noop.write().exit_code, 3); assert_eq!(fixture.read("project.arcadia"), latest); } #[test] fn pending_journal_refuses_read_preview_and_write_without_recovery() { let fixture = Fixture::new(); fixture.put("project.arcadia", SIMPLE); assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0); let noop = fixture.prepare(Operation::Stabilize); let source = fixture.read("project.arcadia"); // An opaque incomplete journal must never be interpreted/recovered by CLI. let journal = fixture.0.join(".arcadia-save-project.arcadia.journal"); fs::create_dir(&journal).unwrap(); fs::write(journal.join("sentinel"), b"retain").unwrap(); for op in [Operation::Check, Operation::Stabilize] { let report = prepare(&fixture.entry(), op).err().unwrap(); assert_eq!(report.exit_code, 3); assert!(report.diagnostics.iter().any(|d| d.code == "pending_save")); } assert_eq!( noop.write().exit_code, 3, "saver must recheck after preview" ); assert_eq!(fixture.read("project.arcadia"), source); assert_eq!(fs::read(journal.join("sentinel")).unwrap(), b"retain"); } #[test] fn rejected_alias_and_same_alias_preserve_source() { let fixture = Fixture::new(); fixture.put("project.arcadia", SIMPLE); assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0); let source = fixture.read("project.arcadia"); for (old, new) in [ ("team", "rescue"), ("team", "invalid alias"), ("absent", "valid"), ] { let (code, result) = fixture.cli(&["rename-alias", old, new, "--write"]); assert_eq!(code, 1); assert_eq!(result["status"], "rejected"); assert_eq!(fixture.read("project.arcadia"), source); } let (code, result) = fixture.cli(&["rename-alias", "team", "team", "--write"]); assert_eq!(code, 0); assert_eq!(result["status"], "unchanged"); assert_eq!(fixture.read("project.arcadia"), source); } #[test] fn rename_shows_missing_identity_and_separate_source_migration() { let fixture = Fixture::new(); let old = "// retain\narcadia 5; project p \"P\" { oa_entity team \"Team\" {} }\n"; fixture.put("project.arcadia", old); let candidate = fixture.prepare(Operation::RenameAlias { old: "team".into(), new: "crew".into(), }); let report = candidate.report(); assert_eq!(report.identities_to_stabilize, 2); assert_eq!(report.migration.as_ref().unwrap().from_schema, 5); assert_eq!(report.migration.as_ref().unwrap().to_schema, 7); assert!(report.changes[0].diff.contains("-arcadia 5;")); assert!(report.changes[0].diff.contains("+arcadia 7;")); assert!(report.changes[0].after.contains("oa_entity crew")); assert_eq!(fixture.read("project.arcadia"), old); assert_eq!(candidate.write().exit_code, 0); } #[test] fn entry_is_explicit_and_machine_readable_usage_errors_do_not_write() { let fixture = Fixture::new(); fixture.put("project.arcadia", SIMPLE); let output = Command::new(env!("CARGO_BIN_EXE_arcadia")) .current_dir(&fixture.0) .args(["check", "--json"]) .output() .unwrap(); assert_eq!(output.status.code(), Some(2)); let error: Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(error["diagnostics"][0]["code"], "usage"); assert_eq!(fixture.read("project.arcadia"), SIMPLE); let (code, _) = fixture.cli(&["check", "--write"]); assert_eq!(code, 2); assert!(Path::new(env!("CARGO_BIN_EXE_arcadia")).is_file()); } #[test] fn loader_encoding_missing_dependency_and_invalid_old_source_keep_original_bytes() { let fixture = Fixture::new(); let entry = "arcadia 7; project p \"P\" { include \"part.arcadia\"; }"; fixture.put("project.arcadia", entry); let (code, report) = fixture.cli(&["stabilize", "--write"]); assert_eq!(code, 3, "a disk dependency could not be read"); assert!( report["diagnostics"] .as_array() .unwrap() .iter() .any(|d| d["code"] == "missing_include") ); fs::write(fixture.0.join("part.arcadia"), [0xff, 0xfe]).unwrap(); let (code, report) = fixture.cli(&["stabilize", "--write"]); assert_eq!(code, 1, "invalid encoding is invalid raw source"); assert!( report["diagnostics"] .as_array() .unwrap() .iter() .any(|d| d["code"] == "source_encoding") ); assert_eq!(fixture.read("project.arcadia"), entry); assert_eq!( fs::read(fixture.0.join("part.arcadia")).unwrap(), [0xff, 0xfe] ); for raw in [ "arcadia 5; project p \"P\" { oa_entity a \"A\" { ??? } }", "arcadia 2; project p \"P\" {}", ] { fixture.put("project.arcadia", raw); let (code, _) = fixture.cli(&["stabilize", "--write"]); assert_eq!(code, 1); assert_eq!(fixture.read("project.arcadia"), raw); } } #[test] fn non_element_alias_detached_file_and_unchanged_dependency_conflict() { let fixture = Fixture::new(); fixture.put("project.arcadia", "arcadia 7; project p \"P\" { include \"part.arcadia\"; oa_activity a \"A\" {} allocation responsible \"Responsible\" { source @ // keep edge comment\nteam; target @a; } }"); fixture.put("part.arcadia", "oa_entity team \"Team\" {}"); fixture.put( "detached.arcadia", "invalid detached draft must stay exact 雨", ); assert_eq!(fixture.prepare(Operation::Stabilize).write().exit_code, 0); let before = fixture.model(); let relation = before .identities() .into_iter() .find(|(_, alias)| *alias == "responsible") .unwrap() .0; let (code, _) = fixture.cli(&["rename-alias", "responsible", "performs", "--write"]); assert_eq!(code, 0); let mut expected = before; expected.relations.get_mut(&relation).unwrap().alias = "performs".into(); assert_eq!(fixture.model(), expected); assert!( fixture .read("project.arcadia") .contains("@ // keep edge comment\nteam") ); let captured = fixture.prepare(Operation::RenameAlias { old: "p".into(), new: "rescue".into(), }); assert!( captured .report() .changes .iter() .all(|c| c.path != "part.arcadia") ); let root_before = fixture.read("project.arcadia"); fixture.put("part.arcadia", "// external unchanged-dependency edit\n"); assert_eq!(captured.write().exit_code, 3); assert_eq!(fixture.read("project.arcadia"), root_before); assert_eq!( fixture.read("part.arcadia"), "// external unchanged-dependency edit\n" ); assert_eq!( fixture.read("detached.arcadia"), "invalid detached draft must stay exact 雨" ); }