//! Source-only authoring. Preparation captures bytes; only core owns disk writes. use oa_core::{ Command, language, persistence::MigrationReport, prepare_command, source_files::{self, DiskProject, SourceFileError}, source_project::{self, CompiledSourceProject, ProjectLimits, SourceDiagnostic}, validation::Severity, }; use serde::Serialize; use std::path::{Path, PathBuf}; pub const LIMITS: ProjectLimits = ProjectLimits { max_files: 256, max_include_depth: 32, max_include_edges: 1024, model: language::ParseLimits { max_source_bytes: 64 * 1024 * 1024, max_graph_objects: 100_000, max_records: 250_000, max_step_references: 500_000, }, }; #[derive(Debug, Clone)] pub enum Operation { Check, Stabilize, RenameAlias { old: String, new: String }, } #[derive(Debug, Clone, Serialize)] pub struct Location { pub path: String, /// Half-open UTF-8 byte offsets. Line/column are one-based Unicode scalars. pub start: usize, pub end: usize, pub line: Option, pub column: Option, } #[derive(Debug, Clone, Serialize)] pub struct Diagnostic { pub severity: String, pub code: String, pub message: String, pub coordinate_space: String, pub location: Option, pub related: Vec, pub object_id: Option, pub field: Option, } #[derive(Debug, Clone, Serialize)] pub struct Change { pub path: String, /// Exact captured and proposed UTF-8 text, including line endings. pub before: String, pub after: String, pub diff: String, } #[derive(Debug, Clone, Serialize)] pub struct Report { pub format_version: u32, pub entry: String, pub operation: String, pub status: String, pub exit_code: u8, /// True only after a successful changed save. False on failure does not /// promise rollback: consult the specific error/recovery outcome. pub written: bool, pub diagnostics: Vec, pub changes: Vec, pub identities_to_stabilize: usize, pub migration: Option, pub notes: Vec, } impl Report { pub fn failure( entry: &Path, operation: &str, exit_code: u8, code: &str, message: String, ) -> Self { let mut report = Self::empty(entry, operation); report.fail(exit_code, code, message); report } fn empty(entry: &Path, operation: &str) -> Self { Self { format_version: 1, entry: entry.to_string_lossy().into_owned(), operation: operation.into(), status: "checked".into(), exit_code: 0, written: false, diagnostics: vec![], changes: vec![], identities_to_stabilize: 0, migration: None, notes: vec![], } } fn fail(&mut self, exit_code: u8, code: &str, message: String) { self.status = "rejected".into(); self.exit_code = exit_code; self.diagnostics.push(Diagnostic { severity: "error".into(), code: code.into(), message, coordinate_space: "operation".into(), location: None, related: vec![], object_id: None, field: None, }); } } fn located(issue: SourceDiagnostic, space: &str) -> Diagnostic { let d = issue.diagnostic; Diagnostic { severity: match d.severity { Severity::Error => "error", Severity::Warning => "warning", } .into(), code: d.code, message: d.message, coordinate_space: space.into(), location: Some(Location { path: issue.path, start: d.span.start, end: d.span.end, line: Some(d.line), column: Some(d.column), }), related: issue .related .into_iter() .map(|at| Location { path: at.path, start: at.span.start, end: at.span.end, line: None, column: None, }) .collect(), object_id: d.object_id.map(|id| id.to_string()), field: d.field, } } fn disk_error(report: &mut Report, error: SourceFileError) { let code = match &error { SourceFileError::Conflict(_) => "external_change", SourceFileError::RecoveryRequired(_) => "recovery_required", SourceFileError::RecoveryFailed { .. } => "recovery_failed", SourceFileError::Path(_) => "unsafe_path", SourceFileError::Limit(_) => "source_limit", SourceFileError::UnknownExpectation(_) => "unknown_disk_expectation", SourceFileError::Invalid(_) => "invalid_save_candidate", SourceFileError::Io { .. } => "io_error", }; report.fail(3, code, error.to_string()); } /// A candidate owns its original expectations and is consumed by saving. /// Callers cannot replace its source/model/expectations after preparation. pub struct Prepared { report: Report, snapshot: DiskProject, compiled: CompiledSourceProject, mutable: bool, } impl Prepared { pub fn report(&self) -> &Report { &self.report } pub fn into_report(self) -> Report { self.report } pub fn write(mut self) -> Report { if !self.mutable { self.report .fail(2, "usage", "check/lint does not accept --write".into()); return self.report; } match source_files::save_project(&self.snapshot, &self.compiled, LIMITS) { Ok(_) => { self.report.written = !self.report.changes.is_empty(); self.report.status = if self.report.written { "written" } else { "unchanged" } .into(); } Err(error) => disk_error(&mut self.report, error), } self.report } } pub fn prepare(entry: &Path, operation: Operation) -> Result> { let name = match operation { Operation::Check => "check", Operation::Stabilize => "stabilize", Operation::RenameAlias { .. } => "rename-alias", }; let mut report = Report::empty(entry, name); let snapshot = source_files::load_project(entry, LIMITS).map_err(|error| { disk_error(&mut report, error); Box::new(report.clone()) })?; let raw = snapshot.reachable_bundle(); // Keep discovery/IO failures that lint cannot recover from the bundle alone. // Recompute source diagnostics below, against the same original bytes. let mut diagnostics: Vec<_> = snapshot .diagnostics .iter() .filter(|d| { matches!( d.diagnostic.code.as_str(), "pending_save" | "source_encoding" | "source_limit" | "invalid_path" | "missing_include" | "project_limit" ) }) .cloned() .collect(); for diagnostic in source_project::lint_bundle(&raw, LIMITS) { if !diagnostics.contains(&diagnostic) { diagnostics.push(diagnostic); } } let disk_refusal = snapshot.diagnostics.iter().any(|d| { matches!( d.diagnostic.code.as_str(), "pending_save" | "missing_include" | "invalid_path" ) }); let invalid = diagnostics .iter() .any(|d| d.diagnostic.severity == Severity::Error); report.diagnostics = diagnostics .into_iter() .map(|d| located(d, "input")) .collect(); if invalid { // Do not run declaration analysis or any updater on invalid raw text. // Failed lint may also have allocated provisional object identities. for diagnostic in &mut report.diagnostics { diagnostic.object_id = None; } report.status = "invalid".into(); report.exit_code = if disk_refusal { 3 } else { 1 }; return Err(Box::new(report)); } let explicit_ids: std::collections::BTreeSet<_> = raw .files .values() .flat_map(|text| language::analyze(text).declarations) .filter_map(|d| d.id.map(|id| id.to_string())) .collect(); for diagnostic in &mut report.diagnostics { if diagnostic .object_id .as_ref() .is_some_and(|id| !explicit_ids.contains(id)) { diagnostic.object_id = None; } } let mut compiled = source_project::compile_bundle(&raw, LIMITS).map_err(|diagnostics| { report.status = "invalid".into(); report.exit_code = 1; report .diagnostics .extend(diagnostics.into_iter().map(|d| located(d, "candidate"))); Box::new(report.clone()) })?; report.identities_to_stabilize = raw .files .values() .map(|text| { language::analyze(text) .declarations .into_iter() .filter(|d| d.id.is_none()) .count() }) .sum(); report.migration = compiled.decoded.migration.clone(); let mutable = !matches!(operation, Operation::Check); if let Operation::RenameAlias { old, new } = operation { let project = &compiled.decoded.project; let Some((id, _)) = project .identities() .into_iter() .find(|(_, alias)| *alias == old) else { report.fail( 1, "unknown_alias", format!("No declaration has alias '{old}'."), ); return Err(Box::new(report)); }; let command = prepare_command(project, &Command::SetAlias(id, new)).map_err(|error| { report.fail(1, "semantic_command_rejected", error.to_string()); Box::new(report.clone()) })?; compiled = source_project::update_bundle_with_identity_map( &compiled, &command.project, &command.identity_map, LIMITS, ) .map_err(|diagnostics| { report.status = "invalid".into(); report.exit_code = 1; report .diagnostics .extend(diagnostics.into_iter().map(|d| located(d, "candidate"))); Box::new(report.clone()) })?; } if mutable { for (path, after) in &compiled.bundle.files { let before = raw.files.get(path).cloned().unwrap_or_default(); if &before != after { report.changes.push(Change { path: path.clone(), diff: unified_diff(path, &before, after), before, after: after.clone(), }); } } report.status = "preview".into(); report.notes.push("This invocation's complete proposed changed-file texts and diffs are reported. Without --write nothing is saved; a later invocation may allocate different missing UUIDs. This is not a saved plan or a digest of unchanged dependencies.".into()); } report.notes.push("Diagnostic object IDs are omitted for declarations without authored IDs; lint's temporary identities are not the candidate's identities.".into()); report.notes.push("Disk writes use the core journal/conflict checks. This is not cross-process atomic visibility or a power-loss durability guarantee; pending saves are never implicitly recovered. A failed write may require explicit journal recovery.".into()); Ok(Prepared { report, snapshot, compiled, mutable, }) } /// One bounded hunk per file: common prefix/suffix plus three context lines. /// Linear in input size; no quadratic LCS allocation for large modules. fn unified_diff(path: &str, before: &str, after: &str) -> String { let a: Vec<_> = before.split_inclusive('\n').collect(); let b: Vec<_> = after.split_inclusive('\n').collect(); let prefix = a.iter().zip(&b).take_while(|(a, b)| a == b).count(); let suffix = a[prefix..] .iter() .rev() .zip(b[prefix..].iter().rev()) .take_while(|(a, b)| a == b) .count(); let start = prefix.saturating_sub(3); let a_end = (a.len() - suffix + 3).min(a.len()); let b_end = (b.len() - suffix + 3).min(b.len()); let a_start = if a_end == start { start } else { start + 1 }; let b_start = if b_end == start { start } else { start + 1 }; let mut out = format!( "--- a/{path}\n+++ b/{path}\n@@ -{a_start},{} +{b_start},{} @@\n", a_end - start, b_end - start ); let mut emit = |prefix: char, line: &str| { out.push(prefix); out.push_str(line); if !line.ends_with('\n') { out.push_str("\n\\ No newline at end of file\n"); } }; for line in &a[start..prefix] { emit(' ', line); } for line in &a[prefix..a.len() - suffix] { emit('-', line); } for line in &b[prefix..b.len() - suffix] { emit('+', line); } for line in &a[a.len() - suffix..a_end] { emit(' ', line); } out } pub struct Arguments { pub entry: PathBuf, pub operation: Operation, pub write: bool, pub json: bool, } pub const HELP: &str = "arcadia check ROOT [--json]\narcadia lint ROOT [--json]\narcadia stabilize ROOT [--write] [--json]\narcadia rename-alias ROOT OLD NEW [--write] [--json]\n\nROOT is an explicit .arcadia entry file; includes resolve relative to it.\nMutation commands preview by default. --write applies this invocation's candidate.\nMissing UUIDs in a preview are provisional until written. No canonical formatting.\nExit: 0 valid/success (engineering warnings allowed), 1 invalid source or semantic\ncommand rejection, 2 usage, 3 disk/path/conflict/pending-journal/output failure.\nNo implicit journal recovery. Limits: 64 MiB, 256 files, include depth32/edges1024,\n100000 graph objects, 250000 records, 500000 ordered references.\n"; pub fn arguments(args: &[std::ffi::OsString]) -> Result { let mut positional = Vec::new(); let (mut write, mut json) = (false, false); for arg in args { if arg == "--write" && !write { write = true; } else if arg == "--json" && !json { json = true; } else if arg.to_string_lossy().starts_with('-') { return Err(format!( "Unknown or repeated option: {}", arg.to_string_lossy() )); } else { positional.push(arg); } } let command = positional.first().and_then(|s| s.to_str()).unwrap_or(""); let operation = match (command, positional.len()) { ("check" | "lint", 2) if !write => Operation::Check, ("stabilize", 2) => Operation::Stabilize, ("rename-alias", 4) => Operation::RenameAlias { old: positional[2].to_str().ok_or("Alias must be UTF-8")?.into(), new: positional[3].to_str().ok_or("Alias must be UTF-8")?.into(), }, _ => return Err(HELP.into()), }; Ok(Arguments { entry: PathBuf::from(positional[1]), operation, write, json, }) }