feat: preserve nested source projects across desktop and REST

This commit is contained in:
2026-09-06 00:17:20 +03:00
parent d364d3c058
commit 4e8575bd11
32 changed files with 4283 additions and 417 deletions
+196 -74
View File
@@ -898,6 +898,10 @@ pub(crate) struct Declaration {
fields: BTreeMap<String, Ast>,
offset: usize,
pub(crate) span: Span,
/// The containing element's identity, independent of an authored parent field.
pub(crate) lexical_owner: Option<Id>,
/// Exact body interior, excluding the opening and closing braces.
pub(crate) body: Span,
pub(crate) id_insertion: Option<usize>,
pub(crate) field_spans: BTreeMap<String, Span>,
}
@@ -952,6 +956,9 @@ pub(crate) enum EditKind {
List(Vec<EditValue>),
}
pub(crate) struct EditDeclaration {
pub(crate) span: Span,
pub(crate) lexical_owner: Option<Id>,
pub(crate) body: Span,
pub(crate) header: BTreeMap<&'static str, Span>,
pub(crate) fields: EditValue,
}
@@ -995,6 +1002,27 @@ pub(crate) fn edit_declaration(source: &str) -> Result<EditDeclaration, Box<Diag
},
)));
}
let mut declarations = edit_positions(source, unit)?;
Ok(declarations.pop().expect("one declaration").1)
}
/// Editing positions for every occurrence in lexical preorder. This is syntax
/// analysis only: intermediate command edits may have duplicate identities or
/// parent fields that disagree with their as-yet unmoved lexical owner. Callers
/// retain occurrence provenance across such edits, then compile the final text.
pub(crate) fn edit_declarations(
source: &str,
entry: bool,
limits: ParseLimits,
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
let unit = parse_unit(source, entry, limits)?;
edit_positions(source, unit)
}
fn edit_positions(
source: &str,
unit: SourceUnit,
) -> Result<Vec<(Id, EditDeclaration)>, Box<Diagnostic>> {
let tokens = strict_tokens(source).map_err(|e| Box::new(diagnostic(source, e)))?;
let mut ends: Vec<_> = tokens.iter().map(|token| token.span.end).collect();
let mut stack = Vec::new();
@@ -1055,21 +1083,48 @@ pub(crate) fn edit_declaration(source: &str) -> Result<EditDeclaration, Box<Diag
tokens: &tokens,
ends,
};
let body = tokens
.iter()
.position(|token| token.text(source) == "{")
.expect("declaration body");
let fields = EditValue {
span: Span {
start: tokens[body].span.start,
end: positions.ends[body],
},
kind: EditKind::Record(positions.fields(&unit.declarations[0].fields)),
};
Ok(EditDeclaration {
header: edit_header(source)?,
fields,
})
unit.declarations
.into_iter()
.map(|declaration| {
if declaration.id_insertion.is_some() {
return Err(Box::new(diagnostic(
source,
Error {
code: "invalid_source",
offset: declaration.span.start,
message: "Stabilize declaration identities before editing source.".into(),
},
)));
}
let roles: &[&str] = if matches!(declaration.kind, DeclKind::Node) {
&["kind", "alias", "id_keyword", "id"]
} else {
&["kind", "alias", "name", "id_keyword", "id"]
};
let start = positions.token(declaration.span.start);
let header = roles
.iter()
.zip(&tokens[start..])
.map(|(role, token)| (*role, token.span))
.collect();
Ok((
declaration.id,
EditDeclaration {
span: declaration.span,
lexical_owner: declaration.lexical_owner,
body: declaration.body,
header,
fields: EditValue {
span: Span {
start: declaration.body.start - 1,
end: declaration.body.end + 1,
},
kind: EditKind::Record(positions.fields(&declaration.fields)),
},
},
))
})
.collect()
}
struct Parser<'a> {
source: &'a str,
@@ -1143,17 +1198,8 @@ impl Parser<'_> {
self.take("{")?;
let mut fields = BTreeMap::new();
while self.text() != "}" {
let key = self.text().to_owned();
let offset = self.current().span.start;
if !matches!(
self.current().class,
TokenClass::Keyword | TokenClass::Identifier
) {
return Err(self.error("Expected a field name or '}'."));
}
self.cursor += 1;
let value = self.value()?;
self.take(";")?;
let (key, value, _) = self.field()?;
if fields.insert(key.clone(), value).is_some() {
return Err(Error {
code: "invalid_source",
@@ -1165,6 +1211,36 @@ impl Parser<'_> {
self.take("}")?;
Ok(fields)
}
fn field(&mut self) -> Result<(String, Ast, Span), Error> {
let key = self.text().to_owned();
if !matches!(
self.current().class,
TokenClass::Keyword | TokenClass::Identifier
) {
return Err(self.error("Expected a field name or '}'."));
}
self.cursor += 1;
let start = self.current().span.start;
let value = self.value()?;
let end = self.tokens[self.cursor - 1].span.end;
self.take(";")?;
Ok((key, value, Span { start, end }))
}
fn nested_header_kind(&self) -> Option<DeclKind> {
let kind = declaration_kind(self.text())?;
let alias = self.tokens.get(self.cursor + 1)?;
let following = self.tokens.get(self.cursor + 2)?;
// A node's ordinary `diagram @view;` field is not a declaration.
if !crate::valid_alias(alias.text(self.source)) {
return None;
}
if matches!(kind, DeclKind::Node) {
matches!(following.text(self.source), "id" | "{").then_some(kind)
} else {
(following.class == TokenClass::String).then_some(kind)
}
}
fn value(&mut self) -> Result<Ast, Error> {
if self.value_depth >= 64 {
return Err(self.error("Typed values may not nest more than 64 levels."));
@@ -1301,15 +1377,54 @@ impl Parser<'_> {
} else {
None
};
let mut declarations = Vec::new();
let mut declarations: Vec<Declaration> = Vec::new();
let mut includes = Vec::new();
let mut graph_count = 0usize;
while if entry {
self.text() != "}"
} else {
!self.text().is_empty()
} {
// Declarations are a flat preorder with a stack of open body indices.
// Semantic nesting can be deep without recursing through the Rust stack
// or conflating declaration depth with the typed-value depth budget.
let mut open: Vec<usize> = Vec::new();
loop {
let offset = self.current().span.start;
if let Some(&owner) = open.last() {
if self.text() == "}" {
declarations[owner].body.end = offset;
declarations[owner].span.end = self.current().span.end;
self.cursor += 1;
open.pop();
continue;
}
if self.text().is_empty() {
return Err(self.error("Expected a field, element declaration, or '}'."));
}
if self.text() == "include" {
return Err(
self.error("Includes must occur at document level, outside declarations.")
);
}
if let Some(kind) = self.nested_header_kind() {
if !matches!(declarations[owner].kind, DeclKind::Element(..))
|| !matches!(kind, DeclKind::Element(..))
{
return Err(self
.error("Only element declarations may nest inside an element body."));
}
} else {
let (key, value, span) = self.field()?;
let declaration = &mut declarations[owner];
if declaration.fields.insert(key.clone(), value).is_some() {
return Err(Error {
code: "invalid_source",
offset,
message: format!("Duplicate field '{key}'."),
});
}
declaration.field_spans.insert(key, span);
continue;
}
} else if (entry && self.text() == "}") || (!entry && self.text().is_empty()) {
break;
}
if self.text() == "include" {
if !includes_allowed {
return Err(self.error("An include requires a source bundle; single-string parsing never reads files."));
@@ -1326,7 +1441,8 @@ impl Parser<'_> {
));
continue;
}
if self.text() == "description"
if open.is_empty()
&& self.text() == "description"
&& let Some(mut entry_header) = header.take()
{
if entry_header.description.is_some() {
@@ -1370,41 +1486,29 @@ impl Parser<'_> {
};
let id_insertion = (self.text() != "id").then_some(self.current().span.start);
let id = self.optional_id()?;
let fields = self.fields()?;
let field_spans = fields
.iter()
.map(|(key, value)| {
let start = value.offset();
let mut depth = 0usize;
let mut end = start;
for token in
self.tokens[self.tokens.partition_point(|t| t.span.start < start)..].iter()
{
match token.text(self.source) {
";" if depth == 0 => break,
"{" | "[" => depth += 1,
"}" | "]" => depth = depth.saturating_sub(1),
_ => {}
}
end = token.span.end;
}
(key.clone(), Span { start, end })
})
.collect();
let body_start = self.current().span.end;
self.take("{")?;
let lexical_owner = open.last().map(|&owner| declarations[owner].id);
declarations.push(Declaration {
kind,
alias,
name,
id,
fields,
fields: BTreeMap::new(),
offset,
span: Span {
start: offset,
end: self.tokens[self.cursor - 1].span.end,
end: body_start,
},
lexical_owner,
body: Span {
start: body_start,
end: body_start,
},
id_insertion,
field_spans,
field_spans: BTreeMap::new(),
});
open.push(declarations.len() - 1);
}
if entry {
header.as_mut().expect("entry header").body.end = self.current().span.start;
@@ -1449,8 +1553,19 @@ pub(crate) fn resolve_declarations(
DeclKind::Element(kind, layer) => {
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let parent =
let explicit_parent = d.fields.contains_key("parent");
let mut parent =
take_optional_id(&mut fields, "parent").map_err(&conversion_error)?;
if let Some(owner) = d.lexical_owner {
if explicit_parent && parent != Some(owner) {
return Err(Error {
code: "conflicting_parent",
offset: d.fields["parent"].offset(),
message: "Explicit parent must equal the containing element; move the declaration to change its lexical parent.".into(),
});
}
parent = Some(owner);
}
let mut initial = if kind == Kind::Port {
if !fields.contains_key("contract") {
return Err(conversion_error(
@@ -1667,6 +1782,21 @@ pub fn analyze(source: &str) -> Analysis {
.iter()
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
.collect::<Vec<_>>();
// Recover matched body bounds once. Scanning each aggregate's descendants
// again would make tolerant navigation quadratic on deeply nested drafts.
let mut body_ends = vec![None; significant.len()];
let mut bodies = Vec::new();
for (index, token) in significant.iter().enumerate() {
match &source[token.span.start..token.span.end] {
"{" => bodies.push(index),
"}" => {
if let Some(open) = bodies.pop() {
body_ends[open] = Some(token.span.end);
}
}
_ => {}
}
}
let mut declarations = Vec::new();
for (i, t) in significant.iter().enumerate() {
let word = &source[t.span.start..t.span.end];
@@ -1709,23 +1839,11 @@ pub fn analyze(source: &str) -> Analysis {
let mut end = significant
.get(j)
.map_or(alias_token.span.end, |t| t.span.end);
let mut depth = 0usize;
for next in significant.iter().skip(j) {
match &source[next.span.start..next.span.end] {
"{" => depth += 1,
"}" => {
if depth == 0 {
break;
}
depth -= 1;
if depth == 0 {
end = next.span.end;
break;
}
}
_ => {}
}
end = next.span.end;
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
{
end = body_ends[j].unwrap_or(source.len());
}
declarations.push(DeclarationSpan {
alias: alias.into(),
@@ -1744,6 +1862,10 @@ pub fn analyze(source: &str) -> Analysis {
}
}
#[cfg(test)]
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
pub(crate) mod legacy {
//! Arcadia text: a readable, lossless representation of a valid OA project.
//!
@@ -0,0 +1,505 @@
use super::*;
use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bundle};
fn project_source(body: &str) -> String {
format!(
"arcadia 3; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
)
}
fn identity(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, value)| *value == alias)
.unwrap()
.0
}
#[test]
fn nested_elements_infer_only_parent_and_keep_typed_fields_and_allocations_explicit() {
let source = project_source(
r#"oa_entity team "Team" {
description "Authored before children";
oa_actor operator_one "Operator" {}
oa_role responder "Responder" {}
}
oa_activity respond "Respond" {
oa_activity report "Report" {}
description "Authored after children";
}
sa_system system_one "System" {
sa_port boundary "Boundary" { contract { Component { kind Standard; orientation Unset; }; }; }
}
sa_function work "Work" {
sa_function assess "Assess" {
sa_port result "Result" { contract { FunctionOutput { outgoing_items [@data]; }; }; }
}
}
sa_exchange_item data "Data" {
sa_data_field latitude "Latitude" { type_ref Real; unit "degrees"; }
}
allocation performs "Performs" { source @system_one; target @assess; }"#,
);
let project = parse(&source).unwrap();
for (child, owner) in [
("operator_one", "team"),
("responder", "team"),
("report", "respond"),
("boundary", "system_one"),
("assess", "work"),
("result", "assess"),
("latitude", "data"),
] {
assert_eq!(
project.elements[&identity(&project, child)].parent,
Some(identity(&project, owner))
);
}
assert_eq!(project.relations.len(), 1);
let relation = &project.relations[&identity(&project, "performs")];
assert_eq!(relation.kind(), RelationKind::Allocation);
assert_eq!(relation.target(), identity(&project, "assess"));
assert_eq!(parse(&format(&project)).unwrap(), project);
assert_eq!(
project.elements[&identity(&project, "respond")].description,
"Authored after children"
);
}
#[test]
fn matching_explicit_parent_is_accepted_but_none_or_another_owner_is_located() {
let nested = project_source(
"oa_activity parent_one \"Parent\" { oa_activity child \"航海\" { parent @parent_one; } }\noa_activity other \"Other\" {}",
);
let project = parse(&nested).unwrap();
assert_eq!(
project.elements[&identity(&project, "child")].parent,
Some(identity(&project, "parent_one"))
);
for reference in ["none", "@other"] {
let invalid = nested
.replace("parent @parent_one;", &format!("parent {reference};"))
.replace('\n', "\r\n");
let errors = parse(&invalid).unwrap_err();
let issue = errors
.iter()
.find(|issue| issue.code == "conflicting_parent")
.unwrap();
let at = invalid.find(&format!("parent {reference};")).unwrap() + "parent ".len();
assert_eq!(issue.span.start, at);
assert_eq!(issue.line, 2);
assert_eq!(
issue.column,
invalid[..at].rsplit('\n').next().unwrap().chars().count() + 1
);
}
}
#[test]
fn containment_validation_rejects_allocation_like_or_cross_layer_nesting() {
for body in [
"sa_system system_one \"System\" { sa_function work \"Work\" {} }",
"oa_activity parent_one \"Parent\" { sa_function child \"Child\" {} }",
] {
let source = project_source(body);
let errors = parse(&source).unwrap_err();
assert!(errors.iter().any(|issue| issue.code == "invalid_parent"));
}
}
#[test]
fn non_elements_includes_and_declarations_inside_typed_values_are_rejected() {
for body in [
"oa_activity outer_one \"Outer\" { allocation a \"A\" { source @outer_one; target @outer_one; } }",
"oa_activity outer_one \"Outer\" { include \"children.arcadia\"; }",
"diagram view_one \"View\" { oa_activity child \"Child\" {} }",
"sa_port port_one \"Port\" { contract { sa_function child \"Child\" {} }; }",
] {
let source = project_source(body);
assert!(parse_unit(&source, true, ParseLimits::UNLIMITED).is_err());
}
// Existing node `diagram @view;` fields must not look like nested headers.
let demo = Project::demo();
assert_eq!(parse(&format(&demo)).unwrap(), demo);
}
#[test]
fn declarations_are_preorder_occurrences_with_real_own_field_and_body_spans() {
let source = "// lead\r\noa_activity outer_one \"航海\" id \"00000000-0000-0000-0000-000000000002\" {\r\n description \"before\";\r\n oa_activity child \"Child\" id \"00000000-0000-0000-0000-000000000003\" { description \"inside\"; } // child proof\r\n}\r\n";
let unit = parse_unit(source, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(unit.declarations.len(), 2);
let outer = &unit.declarations[0];
let child = &unit.declarations[1];
assert_eq!(outer.alias, "outer_one");
assert_eq!(outer.lexical_owner, None);
assert_eq!(child.lexical_owner, Some(outer.id));
assert_eq!(
&source[child.body.start..child.body.end],
" description \"inside\"; "
);
assert_eq!(&source[outer.body.start - 1..outer.body.start], "{");
assert_eq!(&source[outer.body.end..outer.body.end + 1], "}");
assert!(!child.field_spans.contains_key("parent"));
assert!(outer.body.start < child.span.start && child.span.end < outer.body.end);
let edits = edit_declarations(source, false, ParseLimits::UNLIMITED).unwrap();
let (_, outer_edit) = &edits[0];
let (_, child_edit) = &edits[1];
assert_eq!(child_edit.span, child.span);
assert_eq!(child_edit.body, child.body);
assert_eq!(child_edit.lexical_owner, Some(outer.id));
let EditKind::Record(fields) = &outer_edit.fields.kind else {
panic!("own fields");
};
assert_eq!(fields.len(), 1, "children must not appear as typed fields");
let value = &fields["description"].value;
assert_eq!(&source[value.span.start..value.span.end], "\"before\"");
assert_eq!(
&source[child_edit.header["alias"].start..child_edit.header["alias"].end],
"child"
);
}
#[test]
fn syntax_edit_positions_retain_duplicate_identity_occurrences_during_a_command() {
let source = "oa_activity outer_one \"Outer\" id \"00000000-0000-0000-0000-000000000002\" { oa_activity child \"Child\" id \"00000000-0000-0000-0000-000000000002\" { parent none; } }";
let edits = edit_declarations(source, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(edits.len(), 2);
assert_eq!(edits[0].0, edits[1].0);
assert!(edits[0].1.span.start < edits[1].1.span.start);
assert!(parse(&project_source(source)).is_err());
}
#[test]
fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes() {
let original = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 3; project model \"Model\" { include \"children.arcadia\"; }".into(),
),
(
"children.arcadia".into(),
"// rationale\r\nsa_function owner_one \"航海\" { sa_function leaf \"Leaf\" { } } // kept\r\n".into(),
),
]),
};
let compiled = compile_bundle(&original, ProjectLimits::default()).unwrap();
let stable = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle, stable.bundle);
assert_eq!(compiled.decoded.project, stable.decoded.project);
assert_eq!(compiled.index, stable.index);
let child = identity(&compiled.decoded.project, "leaf");
assert!(
!compiled
.index
.fields
.contains_key(&(child, "parent".into()))
);
let location = &compiled.index.declarations[&child];
assert_eq!(location.path, "children.arcadia");
assert!(
compiled.bundle.files[&location.path][location.span.start..location.span.end]
.starts_with("sa_function leaf \"Leaf\" id ")
);
assert!(compiled.bundle.files["children.arcadia"].starts_with("// rationale\r\n"));
assert!(compiled.bundle.files["children.arcadia"].ends_with("// kept\r\n"));
let raw_warning = lint_bundle(&original, ProjectLimits::default())
.into_iter()
.find(|issue| issue.diagnostic.code == "unallocated_leaf")
.unwrap();
let raw = &original.files["children.arcadia"];
assert_eq!(
raw_warning.diagnostic.span.start,
raw.find("sa_function leaf").unwrap()
);
}
#[test]
fn nested_aliases_remain_global_and_forward_references_remain_legal() {
let source = project_source(
"oa_activity outer_one \"Outer\" { oa_activity child \"Child\" { parent @outer_one; } }\noa_activity sibling \"Sibling\" { parent @outer_one; }\noa_activity sa_function \"Alias using a declaration spelling\" {}",
);
let project = parse(&source).unwrap();
assert_eq!(
project.elements[&identity(&project, "sibling")].parent,
project.elements[&identity(&project, "child")].parent
);
let duplicated = source.replace("oa_activity sibling", "oa_activity child");
assert!(
parse(&duplicated)
.unwrap_err()
.iter()
.any(|d| d.message.contains("Duplicate alias"))
);
}
#[test]
fn nested_record_admission_precedes_resolution_and_typed_depth_stays_bounded() {
let source = project_source(
"oa_activity a \"A\" { oa_activity b \"B\" { oa_activity c \"C\" { oa_activity d \"D\" { parent @missing; } } } }",
);
let errors = parse_limited(
&source,
ParseLimits {
max_graph_objects: 3,
..ParseLimits::UNLIMITED
},
)
.unwrap_err();
assert_eq!(errors[0].code, "model_limit");
assert_eq!(errors[0].span.start, source.find("oa_activity d").unwrap());
let deep_value = project_source(&format!(
"oa_activity owner_one \"Owner\" {{ description {}0{}; }}",
"[".repeat(65),
"]".repeat(65)
));
assert!(
parse(&deep_value).unwrap_err()[0]
.message
.contains("64 levels")
);
}
#[test]
fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
let mut fragment = String::new();
for i in 2..602u128 {
fragment.push_str(&format!(
"oa_activity level_{i} \"Level\" id \"{}\" {{\n",
Id::from_u128(i)
));
}
fragment.push_str(&"}\n".repeat(600));
let analyzed_source = project_source(&fragment);
std::thread::Builder::new()
.stack_size(128 * 1024)
.spawn(move || {
let unit = parse_unit(&fragment, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(unit.declarations.len(), 600);
assert_eq!(
unit.declarations[599].lexical_owner,
Some(Id::from_u128(600))
);
let edits = edit_declarations(&fragment, false, ParseLimits::UNLIMITED).unwrap();
assert_eq!(edits.len(), 600);
})
.unwrap()
.join()
.unwrap();
let analysis = analyze(&analyzed_source);
assert_eq!(analysis.declarations.len(), 601);
assert!(
!analysis
.diagnostics
.iter()
.any(|d| d.severity == validation::Severity::Error)
);
}
#[test]
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
let source = "arcadia 3; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
let analysis = analyze(source);
let ready = analysis
.declarations
.iter()
.find(|d| d.alias == "ready")
.unwrap();
assert_eq!(
&source[ready.span.start..ready.span.end],
"oa_activity ready \"Ready\" { description \"} is text\"; }"
);
let outer = analysis
.declarations
.iter()
.find(|d| d.alias == "outer_one")
.unwrap();
assert_eq!(outer.span.end, source.len());
assert!(
analysis
.declarations
.iter()
.any(|d| d.alias == "unfinished")
);
assert!(
analysis
.diagnostics
.iter()
.any(|d| d.severity == validation::Severity::Error)
);
}
#[test]
fn normative_nested_ownership_example_compiles_as_a_source_fragment() {
let spec = include_str!("../../../docs/language.rst");
let section = spec.split("6.2. Nested element ownership").nth(1).unwrap();
let example = section
.split("For example::")
.nth(1)
.unwrap()
.lines()
.skip_while(|line| line.trim().is_empty())
.take_while(|line| line.starts_with(" "))
.map(|line| &line[4..])
.collect::<Vec<_>>()
.join("\n");
assert!(!example.is_empty());
let compiled = compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
project_source("include \"example.arcadia\";"),
),
("example.arcadia".into(), example),
]),
},
ProjectLimits::default(),
)
.unwrap();
let project = &compiled.decoded.project;
assert_eq!(
project.elements[&identity(project, "radio")].parent,
Some(identity(project, "harbor"))
);
}
fn checked_in_source_project() -> SourceBundle {
SourceBundle {
entry: "project.arcadia".into(),
files: [
(
"project.arcadia",
include_str!("../../../examples/source-project/project.arcadia"),
),
(
"oa.arcadia",
include_str!("../../../examples/source-project/oa.arcadia"),
),
(
"sa/participants.arcadia",
include_str!("../../../examples/source-project/sa/participants.arcadia"),
),
(
"sa/reporting.arcadia",
include_str!("../../../examples/source-project/sa/reporting.arcadia"),
),
(
"sa/assurance.arcadia",
include_str!("../../../examples/source-project/sa/assurance.arcadia"),
),
(
"views.arcadia",
include_str!("../../../examples/source-project/views.arcadia"),
),
]
.into_iter()
.map(|(path, text)| (path.into(), text.into()))
.collect(),
}
}
#[test]
fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() {
let input = checked_in_source_project();
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.bundle, input,
"every example identity is already explicit"
);
assert!(
compiled.diagnostics.is_empty(),
"{:?}",
compiled.diagnostics
);
let project = &compiled.decoded.project;
assert_eq!(project.diagrams.len(), 3);
let assess = identity(project, "assess_report");
let actor_port = identity(project, "reporting_output");
assert_eq!(
project.elements[&assess].parent,
Some(identity(project, "handle_reports"))
);
assert_eq!(
project.elements[&actor_port].parent,
Some(identity(project, "vessel_service"))
);
assert!(matches!(
port_contract(&project.elements[&actor_port]),
Some(PortContract::Component { .. })
));
let exchange = &project.relations[&identity(project, "validated_report")];
assert_eq!(exchange.kind(), RelationKind::FunctionalExchange);
assert_eq!(exchange.target(), assess);
for diagram in project.diagrams.values() {
let view = projection::project_diagram(project, diagram);
assert!(!view.nodes.is_empty());
let routed = routing::route_diagram(project, diagram, &view);
assert!(!routed.edges.is_empty());
for edge in routed.edges.values() {
assert!(
edge.warnings.is_empty(),
"{} / {}: {:?}",
diagram.name,
project.named(edge.id),
edge.warnings
);
}
}
}
#[test]
fn checked_in_example_supports_cross_file_reparent_without_changing_allocations() {
let input = checked_in_source_project();
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
let project = &compiled.decoded.project;
let assess = identity(project, "assess_report");
let prepared = prepare_command(
project,
&Command::Reparent {
element: assess,
parent: Some(identity(project, "assure_response")),
},
)
.unwrap();
assert_eq!(prepared.project.relations, project.relations);
let result = crate::source_project::update_bundle_with_identity_map(
&compiled,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
assert_eq!(
result.index.declarations[&assess].path,
"sa/assurance.arcadia"
);
assert_eq!(
result.index.declarations[&identity(project, "report_in")].path,
"sa/assurance.arcadia"
);
assert!(
result.bundle.files["sa/assurance.arcadia"]
.contains("// Assessment rationale travels with this declaration")
);
assert!(
result.bundle.files["sa/assurance.arcadia"]
.contains("// Assessment remains performed by rescue_system")
);
assert!(
result.bundle.files["sa/reporting.arcadia"]
.contains("// This group comment stays in this file")
);
for path in [
"project.arcadia",
"oa.arcadia",
"sa/participants.arcadia",
"views.arcadia",
] {
assert_eq!(result.bundle.files[path], input.files[path]);
}
}
+88 -133
View File
@@ -4,6 +4,8 @@ use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
use language::{Diagnostic, ParseLimits, Span};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[path = "source_project/source_structure.rs"]
mod source_structure;
/// A portable slash-separated path, checked at every bundle boundary.
pub type LogicalPath = String;
@@ -88,6 +90,11 @@ pub struct IncludeLocation {
pub struct SourceIndex {
/// Includes the project header itself under the project UUID.
pub declarations: BTreeMap<Id, SourceLocation>,
/// Interior of each declaration body, excluding its opening/closing braces.
pub declaration_bodies: BTreeMap<Id, SourceLocation>,
/// Authored element nesting, distinct from an explicit semantic parent field.
/// Flat declarations have no entry; the project is not a lexical element owner.
pub lexical_owners: BTreeMap<Id, Id>,
/// Complete value spans for top-level declaration fields.
pub fields: BTreeMap<(Id, String), SourceLocation>,
pub includes: Vec<IncludeLocation>,
@@ -363,6 +370,8 @@ fn compile_once(
let mut alias_locations: BTreeMap<String, SourceLocation> = BTreeMap::new();
let mut project_header = None;
let mut declaration_locations = BTreeMap::new();
let mut declaration_bodies = BTreeMap::new();
let mut lexical_owners = BTreeMap::new();
let mut fields = BTreeMap::new();
let mut includes = Vec::new();
let mut insertions: Edits = BTreeMap::new();
@@ -461,6 +470,16 @@ fn compile_once(
aliases.insert(declaration.alias.clone(), declaration.id);
alias_locations.insert(declaration.alias.clone(), location.clone());
declaration_locations.insert(declaration.id, location);
declaration_bodies.insert(
declaration.id,
SourceLocation {
path: path.clone(),
span: declaration.body,
},
);
if let Some(owner) = declaration.lexical_owner {
lexical_owners.insert(declaration.id, owner);
}
for (key, span) in &declaration.field_spans {
fields.insert(
(declaration.id, key.clone()),
@@ -532,6 +551,8 @@ fn compile_once(
let header = project_header.expect("validated entry header");
let index = SourceIndex {
declarations: declaration_locations,
declaration_bodies,
lexical_owners,
fields,
includes,
project_header: SourceLocation {
@@ -847,63 +868,65 @@ pub fn update_bundle_with_identity_map(
.collect();
let mut bundle = current.bundle.clone();
let mut edits: Edits = BTreeMap::new();
for (id, old_text) in &before {
let location = &current.index.declarations[id];
let new_text = identities.get(id).and_then(|id| after.get(id));
if new_text != Some(old_text) {
let source = &bundle.files[&location.path][location.span.start..location.span.end];
let mut local = Vec::new();
if let Some(new_text) = new_text {
let parse = |text| {
language::edit_declaration(text).map_err(|error| {
vec![problem(
&bundle,
&location.path,
location.span,
"source_edit",
error.message,
)]
})
};
let actual = parse(source)?;
let previous = parse(old_text)?;
let next = parse(new_text)?;
let update = SyntaxUpdate {
source,
before: old_text,
after: new_text,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
};
update.header(&actual.header, &previous.header, &next.header, &mut local);
update.value(
&actual.fields,
Some(&previous.fields),
&next.fields,
&mut local,
);
} else {
erase_syntax(
source,
Span {
start: 0,
end: source.len(),
},
&mut local,
);
let mut original_order = BTreeMap::new();
for (path, source) in &bundle.files {
let declarations = language::edit_declarations(source, path == &bundle.entry, limits.model)
.map_err(|error| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *error,
related: Vec::new(),
}]
})?;
original_order.insert(
path.clone(),
declarations.iter().map(|(id, _)| *id).collect(),
);
for (id, actual) in declarations {
let old_text = &before[&id];
// Deletion is structural: removing a parent's complete span here
// would overlap child edits and lose independently moved descendants.
let Some(new_text) = identities.get(&id).and_then(|id| after.get(id)) else {
continue;
};
if new_text == old_text {
continue;
}
edits
.entry(location.path.clone())
.or_default()
.extend(local.into_iter().map(|(span, text)| {
(
Span {
start: span.start + location.span.start,
end: span.end + location.span.start,
},
text,
)
}));
let parse = |text| {
language::edit_declaration(text).map_err(|error| {
vec![problem(
&bundle,
path,
actual.span,
"source_edit",
error.message,
)]
})
};
let mut previous = parse(old_text)?;
let mut next = parse(new_text)?;
if actual.lexical_owner.is_some()
&& let language::EditKind::Record(fields) = &actual.fields.kind
&& !fields.contains_key("parent")
{
// The lexical structure is the authored definition of parent.
// Keep it implicit while the structural phase moves this record.
for fields in [&mut previous.fields, &mut next.fields] {
if let language::EditKind::Record(fields) = &mut fields.kind {
fields.remove("parent");
}
}
}
let update = SyntaxUpdate {
source,
before: old_text,
after: new_text,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
}
}
if old.id != project.id || old.alias != project.alias || old.name != project.name {
@@ -975,84 +998,16 @@ pub fn update_bundle_with_identity_map(
}
}
let defaults = default_ownership(project);
let mut assigned: BTreeMap<_, _> = current
.index
.declarations
.iter()
.filter_map(|(id, location)| identities.get(id).map(|new| (*new, location.path.clone())))
.collect();
let mut chosen_defaults = BTreeMap::new();
let mut used_paths: BTreeSet<_> = bundle
.files
.keys()
.map(|path| path.to_lowercase())
.collect();
let mut additions: BTreeMap<LogicalPath, String> = BTreeMap::new();
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
let mut new_ids: Vec<_> = after
.keys()
.filter(|id| !origins.contains_key(*id))
.copied()
.collect();
new_ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
for id in new_ids {
let mut parent = semantic_owner(project, id);
let mut seen = BTreeSet::new();
let mut inherited = None;
while let Some(owner) = parent {
if !seen.insert(owner) {
break;
}
if let Some(path) = assigned.get(&owner) {
inherited = Some(path.clone());
break;
}
parent = semantic_owner(project, owner);
}
let path = inherited.unwrap_or_else(|| {
chosen_defaults
.entry(defaults[&id].clone())
.or_insert_with(|| {
let preferred = &defaults[&id];
if bundle.files.contains_key(preferred) {
preferred.clone()
} else {
unique_path(preferred, &mut used_paths)
}
})
.clone()
});
assigned.insert(id, path.clone());
additions
.entry(path)
.or_default()
.push_str(&unindent(&after[&id]));
}
let mut new_includes = String::new();
for (path, text) in additions {
if !bundle.files.contains_key(&path) {
bundle.files.insert(path.clone(), String::new());
new_includes.push_str(&format!(" include {};\n", quote(&path)));
}
let at = if path == bundle.entry {
current.index.project_body.span.end
} else {
bundle.files[&path].len()
};
edits
.entry(path)
.or_default()
.push((Span { start: at, end: at }, format!("\n{text}")));
}
if !new_includes.is_empty() {
let at = current.index.project_body.span.end;
edits
.entry(bundle.entry.clone())
.or_default()
.push((Span { start: at, end: at }, format!("\n{new_includes}")));
}
apply_edits(&mut bundle, edits)?;
source_structure::update(
&mut bundle,
current,
project,
identities,
&original_order,
&after,
limits,
)?;
let result = compile_bundle(&bundle, limits)?;
if &result.decoded.project != project {
return Err(vec![problem(
@@ -0,0 +1,562 @@
//! Lexical containment writeback. Scalar fields use the shared SyntaxUpdate;
//! this phase only relocates declaration pieces and their owned comments.
use super::*;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Scope {
File(LogicalPath),
Element(Id),
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Key {
Live(Id),
Deleted(Id),
}
enum Piece {
Text(String),
/// A relative indentation adjustment, inherited by retained descendants.
Child(Key, isize),
}
struct Record {
path: LogicalPath,
syntax: language::EditDeclaration,
owned: Span,
indent: usize,
}
pub(super) fn update(
bundle: &mut SourceBundle,
current: &CompiledSourceProject,
project: &Project,
identities: &BTreeMap<Id, Id>,
original_order: &BTreeMap<LogicalPath, Vec<Id>>,
after: &BTreeMap<Id, String>,
limits: ProjectLimits,
) -> Result<(), Vec<SourceDiagnostic>> {
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
let structural = after.keys().any(|id| !origins.contains_key(id))
|| original_order
.values()
.flatten()
.any(|id| !identities.contains_key(id))
|| current.index.lexical_owners.iter().any(|(id, owner)| {
identities
.get(id)
.is_some_and(|new| project.elements[new].parent != identities.get(owner).copied())
});
if !structural {
return Ok(());
}
// Scalar changes may temporarily duplicate a deleted UUID or contradict an
// explicit parent until relocation. Parse syntax, then preserve original
// preorder provenance; intermediate IDs are never editing authority.
let mut records = BTreeMap::new();
let mut children: BTreeMap<(LogicalPath, Option<Id>), Vec<Id>> = BTreeMap::new();
let mut file_bodies = BTreeMap::new();
for (path, source) in &bundle.files {
let parsed = language::edit_declarations(source, path == &bundle.entry, limits.model)
.map_err(|error| diagnostic(path, *error))?;
if parsed.len() != original_order[path].len() {
return Err(vec![problem(
bundle,
path,
Span::default(),
"stale_source_index",
"Scalar edits changed the declaration structure.",
)]);
}
let body = if path == &bundle.entry {
language::parse_unit(source, true, limits.model)
.map_err(|error| diagnostic(path, *error))?
.header
.expect("entry header")
.body
} else {
Span {
start: 0,
end: source.len(),
}
};
file_bodies.insert(path.clone(), body);
for (old, (_, syntax)) in original_order[path].iter().zip(parsed) {
children
.entry((path.clone(), current.index.lexical_owners.get(old).copied()))
.or_default()
.push(*old);
records.insert(
*old,
Record {
path: path.clone(),
owned: syntax.span,
indent: indent_at(source, syntax.span.start).len(),
syntax,
},
);
}
}
for ((path, owner), siblings) in &children {
let source = &bundle.files[path];
let body = owner.map_or(file_bodies[path], |id| records[&id].syntax.body);
let mut previous = body.start;
for (index, id) in siblings.iter().enumerate() {
let span = records[id].syntax.span;
let next = siblings
.get(index + 1)
.map_or(body.end, |next| records[next].syntax.span.start);
let owned = attached_span(source, span, previous, next);
records.get_mut(id).unwrap().owned = owned;
previous = owned.end;
}
}
let defaults = default_ownership(project);
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
let mut scopes = BTreeMap::new();
for (old, record) in &records {
if let Some(new) = identities.get(old) {
let scope = if current.index.lexical_owners.contains_key(old) {
project.elements[new]
.parent
.map(Scope::Element)
.unwrap_or_else(|| Scope::File(record.path.clone()))
} else {
Scope::File(record.path.clone())
};
scopes.insert(*new, scope);
}
}
let mut new_ids: Vec<_> = after
.keys()
.filter(|id| !origins.contains_key(id))
.copied()
.collect();
new_ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
let mut chosen_defaults = BTreeMap::new();
let mut used: BTreeSet<_> = bundle
.files
.keys()
.map(|path| path.to_lowercase())
.collect();
for id in &new_ids {
if let Some(parent) = project.elements.get(id).and_then(|element| element.parent) {
scopes.insert(*id, Scope::Element(parent));
continue;
}
let mut owner = semantic_owner(project, *id);
let mut inherited = None;
while let Some(id) = owner {
if id == project.id {
inherited = Some(bundle.entry.clone());
break;
}
if scopes.contains_key(&id) {
inherited = Some(file_for(id, &scopes));
break;
}
owner = semantic_owner(project, id);
}
let path = inherited.unwrap_or_else(|| {
chosen_defaults
.entry(defaults[id].clone())
.or_insert_with(|| {
let preferred = &defaults[id];
if bundle.files.contains_key(preferred) {
preferred.clone()
} else {
unique_path(preferred, &mut used)
}
})
.clone()
});
scopes.insert(*id, Scope::File(path));
}
let mut incoming: BTreeMap<Scope, Vec<Id>> = BTreeMap::new();
for (id, scope) in &scopes {
let original_scope = origins
.get(id)
.and_then(|old| {
records.get(old).map(|record| {
if let Some(owner) = current.index.lexical_owners.get(old) {
identities.get(owner).copied().map(Scope::Element)
} else {
Some(Scope::File(record.path.clone()))
}
})
})
.flatten();
if original_scope.as_ref() != Some(scope) {
incoming.entry(scope.clone()).or_default().push(*id);
}
}
for ids in incoming.values_mut() {
ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
}
let mut generated = BTreeMap::new();
let mut base_indents: BTreeMap<_, _> = records
.iter()
.filter_map(|(old, record)| identities.get(old).map(|new| (*new, record.indent)))
.collect();
for id in &new_ids {
let mut text = unindent(&after[id]);
let path = file_for(*id, &scopes);
if bundle
.files
.get(&path)
.is_some_and(|source| newline(source) == "\r\n")
{
text = text.replace('\n', "\r\n");
}
let parsed =
language::edit_declaration(&text).map_err(|error| diagnostic(&bundle.entry, *error))?;
if matches!(scopes[id], Scope::Element(_))
&& let language::EditKind::Record(fields) = &parsed.fields.kind
&& let Some(parent) = fields.get("parent")
{
text.replace_range(parent.span.start..parent.span.end, "");
}
let syntax =
language::edit_declaration(&text).map_err(|error| diagnostic(&bundle.entry, *error))?;
generated.insert(*id, (text, syntax));
base_indents.insert(*id, 0);
}
let composition = Composition {
records: &records,
identities,
scopes: &scopes,
incoming: &incoming,
base_indents: &base_indents,
};
let mut templates = BTreeMap::new();
for (old, record) in &records {
let source = &bundle.files[&record.path];
let live = identities.get(old).copied();
let scope = live.map(Scope::Element);
let own = |text: &str| {
if live.is_some() {
text.to_owned()
} else {
trivia(text)
}
};
let mut pieces = vec![Piece::Text(own(
&source[record.owned.start..record.syntax.body.start]
))];
pieces.extend(
composition.body(
source,
record.syntax.body,
children
.get(&(record.path.clone(), Some(*old)))
.map_or(&[], Vec::as_slice),
scope.as_ref(),
record.indent + 4,
live.is_none(),
),
);
pieces.push(Piece::Text(own(
&source[record.syntax.body.end..record.owned.end]
)));
templates.insert(live.map_or(Key::Deleted(*old), Key::Live), pieces);
}
for (id, (text, syntax)) in &generated {
let mut pieces = vec![Piece::Text(text[..syntax.body.start].into())];
pieces.extend(composition.body(
text,
syntax.body,
&[],
Some(&Scope::Element(*id)),
4,
false,
));
pieces.push(Piece::Text(text[syntax.body.end..].into()));
templates.insert(Key::Live(*id), pieces);
}
let paths: BTreeSet<_> = bundle
.files
.keys()
.cloned()
.chain(scopes.values().filter_map(|scope| {
if let Scope::File(path) = scope {
Some(path.clone())
} else {
None
}
}))
.collect();
let mut output = BTreeMap::new();
let mut includes = String::new();
let mut bytes = 0usize;
for path in paths {
let source = bundle.files.get(&path).map_or("", String::as_str);
let body = file_bodies.get(&path).copied().unwrap_or_default();
let mut pieces = vec![Piece::Text(source[..body.start].into())];
pieces.extend(
composition.body(
source,
body,
children
.get(&(path.clone(), None))
.map_or(&[], Vec::as_slice),
Some(&Scope::File(path.clone())),
if path == bundle.entry { 4 } else { 0 },
false,
),
);
pieces.push(Piece::Text(source[body.end..].into()));
let text = render(
&pieces,
&templates,
limits.model.max_source_bytes.saturating_sub(bytes),
)
.ok_or_else(|| {
vec![problem(
bundle,
&path,
Span::default(),
"source_limit",
"Structural source update exceeds the source byte budget.",
)]
})?;
bytes = bytes.saturating_add(text.len());
if !bundle.files.contains_key(&path) {
includes.push_str(&format!(
" include {};{}",
quote(&path),
newline(&bundle.files[&bundle.entry])
));
}
output.insert(path, text);
}
if !includes.is_empty() {
let entry = output.get_mut(&bundle.entry).unwrap();
let body = language::parse_unit(entry, true, limits.model)
.map_err(|error| diagnostic(&bundle.entry, *error))?
.header
.unwrap()
.body;
entry.insert_str(body.end, &format!("{}{includes}", newline(entry)));
}
bundle.files = output;
Ok(())
}
fn diagnostic(path: &str, error: Diagnostic) -> Vec<SourceDiagnostic> {
vec![SourceDiagnostic {
path: path.into(),
diagnostic: error,
related: Vec::new(),
}]
}
fn file_for(mut id: Id, scopes: &BTreeMap<Id, Scope>) -> LogicalPath {
loop {
match &scopes[&id] {
Scope::File(path) => return path.clone(),
Scope::Element(owner) => id = *owner,
}
}
}
struct Composition<'a> {
records: &'a BTreeMap<Id, Record>,
identities: &'a BTreeMap<Id, Id>,
scopes: &'a BTreeMap<Id, Scope>,
incoming: &'a BTreeMap<Scope, Vec<Id>>,
base_indents: &'a BTreeMap<Id, usize>,
}
impl Composition<'_> {
fn body(
&self,
source: &str,
body: Span,
children: &[Id],
scope: Option<&Scope>,
child_indent: usize,
deleted: bool,
) -> Vec<Piece> {
let mut pieces = Vec::new();
let text = |s: &str| Piece::Text(if deleted { trivia(s) } else { s.into() });
let mut cursor = body.start;
for old in children {
let record = &self.records[old];
pieces.push(text(&source[cursor..record.owned.start]));
if let Some(new) = self.identities.get(old) {
if scope == self.scopes.get(new) {
pieces.push(Piece::Child(Key::Live(*new), 0));
} else {
pieces.push(Piece::Text(format!("{0}{0}", newline(source))));
}
} else {
// A deleted declaration's annotations remain in the nearest
// surviving original scope, separated from adjacent declarations.
pieces.push(Piece::Text(format!("{0}{0}", newline(source))));
pieces.push(Piece::Child(Key::Deleted(*old), 0));
pieces.push(Piece::Text(format!("{0}{0}", newline(source))));
}
cursor = record.owned.end;
}
let additions: &[Id] = scope
.and_then(|scope| self.incoming.get(scope))
.map_or(&[], Vec::as_slice);
if additions.is_empty() {
pieces.push(text(&source[cursor..body.end]));
return pieces;
}
let line = source[..body.end].rsplit('\n').next().unwrap_or("");
let at = if line.bytes().all(|b| b == b' ' || b == b'\t' || b == b'\r') {
body.end.saturating_sub(line.len()).max(cursor)
} else {
body.end
};
pieces.push(text(&source[cursor..at]));
pieces.push(Piece::Text(newline(source).into()));
for id in additions {
pieces.push(Piece::Child(
Key::Live(*id),
child_indent as isize - self.base_indents[id] as isize,
));
pieces.push(Piece::Text(newline(source).into()));
}
pieces.push(text(&source[at..body.end]));
pieces
}
}
/// Keep contiguous leading comments and same-line trailing comments with the
/// declaration. Blank-separated notes and field trailing comments stay in gaps.
fn attached_span(source: &str, span: Span, lower: usize, upper: usize) -> Span {
let gap = &source[lower..span.start];
let tokens = language::tokenize(gap);
let mut start = span.start;
for token in tokens.iter().rev() {
let token_start = lower + token.span.start;
let token_end = lower + token.span.end;
match token.class {
language::TokenClass::Whitespace => {
if source[token_start..token_end]
.bytes()
.filter(|b| *b == b'\n')
.count()
> 1
{
break;
}
}
language::TokenClass::Comment => {
let line = source[..token_start].rsplit('\n').next().unwrap_or("");
if !line.chars().all(char::is_whitespace) {
break;
}
start = token_start;
}
_ => break,
}
}
let line = source[..start].rsplit('\n').next().unwrap_or("");
if line.bytes().all(|b| b == b' ' || b == b'\t') {
start = start.saturating_sub(line.len()).max(lower);
}
let mut end = span.end;
let mut trailing = false;
for token in language::tokenize(&source[span.end..upper]) {
let token_start = span.end + token.span.start;
let token_end = span.end + token.span.end;
match token.class {
language::TokenClass::Whitespace => {
if let Some(newline) = source[token_start..token_end].find('\n') {
if trailing {
end = token_start + newline + 1;
}
break;
}
}
language::TokenClass::Comment => {
trailing = true;
end = token_end;
if source[token_start..token_end].contains('\n') {
break;
}
}
_ => break,
}
}
Span { start, end }
}
/// Expand a piece forest iteratively. Descendants are streamed directly to the
/// final file, avoiding recursive calls and repeated copying of whole subtrees.
fn render(pieces: &[Piece], templates: &BTreeMap<Key, Vec<Piece>>, limit: usize) -> Option<String> {
let mut output = String::new();
let mut stack = vec![(pieces.iter(), 0isize)];
let mut line_start = true;
// A lexical piece can end in the indentation immediately before another
// piece's closing brace. Delay it until that line's content is available.
let mut indentation = String::new();
while let Some((items, shift)) = stack.last_mut() {
match items.next() {
Some(Piece::Child(key, relative)) => {
let delta = *shift + relative;
stack.push((templates[key].iter(), delta));
}
Some(Piece::Text(text)) => {
for part in text.split_inclusive('\n') {
if line_start {
let leading = part.len() - part.trim_start_matches([' ', '\t']).len();
indentation.push_str(&part[..leading]);
let content = &part[leading..];
if output.len().saturating_add(indentation.len()) > limit {
return None;
}
if content.is_empty() {
continue;
}
let delta = if content.starts_with(['\r', '\n']) {
0
} else {
*shift
};
if delta > 0 {
if output
.len()
.saturating_add(indentation.len())
.saturating_add(delta as usize)
.saturating_add(content.len())
> limit
{
return None;
}
output.extend(std::iter::repeat_n(' ', delta as usize));
}
let trim = if delta < 0 {
indentation.len().min(delta.unsigned_abs())
} else {
0
};
output.push_str(&indentation[trim..]);
indentation.clear();
output.push_str(content);
} else {
output.push_str(part);
}
if output.len() > limit {
return None;
}
line_start = part.ends_with('\n');
}
}
None => {
stack.pop();
}
}
}
output.push_str(&indentation);
Some(output)
}
+10 -1
View File
@@ -1021,6 +1021,15 @@ pub fn plan_transition(
Ok(plan)
}
pub fn apply_transition(project: &Project, plan: &TransitionPlan) -> Result<Project, ModelError> {
prepare_transition(project, plan).map(|prepared| prepared.project)
}
/// Prepare a reviewed transition once, retaining identity correspondence for
/// adapters that update accepted graphical and authored source state together.
pub fn prepare_transition(
project: &Project,
plan: &TransitionPlan,
) -> Result<PreparedCommand, ModelError> {
if project_fingerprint(project) != plan.base_fingerprint {
return Err(ModelError(
"Transition preview is stale; create a new preview.".into(),
@@ -1031,7 +1040,7 @@ pub fn apply_transition(project: &Project, plan: &TransitionPlan) -> Result<Proj
"Resolve transition conflicts before applying.".into(),
));
}
execute_batch(project, &plan.commands)
prepare_command(project, &Command::Batch(plan.commands.clone()))
}
/// Supported explicit element counterparts, in stable kind-catalog order.
@@ -0,0 +1,388 @@
use oa_core::{source_project::*, *};
use std::collections::BTreeMap;
fn compile(a: &str, b: &str) -> CompiledSourceProject {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 3; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
]),
}, ProjectLimits::default()).unwrap()
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn apply(current: &CompiledSourceProject, project: &Project) -> CompiledSourceProject {
let result = update_bundle(current, project, ProjectLimits::default()).unwrap();
assert_eq!(&result.decoded.project, project);
assert_eq!(
update_bundle(&result, project, ProjectLimits::default())
.unwrap()
.bundle,
result.bundle,
"unchanged writeback is byte-stable"
);
result
}
fn comments(bundle: &SourceBundle) -> Vec<String> {
let mut result: Vec<_> = bundle
.files
.values()
.flat_map(|source| {
language::tokenize(source)
.into_iter()
.filter(|t| t.class == language::TokenClass::Comment)
.map(|t| source[t.span.start..t.span.end].to_owned())
})
.collect();
result.sort();
result
}
#[test]
fn nested_scalar_edits_touch_only_authored_tokens() {
let current = compile(
"// owner rationale\nsa_function owner \"Owner\" {\n description \"owner notes\";\n sa_function child \"Child\" { description \"before\" ; // inline\n } // child tail\n}\n",
"// unrelated file\n",
);
let mut project = current.decoded.project.clone();
let child = id(&project, "child");
project.elements.get_mut(&child).unwrap().description = "after".into();
project
.elements
.get_mut(&id(&current.decoded.project, "owner"))
.unwrap()
.name = "Renamed owner".into();
let result = apply(&current, &project);
assert_eq!(
result.bundle.files["a.arcadia"],
current.bundle.files["a.arcadia"]
.replace("\"Owner\"", "\"Renamed owner\"")
.replace("\"before\"", "\"after\"")
);
assert_eq!(
result.bundle.files["b.arcadia"],
current.bundle.files["b.arcadia"]
);
assert!(!result.index.fields.contains_key(&(child, "parent".into())));
}
#[test]
fn cross_file_move_carries_attached_comments_and_subtree_but_leaves_gap_notes() {
let current = compile(
"sa_function left \"Left\" {\n // stationary gap note\n\n // child rationale\n sa_function child \"Child\" {\n sa_function grandchild \"Grandchild\" {}\n } // child trailing rationale\n}\n",
"sa_function right \"Right\" {}\n",
);
let mut project = current.decoded.project.clone();
let (child, right, grandchild) = (
id(&project, "child"),
id(&project, "right"),
id(&project, "grandchild"),
);
project.elements.get_mut(&child).unwrap().parent = Some(right);
project.elements.get_mut(&grandchild).unwrap().name = "Updated grandchild".into();
let result = apply(&current, &project);
assert_eq!(result.index.declarations[&child].path, "b.arcadia");
assert_eq!(result.index.declarations[&grandchild].path, "b.arcadia");
assert_eq!(result.index.lexical_owners[&child], right);
assert_eq!(result.index.lexical_owners[&grandchild], child);
assert!(result.bundle.files["a.arcadia"].contains("// stationary gap note"));
assert!(!result.bundle.files["b.arcadia"].contains("// stationary gap note"));
assert!(result.bundle.files["b.arcadia"].contains("// child rationale"));
assert!(result.bundle.files["b.arcadia"].contains("// child trailing rationale"));
assert!(
result.bundle.files["b.arcadia"].contains("\n } // child trailing rationale"),
"moved closing brace must align with its header: {}",
result.bundle.files["b.arcadia"]
);
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn moving_a_child_keeps_the_owners_opening_line_comment_with_its_owner() {
let current = compile(
"sa_function left \"Left\" { // Owner rationale belongs to Left.\n // Child rationale follows Child.\n sa_function child \"Child\" {}\n}\n",
"sa_function right \"Right\" {}\n",
);
let mut project = current.decoded.project.clone();
let (child, right) = (id(&project, "child"), id(&project, "right"));
project.elements.get_mut(&child).unwrap().parent = Some(right);
let result = apply(&current, &project);
assert!(result.bundle.files["a.arcadia"].contains("// Owner rationale belongs to Left."));
assert!(!result.bundle.files["b.arcadia"].contains("// Owner rationale belongs to Left."));
assert!(result.bundle.files["b.arcadia"].contains("// Child rationale follows Child."));
assert!(!result.bundle.files["a.arcadia"].contains("// Child rationale follows Child."));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn compact_same_file_reparent_and_unnest_preserve_explicit_parent_comments() {
let current = compile(
"sa_function left \"Left\" {sa_function child \"Child\" {parent // parent rationale\n @left;}} sa_function right \"Right\" {}\n",
"// untouched\n",
);
let mut project = current.decoded.project.clone();
let (child, right) = (id(&project, "child"), id(&project, "right"));
project.elements.get_mut(&child).unwrap().parent = Some(right);
let moved = apply(&current, &project);
assert_eq!(moved.index.lexical_owners[&child], right);
assert!(moved.bundle.files["a.arcadia"].contains("// parent rationale"));
assert!(moved.bundle.files["a.arcadia"].contains("@right;"));
project.elements.get_mut(&child).unwrap().parent = None;
let unnested = apply(&moved, &project);
assert!(!unnested.index.lexical_owners.contains_key(&child));
assert_eq!(unnested.index.declarations[&child].path, "a.arcadia");
assert_eq!(comments(&unnested.bundle), comments(&current.bundle));
}
#[test]
fn additions_nest_in_existing_and_new_owners_while_authored_flat_records_stay_flat() {
let current = compile(
"sa_function owner \"Owner\" {}\nsa_function flat \"Flat\" {parent @owner;}\n",
"// unrelated\n",
);
let mut project = current.decoded.project.clone();
let (owner, flat) = (id(&project, "owner"), id(&project, "flat"));
let added = project
.add_element(Kind::Function, Layer::Sa, "Added", Some(owner))
.unwrap();
let grandchild = project
.add_element(Kind::Function, Layer::Sa, "Added descendant", Some(added))
.unwrap();
project.elements.get_mut(&flat).unwrap().parent = Some(added);
let fresh_root = project
.add_element(Kind::Function, Layer::Sa, "Fresh root", None)
.unwrap();
let fresh_child = project
.add_element(Kind::Function, Layer::Sa, "Fresh child", Some(fresh_root))
.unwrap();
let result = apply(&current, &project);
for (child, parent) in [
(added, owner),
(grandchild, added),
(fresh_child, fresh_root),
] {
assert_eq!(result.index.lexical_owners[&child], parent);
assert_eq!(
result.index.declarations[&child].path,
result.index.declarations[&parent].path
);
assert!(!result.index.fields.contains_key(&(child, "parent".into())));
}
assert!(!result.index.lexical_owners.contains_key(&flat));
assert_eq!(result.index.declarations[&flat].path, "a.arcadia");
assert_eq!(
result.bundle.files["b.arcadia"],
current.bundle.files["b.arcadia"]
);
}
#[test]
fn deleting_owner_retains_annotations_and_extracts_independently_moved_descendant() {
let current = compile(
"// deleted owner rationale\nsa_function doomed \"Doomed\" {\n description \"old\"; // deleted field rationale\n // retained child rationale\n sa_function rescued \"Rescued\" {} // retained child tail\n // deleted child rationale\n sa_function removed \"Removed\" {}\n}\nsa_function neighbor \"Neighbor\" {}\n",
"sa_function destination \"Destination\" {}\n",
);
let mut project = current.decoded.project.clone();
let (doomed, removed, rescued, destination) = (
id(&project, "doomed"),
id(&project, "removed"),
id(&project, "rescued"),
id(&project, "destination"),
);
project.elements.remove(&doomed);
project.elements.remove(&removed);
project.elements.get_mut(&rescued).unwrap().parent = Some(destination);
let result = apply(&current, &project);
assert_eq!(result.index.lexical_owners[&rescued], destination);
for comment in [
"deleted owner rationale",
"deleted field rationale",
"deleted child rationale",
] {
assert!(result.bundle.files["a.arcadia"].contains(comment));
assert!(!result.bundle.files["b.arcadia"].contains(comment));
}
assert!(result.bundle.files["b.arcadia"].contains("retained child rationale"));
assert!(result.bundle.files["b.arcadia"].contains("retained child tail"));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
// Old annotations are separated: moving the neighbor in a later command
// cannot accidentally steal comments of deleted declarations.
let neighbor = id(&project, "neighbor");
project.elements.get_mut(&neighbor).unwrap().description = "changed".into();
let next = apply(&result, &project);
assert_eq!(comments(&next.bundle), comments(&current.bundle));
}
#[test]
fn independent_descendant_move_can_reverse_old_containment_without_overlapping_edits() {
let current = compile(
"sa_function root \"Root\" {\n // outer rationale\n sa_function outer \"Outer\" {\n // inner rationale\n sa_function inner \"Inner\" {}\n }\n}\n",
"",
);
let mut project = current.decoded.project.clone();
let (root, outer, inner) = (
id(&project, "root"),
id(&project, "outer"),
id(&project, "inner"),
);
project.elements.get_mut(&outer).unwrap().parent = Some(inner);
project.elements.get_mut(&inner).unwrap().parent = Some(root);
let result = apply(&current, &project);
assert_eq!(result.index.lexical_owners[&inner], root);
assert_eq!(result.index.lexical_owners[&outer], inner);
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn nested_uuid_alias_swap_and_cross_file_move_follow_command_provenance() {
let current = compile(
"sa_function left \"Left\" {\n // first rationale\n sa_function first \"First\" {}\n}\n",
"sa_function right \"Right\" {\n // second rationale\n sa_function second \"Second\" {}\n}\n",
);
let old = &current.decoded.project;
let (first, second, right) = (id(old, "first"), id(old, "second"), id(old, "right"));
let temporary = Id::new_v4();
let prepared = prepare_command(
old,
&Command::Batch(vec![
Command::Reidentify(first, temporary),
Command::Reidentify(second, first),
Command::Reidentify(temporary, second),
Command::Reparent {
element: second,
parent: Some(right),
},
]),
)
.unwrap();
let result = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(result.decoded.project, prepared.project);
assert_eq!(result.index.declarations[&second].path, "b.arcadia");
assert!(result.bundle.files["b.arcadia"].contains("// first rationale"));
assert!(result.bundle.files["b.arcadia"].contains("// second rationale"));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn deleted_owner_identity_reused_by_new_owner_does_not_keep_old_lexical_ownership() {
let current = compile(
"// retired owner note\nsa_function old_owner \"Old owner\" {\n // rescued child note\n sa_function child \"Child\" {}\n}\n",
"",
);
let old = &current.decoded.project;
let (owner, child) = (id(old, "old_owner"), id(old, "child"));
let mut project = old.clone();
project.elements.remove(&owner);
let mut replacement = project
.make_element(Kind::Function, Layer::Sa, "New owner", None)
.unwrap();
replacement.id = owner;
project.elements.insert(owner, replacement);
let mut identities: BTreeMap<_, _> = old
.identities()
.into_iter()
.map(|(id, _)| (id, id))
.collect();
identities.remove(&owner);
let result =
update_bundle_with_identity_map(&current, &project, &identities, ProjectLimits::default())
.unwrap();
assert_eq!(result.decoded.project, project);
let destination = &result.index.declarations[&owner].path;
assert_ne!(destination, "a.arcadia");
assert_eq!(&result.index.declarations[&child].path, destination);
assert_eq!(result.index.lexical_owners[&child], owner);
assert!(result.bundle.files["a.arcadia"].contains("retired owner note"));
assert!(!result.bundle.files[destination].contains("retired owner note"));
assert!(result.bundle.files[destination].contains("rescued child note"));
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn nested_typed_reference_list_updates_keep_item_rationale_while_port_moves() {
let current = compile(
"sa_exchange_item first \"First\" {}\nsa_exchange_item second \"Second\" {}\nsa_function sender \"Sender\" {\n // port rationale\n sa_port output \"Output\" { contract { FunctionOutput { outgoing_items [\n @first, // first item rationale\n @second // second item rationale\n ]; }; }; }\n}\n",
"sa_function other \"Other\" {}\n",
);
let mut project = current.decoded.project.clone();
let (first, output, other) = (
id(&project, "first"),
id(&project, "output"),
id(&project, "other"),
);
project.elements.get_mut(&first).unwrap().alias = "renamed_first".into();
let port = project.elements.get_mut(&output).unwrap();
port.parent = Some(other);
let Some(SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
}) = port.shared_mut()
else {
panic!("output contract")
};
outgoing_items.reverse();
let result = apply(&current, &project);
let text = &result.bundle.files["b.arcadia"];
assert!(text.find("@second").unwrap() < text.find("@renamed_first").unwrap());
assert_eq!(result.index.lexical_owners[&output], other);
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn crlf_moved_and_new_children_preserve_authored_and_destination_line_endings() {
let current = compile(
"sa_function left \"Left\" {\r\n // child rationale\r\n sa_function child \"Child\" {} // trailing\r\n}\r\n",
"sa_function right \"Right\" {}\r\n",
);
let mut project = current.decoded.project.clone();
let (child, right) = (id(&project, "child"), id(&project, "right"));
project.elements.get_mut(&child).unwrap().parent = Some(right);
project
.add_element(Kind::Function, Layer::Sa, "Added", Some(right))
.unwrap();
let result = apply(&current, &project);
for path in ["a.arcadia", "b.arcadia"] {
let text = &result.bundle.files[path];
assert!(
!text.replace("\r\n", "").contains('\n'),
"bare LF introduced in {path}: {text:?}"
);
}
assert_eq!(comments(&result.bundle), comments(&current.bundle));
}
#[test]
fn deep_structural_writeback_runs_on_a_small_thread_stack() {
std::thread::Builder::new()
.stack_size(256 * 1024)
.spawn(|| {
let mut source = String::new();
for level in 0..600 {
source.push_str(&format!("sa_function n{level} \"N{level}\" {{"));
}
source.push_str(&"}".repeat(600));
let current = compile(&source, "sa_function destination \"Destination\" {}\n");
let mut project = current.decoded.project.clone();
let (leaf, destination) = (id(&project, "n599"), id(&project, "destination"));
project.elements.get_mut(&leaf).unwrap().parent = Some(destination);
let result = apply(&current, &project);
assert_eq!(result.index.declarations[&leaf].path, "b.arcadia");
assert_eq!(result.index.lexical_owners[&leaf], destination);
})
.unwrap()
.join()
.unwrap();
}
+140 -9
View File
@@ -165,15 +165,16 @@ impl Document {
pub fn execute(&mut self, command: Command) -> Result<Option<Id>, DocumentError> {
let candidate = prepare_command(self.project(), &command)?;
if &candidate.project != self.project() {
let compiled = source_project::update_bundle_with_identity_map(
&self.current,
&candidate.project,
&candidate.identity_map,
self.limits,
)?;
self.commit(compiled);
}
// 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)
}
@@ -369,6 +370,136 @@ mod tests {
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();
+48 -6
View File
@@ -7,6 +7,7 @@ use iced::{
Element as View, Fill, Font,
widget::{
self, Space, button, column, container, pick_list, row, scrollable, text, text_input,
tooltip,
},
};
use oa_core::*;
@@ -1080,7 +1081,7 @@ fn source_files(app: &App) -> View<'_, Message> {
);
}
}
let mut panel = column![
let panel = column![
text("SOURCE FILES").size(13),
label(format!(
"{} files · one system model",
@@ -1097,9 +1098,6 @@ fn source_files(app: &App) -> View<'_, Message> {
label("Includes define file membership. Closing a detached buffer never deletes its file."),
]
.spacing(9);
if app.pending_recovery.is_some() {
panel = panel.push(action("Recover interrupted save", Message::SourceRecover));
}
panel.into()
}
@@ -1144,8 +1142,49 @@ fn source(app: &App) -> View<'_, Message> {
} else {
column![].into()
};
let recovery: View<'_, Message> = if let Some(path) = &app.pending_recovery {
let edits_block_recovery =
!app.source_workspace.applied && app.source_workspace.has_edits();
let filename = path.file_name().unwrap_or_default().to_string_lossy();
container(
column![
tooltip(
text(format!("Interrupted save · {filename}")).size(15),
text(path.display().to_string()).size(12),
tooltip::Position::Bottom,
),
text(if edits_block_recovery {
"Revert the edited files before recovery. Recovery restores files to their state before the interrupted save, then reopens them."
} else if app.source_workspace.applied {
"Restore files to their state before the interrupted save. Your current model and editor drafts are kept."
} else {
"Restore files to their state before the interrupted save, then reopen them. This rolls back the incomplete save."
})
.size(13),
button(text("Recover interrupted save").size(12))
.padding(10)
.on_press_maybe(
(!edits_block_recovery && !app.busy).then_some(Message::SourceRecover),
),
]
.spacing(8),
)
.padding(12)
.width(Fill)
.style(style::card)
.into()
} else {
column![].into()
};
let mut diagnostics = column![].spacing(5);
let mut visible_diagnostics = 0;
for (index, issue) in app.source_diagnostics.iter().enumerate() {
// The persistent recovery banner supplies the action and explanation;
// repeating the internal journal path obscures ordinary diagnostics.
if app.pending_recovery.is_some() && issue.diagnostic.code == "pending_save" {
continue;
}
visible_diagnostics += 1;
diagnostics = diagnostics.push(
button(
text(format!(
@@ -1216,8 +1255,11 @@ fn source(app: &App) -> View<'_, Message> {
]
.spacing(7)
.align_y(iced::Center),
recovery,
text(app.source_workspace.active.clone()).font(Font::MONOSPACE).size(12).color(style::INK),
label(if !app.source_workspace.applied {
label(if app.pending_recovery.is_some() && !app.source_workspace.applied {
"The previous graphical model remains available while these files are recovered."
} else if !app.source_workspace.applied {
"This project needs repair. Apply all adopts it; the previous graphical model remains available."
} else if app.source_workspace.detached_dirty() {
"A detached file has an unsaved draft. Reattach it or revert that file before saving."
@@ -1226,7 +1268,7 @@ fn source(app: &App) -> View<'_, Message> {
}),
navigation,
scrollable(diagnostics)
.height((app.source_diagnostics.len() as f32 * 32.).min(132.))
.height((visible_diagnostics as f32 * 32.).min(132.))
]
.spacing(12)
)
+76
View File
@@ -33,6 +33,79 @@ pub struct IssueDto {
pub line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub span: Option<SpanDto>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub related: Vec<SourceLocationDto>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SpanDto {
pub start: usize,
pub end: usize,
}
impl From<oa_core::language::Span> for SpanDto {
fn from(span: oa_core::language::Span) -> Self {
Self {
start: span.start,
end: span.end,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SourceLocationDto {
pub path: String,
pub span: SpanDto,
}
pub(crate) fn source_issue(issue: oa_core::source_project::SourceDiagnostic) -> IssueDto {
let diagnostic = issue.diagnostic;
IssueDto {
severity: match diagnostic.severity {
oa_core::validation::Severity::Error => "error",
oa_core::validation::Severity::Warning => "warning",
},
code: diagnostic.code,
message: diagnostic.message,
field: diagnostic.field,
object_id: diagnostic.object_id,
line: Some(diagnostic.line),
column: Some(diagnostic.column),
path: Some(issue.path),
span: Some(diagnostic.span.into()),
related: issue
.related
.into_iter()
.map(|location| SourceLocationDto {
path: location.path,
span: location.span.into(),
})
.collect(),
}
}
pub(crate) fn source_error(
diagnostics: Vec<oa_core::source_project::SourceDiagnostic>,
) -> ApiError {
let code = if diagnostics
.iter()
.any(|d| d.diagnostic.code == "model_limit")
{
"model_limit"
} else {
"invalid_source_project"
};
let mut error = ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
code,
"Source project could not be applied.",
);
error.issues = diagnostics.into_iter().map(source_issue).collect();
error
}
impl ApiError {
@@ -106,6 +179,9 @@ pub fn issues(model: &oa_core::Project) -> Vec<IssueDto> {
object_id: issue.element,
line: None,
column: None,
path: None,
span: None,
related: vec![],
})
.collect()
}
+131 -34
View File
@@ -1,7 +1,10 @@
use crate::{
AppState, blocking,
error::{ApiError, ApiResult, IssueDto, issues},
store::{MODEL_LIMITS, StoredProject, check_model_size, validate_model},
error::{ApiError, ApiResult, IssueDto, issues, source_issue},
store::{
MODEL_LIMITS, PreparedProject, SOURCE_LIMITS, StoredProject, check_model_size,
check_source_size, validate_model,
},
};
use axum::{
Json, Router,
@@ -14,6 +17,7 @@ use axum::{
response::{IntoResponse, Response},
routing::{get, post},
};
use oa_core::source_project::{self, SourceBundle};
use oa_core::{Command, Id, Kind, Layer, Project, RelationKind};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::{Value, json};
@@ -31,6 +35,10 @@ pub(crate) fn router() -> Router<AppState> {
.route("/api/v1/openapi.json", get(openapi))
.route("/api/v1/projects", get(list_projects).post(create_project))
.route("/api/v1/projects/import", post(import_project))
.route(
"/api/v1/projects/import-source-project",
post(import_source_project),
)
.route(
"/api/v1/projects/{id}",
get(get_project)
@@ -42,6 +50,10 @@ pub(crate) fn router() -> Router<AppState> {
"/api/v1/projects/{id}/source",
get(get_source).put(put_source),
)
.route(
"/api/v1/projects/{id}/source-project",
get(get_source_project).put(put_source_project),
)
.route("/api/v1/projects/{id}/elements", get(list_elements))
.route("/api/v1/projects/{id}/relations", get(list_relations))
.route("/api/v1/projects/{id}/artifact-links", get(list_artifacts))
@@ -209,6 +221,9 @@ fn parse_source(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
object_id: issue.object_id,
line: Some(issue.line),
column: Some(issue.column),
path: None,
span: Some(issue.span.into()),
related: vec![],
})
.collect();
error
@@ -236,6 +251,9 @@ fn import_json(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
object_id: issue.element,
line: None,
column: None,
path: None,
span: None,
related: vec![],
})
.collect();
error
@@ -306,6 +324,20 @@ async fn import_project(
.await
}
async fn import_source_project(
State(state): State<AppState>,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let bundle: SourceBundle = json_body(&body)?;
created(store.create_source_project(bundle)?)
})
.await
}
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Page {
@@ -377,14 +409,14 @@ async fn rename_project(
let request: NameRequest = json_body(&body)?;
let snapshot = store.checked(id, &expected)?;
let current = &snapshot;
let model = oa_core::execute_batch(
let prepared = oa_core::prepare_command(
&current.model,
&[Command::EditProject {
&Command::EditProject {
id: current.model.id,
alias: current.model.alias.clone(),
name: request.name,
description: current.model.description.clone(),
}],
},
)
.map_err(|error| {
ApiError::new(
@@ -393,7 +425,8 @@ async fn rename_project(
error.to_string(),
)
})?;
let (stored, changed) = store.replace(id, &expected, model)?;
let prepared = PreparedProject::from_command(current, prepared)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
@@ -457,6 +490,48 @@ async fn get_source(
.await
}
async fn get_source_project(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
) -> ApiResult<Response> {
let id = path(id)?;
blocking(state, move |store| {
let stored = store.get(id)?;
Ok(not_modified(&stored, &headers)
.unwrap_or_else(|| response(&stored, StatusCode::OK, &stored.source_project)))
})
.await
}
async fn put_source_project(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
let expected = expected(&headers)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
store.checked(id, &expected)?;
let bundle: SourceBundle = json_body(&body)?;
let prepared = PreparedProject::from_bundle(bundle)?;
let diagnostics = prepared.source_issues();
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
json!({
"revision": stored.revision, "changed": changed, "issues": diagnostics,
"source_project": stored.source_project
}),
))
})
.await
}
async fn put_model(
State(state): State<AppState>,
id: ProjectPath,
@@ -504,7 +579,7 @@ async fn replace_model(
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration,"migration":stored.migration}),
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model),"migration":stored.migration}),
))
})
.await
@@ -844,8 +919,11 @@ async fn get_validation(State(state): State<AppState>, id: ProjectPath) -> ApiRe
#[serde(deny_unknown_fields)]
struct ValidationRequest {
format: String,
// Keep RawValue directly in the struct: internally tagged enums buffer
// their content and cannot preserve raw model JSON for duplicate-key checks.
model: Option<Box<serde_json::value::RawValue>>,
source: Option<String>,
source_project: Option<SourceBundle>,
}
async fn validate_candidate(
@@ -861,31 +939,31 @@ async fn validate_candidate(
let snapshot = store.get(id)?;
let stored = &snapshot;
let request: ValidationRequest = json_body(&body)?;
let parsed = match (request.format.as_str(), request.model, request.source) {
("json", Some(model), None) => import_json(model.get().as_bytes()),
("arcadia", None, Some(source)) => parse_source(source.as_bytes()),
_ => return Err(ApiError::bad_request("Validation requires either format json with only model, or format arcadia with only source.")),
let parsed = match (request.format.as_str(), request.model, request.source, request.source_project) {
("json", Some(model), None, None) => import_json(model.get().as_bytes()),
("arcadia", None, Some(source), None) => parse_source(source.as_bytes()),
("source-project", None, None, Some(source_project)) => {
let result = check_source_size(&source_project).and_then(|()| {
// Advisory locations refer to submitted bytes, before Apply
// would insert missing persistent identities.
let diagnostics: Vec<_> = source_project::lint_bundle(&source_project, SOURCE_LIMITS).into_iter().map(source_issue).collect();
if !diagnostics.iter().any(|issue| issue.severity == "error") {
PreparedProject::from_bundle(source_project)?;
}
Ok(diagnostics)
});
let diagnostics = result.unwrap_or_else(candidate_issues);
let valid = !diagnostics.iter().any(|issue| issue.severity == "error");
return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":null})));
}
_ => return Err(ApiError::bad_request("Validation requires one matching format/payload: json with model, arcadia with source, or source-project with source_project.")),
};
let (valid, issues, migration) = match parsed.and_then(|decoded| {
validate_model(&decoded.project)?;
Ok(decoded)
}) {
Ok(decoded) => (true, issues(&decoded.project), decoded.migration),
Err(error) => {
let mut diagnostics = error.issues;
if diagnostics.is_empty() {
diagnostics.push(IssueDto {
severity: "error",
code: error.code.into(),
message: error.detail,
field: None,
object_id: None,
line: None,
column: None,
});
}
(false, diagnostics, None)
}
Err(error) => (false, candidate_issues(error), None),
};
Ok(response(
stored,
@@ -896,6 +974,24 @@ async fn validate_candidate(
.await
}
fn candidate_issues(error: ApiError) -> Vec<IssueDto> {
if !error.issues.is_empty() {
return error.issues;
}
vec![IssueDto {
severity: "error",
code: error.code.into(),
message: error.detail,
field: None,
object_id: None,
line: None,
column: None,
path: None,
span: None,
related: vec![],
}]
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TransactionRequest {
@@ -919,13 +1015,13 @@ async fn transactions(
check_commands(&request.commands)?;
let snapshot = store.checked(id, &expected)?;
let current = &snapshot;
let model = oa_core::execute_batch(&current.model, &request.commands)
let candidate = oa_core::prepare_command(&current.model, &Command::Batch(request.commands))
.map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "command_rejected", error.to_string()))?;
validate_model(&model)?;
let prepared = PreparedProject::from_command(current, candidate)?;
if request.dry_run {
return Ok(response(current, StatusCode::OK, json!({"revision":current.revision,"dry_run":true,"changed":model != current.model,"issues":issues(&model),"model":model})));
return Ok(response(current, StatusCode::OK, json!({"revision":current.revision,"dry_run":true,"changed":prepared.changed(current),"issues":prepared.source_issues(),"model":prepared.model(),"source_project":prepared.bundle()})));
}
let (stored, changed) = store.replace(id, &expected, model)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(&stored, StatusCode::OK, json!({"revision":stored.revision,"dry_run":false,"changed":changed,"issues":issues(&stored.model)})))
}).await
}
@@ -976,9 +1072,9 @@ async fn preview_transition(
.map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string()))?;
if !plan.commands.is_empty() { check_commands(&plan.commands)?; }
if plan.conflicts.is_empty() {
let candidate = oa_core::transitions::apply_transition(&current.model, &plan)
let candidate = oa_core::transitions::prepare_transition(&current.model, &plan)
.map_err(|error| ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, "transition_rejected", error.to_string()))?;
validate_model(&candidate)?;
PreparedProject::from_command(current, candidate)?;
}
Ok(response(current, StatusCode::OK, json!({"base_revision":current.revision,"plan":plan})))
}).await
@@ -1017,7 +1113,7 @@ async fn commit_transition(
if !request.plan.commands.is_empty() {
check_commands(&request.plan.commands)?;
}
let candidate = oa_core::transitions::apply_transition(&current.model, &request.plan)
let candidate = oa_core::transitions::prepare_transition(&current.model, &request.plan)
.map_err(|error| {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
@@ -1025,7 +1121,8 @@ async fn commit_transition(
error.to_string(),
)
})?;
let (stored, changed) = store.replace(id, &expected, candidate)?;
let prepared = PreparedProject::from_command(current, candidate)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
+232 -40
View File
@@ -1,7 +1,8 @@
//! Single-owner on-disk envelopes. All methods run on a blocking worker.
use crate::error::{ApiError, ApiResult, issues};
use crate::error::{ApiError, ApiResult, issues, source_error};
use axum::http::StatusCode;
use oa_core::persistence::{DecodeResult, MigrationReport};
use oa_core::source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle};
use oa_core::{Id, Project};
use serde::{Deserialize, Serialize};
use std::{
@@ -13,6 +14,7 @@ use std::{
};
pub const MAX_MODEL_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_SOURCE_PROJECT_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_OBJECTS: usize = 5_000;
pub const MAX_PROJECTS: usize = 128;
pub const MAX_STORE_BYTES: usize = 256 * 1024 * 1024;
@@ -22,7 +24,13 @@ pub(crate) const MODEL_LIMITS: oa_core::language::ParseLimits = oa_core::languag
max_records: 20_000,
max_step_references: 50_000,
};
const MAX_ENVELOPE_BYTES: usize = MAX_MODEL_BYTES + 512;
pub(crate) const SOURCE_LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1_024,
model: MODEL_LIMITS,
};
const MAX_ENVELOPE_BYTES: usize = MAX_MODEL_BYTES + MAX_SOURCE_PROJECT_BYTES + 512;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -30,6 +38,7 @@ pub struct StoredProject {
pub resource_id: Id,
pub revision: u64,
pub model: Project,
pub source_project: SourceBundle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub migration: Option<MigrationReport>,
}
@@ -93,11 +102,88 @@ struct StoreInner {
/// Constructed only after complete candidate validation, outside the catalog
/// mutex. Commit methods cannot accidentally accept an unchecked candidate.
struct PreparedModel(Project, Option<MigrationReport>);
impl PreparedModel {
pub(crate) struct PreparedProject {
compiled: CompiledSourceProject,
migration: Option<MigrationReport>,
}
impl PreparedProject {
fn new(model: Project) -> ApiResult<Self> {
validate_model(&model)?;
Ok(Self(model, None))
let prepared = Self::from_bundle(source_project::format_bundle(&model))?;
if prepared.model() != &model {
return Err(ApiError::internal(
"Generated source does not reproduce the model.",
));
}
Ok(prepared)
}
pub(crate) fn from_bundle(bundle: SourceBundle) -> ApiResult<Self> {
check_source_size(&bundle)?;
let compiled =
source_project::compile_bundle(&bundle, SOURCE_LIMITS).map_err(source_error)?;
Self::from_compiled(compiled)
}
fn from_compiled(compiled: CompiledSourceProject) -> ApiResult<Self> {
check_model_size(&compiled.decoded.project)?;
check_source_size(&compiled.bundle)?;
Ok(Self {
compiled,
migration: None,
})
}
pub(crate) fn from_command(
current: &StoredProject,
prepared: oa_core::PreparedCommand,
) -> ApiResult<Self> {
check_model_size(&prepared.project)?;
let compiled = source_project::compile_bundle(&current.source_project, SOURCE_LIMITS)
.map_err(source_error)?;
// Model equality alone is not a source no-op: delete/recreate and
// identity swaps can change declaration ownership without changing the
// final semantic snapshot.
let compiled = source_project::update_bundle_with_identity_map(
&compiled,
&prepared.project,
&prepared.identity_map,
SOURCE_LIMITS,
)
.map_err(source_error)?;
Self::from_compiled(compiled)
}
fn replacement(current: &StoredProject, decoded: DecodeResult) -> ApiResult<Self> {
validate_model(&decoded.project)?;
let compiled = source_project::compile_bundle(&current.source_project, SOURCE_LIMITS)
.map_err(source_error)?;
let compiled = source_project::update_bundle(&compiled, &decoded.project, SOURCE_LIMITS)
.map_err(source_error)?;
let mut prepared = Self::from_compiled(compiled)?;
prepared.migration = decoded.migration;
Ok(prepared)
}
pub(crate) fn model(&self) -> &Project {
&self.compiled.decoded.project
}
pub(crate) fn bundle(&self) -> &SourceBundle {
&self.compiled.bundle
}
pub(crate) fn source_issues(&self) -> Vec<crate::error::IssueDto> {
self.compiled
.diagnostics
.iter()
.cloned()
.map(crate::error::source_issue)
.collect()
}
pub(crate) fn changed(&self, current: &StoredProject) -> bool {
self.model() != &current.model || self.bundle() != &current.source_project
}
}
@@ -130,13 +216,17 @@ impl Store {
}
pub(crate) fn create(&self, model: Project) -> ApiResult<StoredProject> {
let prepared = PreparedModel::new(model)?;
let prepared = PreparedProject::new(model)?;
self.lock()?.create(prepared)
}
pub(crate) fn create_decoded(&self, decoded: DecodeResult) -> ApiResult<StoredProject> {
let mut prepared = PreparedModel::new(decoded.project)?;
prepared.1 = decoded.migration;
let mut prepared = PreparedProject::new(decoded.project)?;
prepared.migration = decoded.migration;
self.lock()?.create(prepared)
}
pub(crate) fn create_source_project(&self, bundle: SourceBundle) -> ApiResult<StoredProject> {
let prepared = PreparedProject::from_bundle(bundle)?;
self.lock()?.create(prepared)
}
pub(crate) fn replace_decoded(
@@ -145,8 +235,8 @@ impl Store {
expected: &str,
decoded: DecodeResult,
) -> ApiResult<(StoredProject, bool)> {
let mut prepared = PreparedModel::new(decoded.project)?;
prepared.1 = decoded.migration;
let current = self.checked(id, expected)?;
let prepared = PreparedProject::replacement(&current, decoded)?;
self.lock()?.replace(id, expected, prepared)
}
@@ -154,9 +244,8 @@ impl Store {
&self,
id: Id,
expected: &str,
model: Project,
prepared: PreparedProject,
) -> ApiResult<(StoredProject, bool)> {
let prepared = PreparedModel::new(model)?;
self.lock()?.replace(id, expected, prepared)
}
@@ -216,8 +305,7 @@ impl Store {
if bytes.len() > MAX_ENVELOPE_BYTES {
return Err(corrupt("Envelope exceeds the size limit.".into()));
}
total_bytes = total_bytes.saturating_add(bytes.len());
if projects.len() >= MAX_PROJECTS || total_bytes > MAX_STORE_BYTES {
if projects.len() >= MAX_PROJECTS {
return Err(StoreError::Capacity(
"At most 128 models and 256 MiB of serialized envelopes are supported.".into(),
));
@@ -228,6 +316,8 @@ impl Store {
resource_id: Id,
revision: u64,
model: Box<serde_json::value::RawValue>,
#[serde(default, deserialize_with = "present_source_project")]
source_project: Option<SourceBundle>,
}
let envelope: Envelope =
serde_json::from_slice(&bytes).map_err(|error| corrupt(error.to_string()))?;
@@ -236,10 +326,27 @@ impl Store {
MODEL_LIMITS,
)
.map_err(|error| corrupt(error.to_string()))?;
let prepared = if let Some(bundle) = envelope.source_project {
let prepared = PreparedProject::from_bundle(bundle.clone())
.map_err(|error| corrupt(error.detail))?;
if prepared.bundle() != &bundle || prepared.model() != &decoded.project {
return Err(corrupt(
"Stored source and model disagree, or source lacks stable identities."
.into(),
));
}
prepared
} else {
// Old model-only envelopes gain deterministic source in memory.
// Opening the store never saves or advances their revision.
PreparedProject::new(decoded.project.clone())
.map_err(|error| corrupt(error.detail))?
};
let stored = StoredProject {
resource_id: envelope.resource_id,
revision: envelope.revision,
model: decoded.project,
source_project: prepared.compiled.bundle,
migration: decoded.migration,
};
if stored.resource_id != filename_id || stored.revision == 0 {
@@ -247,8 +354,18 @@ impl Store {
"Resource ID/filename mismatch or zero revision.".into(),
));
}
validate_model(&stored.model).map_err(|error| corrupt(error.detail))?;
byte_sizes.insert(stored.resource_id, bytes.len());
let size = envelope_bytes(&stored)
.map_err(|error| corrupt(error.detail))?
.len();
// Capacity charges the complete accepted pair, including an old
// envelope's in-memory source upgrade before its next changed save.
total_bytes = total_bytes.saturating_add(size);
if total_bytes > MAX_STORE_BYTES {
return Err(StoreError::Capacity(
"Complete accepted envelopes exceed 256 MiB.".into(),
));
}
byte_sizes.insert(stored.resource_id, size);
if projects.insert(stored.resource_id, stored).is_some() {
return Err(corrupt("Duplicate hosted resource ID.".into()));
}
@@ -284,16 +401,16 @@ impl StoreInner {
Ok(current)
}
fn create(&mut self, prepared: PreparedModel) -> ApiResult<StoredProject> {
fn create(&mut self, prepared: PreparedProject) -> ApiResult<StoredProject> {
if self.projects.len() >= MAX_PROJECTS {
return Err(capacity_error());
}
let model = prepared.0;
let stored = StoredProject {
resource_id: Id::new_v4(),
revision: 1,
model,
migration: prepared.1,
model: prepared.compiled.decoded.project,
source_project: prepared.compiled.bundle,
migration: prepared.migration,
};
let size = self.write(&stored, true)?;
self.adopt(stored.clone(), size);
@@ -305,14 +422,13 @@ impl StoreInner {
&mut self,
id: Id,
expected: &str,
prepared: PreparedModel,
prepared: PreparedProject,
) -> ApiResult<(StoredProject, bool)> {
let current = self.checked(id, expected)?;
let model = prepared.0;
if current.model == model {
if !prepared.changed(current) {
let mut unchanged = current.clone();
if prepared.1.is_some() {
unchanged.migration = prepared.1;
if prepared.migration.is_some() {
unchanged.migration = prepared.migration;
}
return Ok((unchanged, false));
}
@@ -326,8 +442,9 @@ impl StoreInner {
let stored = StoredProject {
resource_id: id,
revision,
model,
migration: prepared.1,
model: prepared.compiled.decoded.project,
source_project: prepared.compiled.bundle,
migration: prepared.migration,
};
let size = self.write(&stored, false)?;
self.adopt(stored.clone(), size);
@@ -357,17 +474,7 @@ impl StoreInner {
}
fn write(&self, stored: &StoredProject, create: bool) -> ApiResult<usize> {
// Migration observations are response metadata, never persisted facts.
let envelope = serde_json::json!({"resource_id": stored.resource_id, "revision": stored.revision, "model": stored.model});
let bytes = serde_json::to_vec(&envelope)
.map_err(|_| ApiError::internal("Could not serialize model."))?;
if bytes.len() > MAX_ENVELOPE_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"model_too_large",
"Stored model exceeds 8 MiB.",
));
}
let bytes = envelope_bytes(stored)?;
let previous = self
.byte_sizes
.get(&stored.resource_id)
@@ -410,6 +517,51 @@ fn capacity_error() -> ApiError {
)
}
fn present_source_project<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<SourceBundle>, D::Error> {
SourceBundle::deserialize(deserializer).map(Some)
}
fn envelope_bytes(stored: &StoredProject) -> ApiResult<Vec<u8>> {
// Migration observations are response metadata, never persisted facts.
#[derive(Serialize)]
struct Envelope<'a> {
resource_id: Id,
revision: u64,
model: &'a Project,
source_project: &'a SourceBundle,
}
let bytes = serde_json::to_vec(&Envelope {
resource_id: stored.resource_id,
revision: stored.revision,
model: &stored.model,
source_project: &stored.source_project,
})
.map_err(|_| ApiError::internal("Could not serialize accepted project."))?;
if bytes.len() > MAX_ENVELOPE_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"project_too_large",
"Stored project exceeds the envelope limit.",
));
}
Ok(bytes)
}
pub(crate) fn check_source_size(bundle: &SourceBundle) -> ApiResult<()> {
let bytes = serde_json::to_vec(bundle)
.map_err(|_| ApiError::bad_request("Source project cannot be serialized."))?;
if bytes.len() > MAX_SOURCE_PROJECT_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"source_project_too_large",
"Serialized source project exceeds 8 MiB after identity stabilization.",
));
}
Ok(())
}
pub(crate) fn check_model_size(model: &Project) -> ApiResult<()> {
MODEL_LIMITS
.check_model(model)
@@ -445,6 +597,46 @@ pub(crate) fn validate_model(model: &Project) -> ApiResult<()> {
mod tests {
use super::*;
#[test]
fn source_bytes_are_charged_on_legacy_open_update_and_delete() {
let directory = tempfile::tempdir().unwrap();
let resource_id = Id::new_v4();
let model = Project::empty();
let path = directory.path().join(format!("{resource_id}.json"));
let legacy_bytes = serde_json::to_vec(
&serde_json::json!({"resource_id":resource_id,"revision":4,"model":model}),
)
.unwrap();
fs::write(&path, &legacy_bytes).unwrap();
let store = Store::open(directory.path()).unwrap();
let current = store.get(resource_id).unwrap();
let accepted_size = envelope_bytes(&current).unwrap().len();
assert!(accepted_size > legacy_bytes.len());
assert_eq!(store.lock().unwrap().total_bytes, accepted_size);
assert_eq!(fs::read(&path).unwrap(), legacy_bytes);
let mut bundle = current.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str(&format!("// {}\n", "rationale ".repeat(512)));
let candidate = PreparedProject::from_bundle(bundle).unwrap();
let changed = store
.replace(resource_id, &current.etag(), candidate)
.unwrap()
.0;
let changed_size = envelope_bytes(&changed).unwrap().len();
assert!(changed_size > accepted_size + 5_000);
assert_eq!(store.lock().unwrap().total_bytes, changed_size);
assert_eq!(fs::read(&path).unwrap().len(), changed_size);
drop(store);
let store = Store::open(directory.path()).unwrap();
assert_eq!(store.lock().unwrap().total_bytes, changed_size);
store.delete(resource_id, &changed.etag()).unwrap();
assert_eq!(store.lock().unwrap().total_bytes, 0);
assert!(store.lock().unwrap().byte_sizes.is_empty());
}
#[test]
fn prepared_candidates_recheck_revision_at_commit_and_failed_loser_is_not_installed() {
let directory = tempfile::tempdir().unwrap();
@@ -456,8 +648,8 @@ mod tests {
winner.name = "Winning review".into();
loser.name = "Losing stale review".into();
// Both requests completed preparation against the same original head.
let winner = PreparedModel::new(winner).unwrap();
let loser = PreparedModel::new(loser).unwrap();
let winner = PreparedProject::new(winner).unwrap();
let loser = PreparedProject::new(loser).unwrap();
let committed = store
.lock()
.unwrap()
+13 -3
View File
@@ -8,6 +8,9 @@ use serde_json::{Value, json};
use std::{path::Path, sync::Arc};
use tokio::{sync::oneshot, task::JoinHandle};
#[path = "http/source_projects.rs"]
mod source_projects;
fn v2_missing_ports() -> Value {
let mut old: Value = serde_json::from_slice(include_bytes!(
"../../oa-core/tests/fixtures/v2-coastal-rescue.json"
@@ -1590,6 +1593,7 @@ async fn failed_atomic_replacement_preserves_the_accepted_model() {
let after = server.get(id).await;
assert_eq!(after.revision, before.revision);
assert_eq!(after.model, before.model);
assert_eq!(after.source_project, before.source_project);
std::fs::remove_dir(&envelope).unwrap();
std::fs::write(&envelope, bytes).unwrap();
server.stop().await;
@@ -1605,19 +1609,23 @@ fn storage_rejects_second_owners_corruption_and_identity_mismatch() {
let path = directory.path().join(format!("{filename}.json"));
std::fs::write(&path, "{broken").unwrap();
assert!(Store::open(directory.path()).is_err());
let model = Project::empty();
let mismatched = StoredProject {
migration: None,
resource_id: Id::new_v4(),
revision: 1,
model: Project::empty(),
source_project: oa_core::source_project::format_bundle(&model),
model,
};
std::fs::write(&path, serde_json::to_vec(&mismatched).unwrap()).unwrap();
assert!(Store::open(directory.path()).is_err());
let model = Project::empty();
let invalid = StoredProject {
migration: None,
resource_id: filename,
revision: 0,
model: Project::empty(),
source_project: oa_core::source_project::format_bundle(&model),
model,
};
std::fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap();
assert!(Store::open(directory.path()).is_err());
@@ -1773,11 +1781,13 @@ async fn fixed_store_capacity_is_enforced_on_http_and_restart() {
let directory = tempfile::tempdir().unwrap();
let mut last = None;
for _ in 0..oa_server::store::MAX_PROJECTS {
let model = Project::empty();
let envelope = StoredProject {
migration: None,
resource_id: Id::new_v4(),
revision: 1,
model: Project::empty(),
source_project: oa_core::source_project::format_bundle(&model),
model,
};
std::fs::write(
directory
@@ -0,0 +1,806 @@
use super::*;
use oa_core::source_project::*;
use std::collections::BTreeMap;
fn authored() -> SourceBundle {
let bundle = SourceBundle {
entry: "harbor.arcadia".into(),
files: BTreeMap::from([
("harbor.arcadia".into(), "// Harbor engineering rationale 航海\r\narcadia 3; project harbor \"Harbor rescue\" {\r\n include \"team/first.arcadia\";\r\n include \"team/second.arcadia\";\r\n include \"contracts.arcadia\";\r\n include \"work.arcadia\";\r\n}\r\n".into()),
("team/first.arcadia".into(), "// First payload rationale\r\nsa_exchange_item first \"First payload\" { description \"Reviewed first\"; }\r\n".into()),
("team/second.arcadia".into(), "// Second payload rationale\r\nsa_exchange_item second \"Second payload\" {}\r\n".into()),
("contracts.arcadia".into(), "// Contract ownership stays here\r\nsa_interface contract \"Contract\" { items [\r\n @first // first reference rationale\r\n ,\r\n @second // second reference rationale\r\n ]; }\r\n".into()),
("work.arcadia".into(), "// Operational rationale\r\noa_activity assess \"Assess assistance\" {}\r\n".into()),
]),
};
compile_bundle(&bundle, ProjectLimits::default())
.unwrap()
.bundle
}
fn id_for(model: &Project, alias: &str) -> Id {
model
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn assert_pair(stored: &StoredProject) -> CompiledSourceProject {
let compiled = compile_bundle(&stored.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, stored.model);
assert_eq!(
compiled.bundle, stored.source_project,
"Stored IDs already stabilized"
);
compiled
}
async fn import(server: &TestServer, bundle: &SourceBundle) -> StoredProject {
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(bundle)
.send()
.await
.unwrap();
let status = response.status();
let body: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::CREATED, "{body}");
server
.get(body["resource_id"].as_str().unwrap().parse().unwrap())
.await
}
async fn put(server: &TestServer, current: &StoredProject, bundle: &SourceBundle) -> Value {
let response = server
.client
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.header("if-match", current.etag())
.json(bundle)
.send()
.await
.unwrap();
let status = response.status();
let body: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{body}");
body
}
async fn transact(
server: &TestServer,
current: &StoredProject,
commands: &[Command],
dry_run: bool,
) -> Value {
let response = server
.client
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
.header("if-match", current.etag())
.json(&json!({"commands":commands,"dry_run":dry_run}))
.send()
.await
.unwrap();
let status = response.status();
let body: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{body}");
body
}
#[tokio::test]
async fn source_projects_preserve_exact_files_comments_and_comment_only_revisions() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let source = authored();
let original = import(&server, &source).await;
let id = original.resource_id;
assert_eq!(original.source_project, source);
assert_pair(&original);
let url = server.url(&format!("/projects/{id}/source-project"));
let exported = server.client.get(&url).send().await.unwrap();
assert_eq!(
exported.headers()["etag"].to_str().unwrap(),
original.etag()
);
assert_eq!(exported.json::<SourceBundle>().await.unwrap(), source);
assert_eq!(
server
.client
.get(&url)
.header("if-none-match", original.etag())
.send()
.await
.unwrap()
.status(),
StatusCode::NOT_MODIFIED
);
let mut edited = source.clone();
edited
.files
.get_mut("team/first.arcadia")
.unwrap()
.push_str("// Reviewed through automation\r\n");
let response = put(&server, &original, &edited).await;
assert_eq!(response["changed"], true);
assert_eq!(
response["source_project"],
serde_json::to_value(&edited).unwrap()
);
let current = server.get(id).await;
assert_eq!(current.revision, 2);
assert_eq!(current.model, original.model);
assert_eq!(current.source_project, edited);
let stale = server
.client
.post(server.url(&format!("/projects/{id}/transactions")))
.header("if-match", original.etag())
.json(&json!({"commands":[Command::Rename(current.model.id,"Stale change".into())]}))
.send()
.await
.unwrap();
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
let filename = directory.path().join(format!("{id}.json"));
let saved_bytes = std::fs::read(&filename).unwrap();
let no_op = put(&server, &current, &edited).await;
assert_eq!(no_op["changed"], false);
assert_eq!(no_op["revision"], 2);
assert_eq!(std::fs::read(&filename).unwrap(), saved_bytes);
let mut renamed = edited;
let text = renamed.files.remove(&renamed.entry).unwrap();
renamed.entry = "rescue.arcadia".into();
renamed.files.insert(renamed.entry.clone(), text);
assert_eq!(put(&server, &current, &renamed).await["changed"], true);
let current = server.get(id).await;
assert_eq!(current.revision, 3);
assert_eq!(current.model, original.model);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let restored = server.get(id).await;
assert_eq!(restored.source_project, renamed);
assert_eq!(restored.etag(), current.etag());
assert_pair(&restored);
server.stop().await;
}
#[tokio::test]
async fn source_projects_failed_atomic_comment_write_preserves_both_representations() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = import(&server, &authored()).await;
let id = current.resource_id;
let envelope = directory.path().join(format!("{id}.json"));
let bytes = std::fs::read(&envelope).unwrap();
let mut edited = current.source_project.clone();
edited
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("// Must not be adopted\r\n");
std::fs::remove_file(&envelope).unwrap();
std::fs::create_dir(&envelope).unwrap();
let failed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", current.etag())
.json(&edited)
.send()
.await
.unwrap();
assert_eq!(failed.status(), StatusCode::SERVICE_UNAVAILABLE);
let unchanged = server.get(id).await;
assert_eq!(unchanged.etag(), current.etag());
assert_eq!(unchanged.model, current.model);
assert_eq!(unchanged.source_project, current.source_project);
std::fs::remove_dir(&envelope).unwrap();
std::fs::write(&envelope, &bytes).unwrap();
assert!(!std::fs::read_dir(directory.path()).unwrap().any(|entry| {
entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".arcadia-write-")
}));
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.source_project, current.source_project);
server.stop().await;
}
#[test]
fn source_projects_openapi_describes_the_coupled_envelope_and_source_diagnostics() {
let spec: Value = serde_json::from_str(include_str!("../../../../docs/openapi.json")).unwrap();
let schemas = &spec["components"]["schemas"];
assert!(
schemas["StoredProject"]["required"]
.as_array()
.unwrap()
.contains(&json!("source_project"))
);
assert_eq!(
schemas["SourceBundle"]["required"],
json!(["entry", "files"])
);
assert_eq!(schemas["SourceBundle"]["additionalProperties"], false);
assert_eq!(
schemas["SourceBundle"]["properties"]["files"]["maxProperties"],
256
);
for property in ["path", "span", "related"] {
assert!(schemas["Issue"]["properties"].get(property).is_some());
}
assert_eq!(
schemas["TransactionResult"]["properties"]["source_project"]["$ref"],
"#/components/schemas/SourceBundle"
);
assert!(
schemas["ValidationRequest"]["oneOf"]
.as_array()
.unwrap()
.iter()
.any(|candidate| candidate["properties"]["format"]["const"] == "source-project")
);
let routes = &spec["paths"];
assert_eq!(
routes["/api/v1/projects/import-source-project"]["post"]["requestBody"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/SourceBundle"
);
assert_eq!(
routes["/api/v1/projects/{id}/source-project"]["get"]["responses"]["200"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/SourceBundle"
);
assert_eq!(
routes["/api/v1/projects/{id}/source-project"]["put"]["responses"]["200"]["content"]["application/json"]
["schema"]["$ref"],
"#/components/schemas/SourceProjectMutationResult"
);
}
#[tokio::test]
async fn source_projects_command_dry_runs_and_identity_swaps_preserve_lexical_ownership() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let first = id_for(&original.model, "first");
let second = id_for(&original.model, "second");
let changed_id = Id::new_v4();
let mut element = original.model.elements[&first].clone();
element.id = changed_id;
element.alias = "revised_first".into();
element.name = "Reviewed payload".into();
let commands = vec![Command::EditElement(first, element)];
let filename = directory
.path()
.join(format!("{}.json", original.resource_id));
let before_bytes = std::fs::read(&filename).unwrap();
let dry_run = transact(&server, &original, &commands, true).await;
let proposed: SourceBundle = serde_json::from_value(dry_run["source_project"].clone()).unwrap();
assert!(proposed.files["team/first.arcadia"].contains(&changed_id.to_string()));
assert!(proposed.files["team/first.arcadia"].starts_with("// First payload rationale\r\n"));
assert_eq!(
proposed.files["team/second.arcadia"],
original.source_project.files["team/second.arcadia"]
);
assert!(
proposed.files["contracts.arcadia"].contains("@revised_first // first reference rationale")
);
assert_eq!(
server.get(original.resource_id).await.source_project,
original.source_project
);
assert_eq!(std::fs::read(&filename).unwrap(), before_bytes);
transact(&server, &original, &commands, false).await;
let current = server.get(original.resource_id).await;
assert_eq!(current.source_project, proposed);
assert_eq!(
assert_pair(&current).index.declarations[&changed_id].path,
"team/first.arcadia"
);
let temporary = Id::new_v4();
let swaps = vec![Command::Batch(vec![
Command::Reidentify(changed_id, temporary),
Command::Reidentify(second, changed_id),
Command::Reidentify(temporary, second),
])];
transact(&server, &current, &swaps, false).await;
let swapped = server.get(current.resource_id).await;
let compiled = assert_pair(&swapped);
assert_eq!(
compiled.index.declarations[&second].path,
"team/first.arcadia"
);
assert_eq!(
compiled.index.declarations[&changed_id].path,
"team/second.arcadia"
);
assert!(
swapped.source_project.files["team/first.arcadia"]
.starts_with("// First payload rationale\r\n")
);
assert!(
swapped.source_project.files["contracts.arcadia"]
.contains("@revised_first // first reference rationale")
);
// Removing and reusing a UUID does not attach the old authored declaration
// and its rationale to an unrelated new record.
let mut replacement = swapped.model.elements[&second].clone();
replacement.alias = "new_payload".into();
replacement.name = "Independent new payload".into();
replacement.description.clear();
transact(
&server,
&swapped,
&[Command::Delete(second), Command::CreateElement(replacement)],
false,
)
.await;
let replaced = server.get(swapped.resource_id).await;
let compiled = assert_pair(&replaced);
assert_ne!(
compiled.index.declarations[&second].path,
"team/first.arcadia"
);
assert!(
replaced.source_project.files["team/first.arcadia"].contains("First payload rationale")
);
assert!(!replaced.source_project.files["team/first.arcadia"].contains("new_payload"));
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(
server.get(replaced.resource_id).await.source_project,
replaced.source_project
);
server.stop().await;
}
#[tokio::test]
async fn source_projects_equal_models_still_apply_changed_identity_provenance() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let work = id_for(&original.model, "assess");
let commands = [
Command::Delete(work),
Command::CreateElement(original.model.elements[&work].clone()),
];
let prepared =
oa_core::prepare_command(&original.model, &Command::Batch(commands.to_vec())).unwrap();
assert_eq!(prepared.project, original.model);
assert!(!prepared.identity_map.contains_key(&work));
let dry_run = transact(&server, &original, &commands, true).await;
assert_eq!(
dry_run["changed"], true,
"Recreated record must not inherit the deleted record's declaration ownership"
);
assert_eq!(
dry_run["model"],
serde_json::to_value(&original.model).unwrap()
);
let response = transact(&server, &original, &commands, false).await;
assert_eq!(response["changed"], true);
let changed = server.get(original.resource_id).await;
assert_eq!(changed.revision, original.revision + 1);
assert_eq!(changed.model, original.model);
let compiled = assert_pair(&changed);
assert_ne!(compiled.index.declarations[&work].path, "work.arcadia");
assert!(changed.source_project.files["work.arcadia"].contains("Operational rationale"));
assert!(!changed.source_project.files["work.arcadia"].contains("oa_activity"));
server.stop().await;
}
#[tokio::test]
async fn source_projects_model_interchange_and_transitions_keep_authored_modules() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let id = original.resource_id;
let work = id_for(&original.model, "assess");
let mut model = original.model.clone();
model.elements.get_mut(&work).unwrap().name = "Review assistance".into();
let response = server
.client
.put(server.url(&format!("/projects/{id}/model")))
.header("if-match", original.etag())
.json(&model)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let current = server.get(id).await;
assert_eq!(current.source_project.entry, "harbor.arcadia");
assert_eq!(
current.source_project.files["team/first.arcadia"],
original.source_project.files["team/first.arcadia"]
);
assert!(
current.source_project.files["work.arcadia"].starts_with("// Operational rationale\r\n")
);
model.elements.get_mut(&work).unwrap().description =
"Changed through canonical interchange".into();
let response = server
.client
.put(server.url(&format!("/projects/{id}/source")))
.header("if-match", current.etag())
.header("content-type", "text/plain")
.body(oa_core::language::format(&model))
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let current = server.get(id).await;
assert_pair(&current);
let request = oa_core::transitions::TransitionRequest::new(
oa_core::transitions::TransitionDirection::OaToSa,
vec![work],
);
let response = server
.client
.post(server.url(&format!("/projects/{id}/transitions/preview")))
.header("if-match", current.etag())
.json(&request)
.send()
.await
.unwrap();
let status = response.status();
let preview: Value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{preview}");
assert_eq!(server.get(id).await.source_project, current.source_project);
let response = server
.client
.post(server.url(&format!("/projects/{id}/transitions/apply")))
.header("if-match", current.etag())
.json(&preview)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let final_state = server.get(id).await;
assert_pair(&final_state);
assert!(
final_state
.model
.elements
.values()
.any(|e| e.kind() == Kind::Function)
);
assert_eq!(final_state.model.traces.len(), 1);
for path in [
"team/first.arcadia",
"team/second.arcadia",
"contracts.arcadia",
] {
assert_eq!(
final_state.source_project.files[path],
original.source_project.files[path]
);
}
server.stop().await;
}
#[tokio::test]
async fn source_projects_invalid_dependencies_and_duplicate_paths_never_change_the_head() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = import(&server, &authored()).await;
let id = current.resource_id;
let filename = directory.path().join(format!("{id}.json"));
let original_bytes = std::fs::read(&filename).unwrap();
// A same-named server file cannot satisfy a logical source include.
std::fs::write(
directory.path().join("server_only.arcadia"),
"oa_activity external \"Must not load\" {}\n",
)
.unwrap();
let mut cases = vec![];
let mut missing = current.source_project.clone();
missing
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("include \"server_only.arcadia\";\n");
cases.push((missing, "missing_include"));
let mut duplicate = current.source_project.clone();
let first = duplicate.files["team/first.arcadia"].clone();
duplicate
.files
.get_mut("team/second.arcadia")
.unwrap()
.push_str(&first);
cases.push((duplicate, "duplicate_declaration"));
let mut invalid = current.source_project.clone();
invalid
.files
.get_mut("team/second.arcadia")
.unwrap()
.push('?');
cases.push((invalid, ""));
let mut cycle = current.source_project.clone();
cycle
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("include \"harbor.arcadia\";\n");
cases.push((cycle, "include_cycle"));
let mut orphan = current.source_project.clone();
orphan
.files
.insert("orphan.arcadia".into(), "// not included\n".into());
cases.push((orphan, "unreachable_file"));
let mut escaped = current.source_project.clone();
escaped
.files
.insert("../outside.arcadia".into(), String::new());
cases.push((escaped, "invalid_path"));
let mut excessive = current.source_project.clone();
for n in 0..256 {
excessive
.files
.insert(format!("empty{n}.arcadia"), String::new());
}
cases.push((excessive, "project_limit"));
for (bundle, code) in cases {
let response = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", current.etag())
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let error: Value = response.json().await.unwrap();
let issue = &error["issues"][0];
if !code.is_empty() {
assert_eq!(issue["code"], code, "{error}");
}
assert!(issue["path"].is_string(), "{error}");
assert!(issue["span"]["start"].is_number(), "{error}");
assert!(issue["line"].as_u64().unwrap() >= 1);
if code == "duplicate_declaration" {
assert_eq!(issue["path"], "team/second.arcadia");
assert_eq!(issue["related"][0]["path"], "team/first.arcadia");
}
let response = server
.client
.post(server.url(&format!("/projects/{id}/validation")))
.json(&json!({"format":"source-project","source_project":bundle}))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let validation: Value = response.json().await.unwrap();
assert_eq!(validation["valid"], false, "{validation}");
assert_eq!(validation["issues"][0]["path"], issue["path"]);
assert_eq!(server.get(id).await.source_project, current.source_project);
assert_eq!(std::fs::read(&filename).unwrap(), original_bytes);
}
let body = "{\"entry\":\"a.arcadia\",\"files\":{\"a.arcadia\":\"one\",\"a.arcadia\":\"two\"}}";
let duplicate_keys = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", current.etag())
.header("content-type", "application/json")
.body(body)
.send()
.await
.unwrap();
assert_eq!(duplicate_keys.status(), StatusCode::BAD_REQUEST);
assert_eq!(std::fs::read(&filename).unwrap(), original_bytes);
let missing_precondition = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.json(&current.source_project)
.send()
.await
.unwrap();
assert_eq!(
missing_precondition.status(),
StatusCode::PRECONDITION_REQUIRED
);
server.stop().await;
}
#[tokio::test]
async fn source_projects_stabilize_once_and_enforce_size_after_insertions() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let bundle = SourceBundle {
entry: "entry.arcadia".into(),
files: BTreeMap::from([
(
"entry.arcadia".into(),
"// Header rationale\narcadia 3; project p \"P\" { include \"work.arcadia\"; }\n"
.into(),
),
(
"work.arcadia".into(),
"// Work rationale\noa_activity work \"Work\" {}\n".into(),
),
]),
};
let current = import(&server, &bundle).await;
assert_ne!(current.source_project, bundle);
assert!(current.source_project.files["work.arcadia"].starts_with("// Work rationale\n"));
assert_pair(&current);
assert_eq!(
put(&server, &current, &current.source_project).await["changed"],
false
);
let mut large = SourceBundle {
entry: "entry.arcadia".into(),
files: BTreeMap::from([(
"entry.arcadia".into(),
"arcadia 3; project p \"P\" {}\n// ".into(),
)]),
};
let base = serde_json::to_vec(&large).unwrap().len();
large
.files
.get_mut("entry.arcadia")
.unwrap()
.push_str(&"x".repeat(oa_server::store::MAX_SOURCE_PROJECT_BYTES - base - 8));
assert!(serde_json::to_vec(&large).unwrap().len() < oa_server::store::MAX_MODEL_BYTES);
let response = server
.client
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
.header("if-match", current.etag())
.json(&large)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
let error: Value = response.json().await.unwrap();
assert_eq!(error["code"], "source_project_too_large");
assert_eq!(
server.get(current.resource_id).await.source_project,
current.source_project
);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let restored = server.get(current.resource_id).await;
assert_eq!(restored.source_project, current.source_project);
assert_eq!(restored.etag(), current.etag());
server.stop().await;
}
#[tokio::test]
async fn source_projects_concurrent_comment_and_semantic_writes_share_one_revision() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let original = import(&server, &authored()).await;
let id = original.resource_id;
let mut bundle = original.source_project.clone();
bundle
.files
.get_mut("work.arcadia")
.unwrap()
.push_str("// Comment writer\r\n");
let comment = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", original.etag())
.json(&bundle)
.send();
let semantic = server
.client
.patch(server.url(&format!("/projects/{id}")))
.header("if-match", original.etag())
.json(&json!({"name":"Semantic writer"}))
.send();
let (comment, semantic) = tokio::join!(comment, semantic);
let statuses = [comment.unwrap().status(), semantic.unwrap().status()];
assert_eq!(statuses.iter().filter(|s| **s == StatusCode::OK).count(), 1);
assert_eq!(
statuses
.iter()
.filter(|s| **s == StatusCode::PRECONDITION_FAILED)
.count(),
1
);
let accepted = server.get(id).await;
assert_eq!(accepted.revision, 2);
assert_pair(&accepted);
if accepted.model.name == "Semantic writer" {
assert_eq!(
accepted.source_project.files["work.arcadia"],
original.source_project.files["work.arcadia"]
);
} else {
assert_eq!(accepted.model, original.model);
assert_eq!(accepted.source_project, bundle);
}
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.source_project, accepted.source_project);
server.stop().await;
}
#[tokio::test]
async fn source_projects_upgrade_old_envelopes_without_writes_and_reject_inconsistent_pairs() {
let directory = tempfile::tempdir().unwrap();
let resource = Id::new_v4();
let model = graph();
let filename = directory.path().join(format!("{resource}.json"));
let original =
serde_json::to_vec(&json!({"resource_id":resource,"revision":9,"model":model})).unwrap();
std::fs::write(&filename, &original).unwrap();
let server = TestServer::start(directory.path(), None).await;
let current = server.get(resource).await;
assert_eq!(current.model, model);
assert_eq!(current.revision, 9);
assert_pair(&current);
assert_eq!(std::fs::read(&filename).unwrap(), original);
let response = server
.client
.put(server.url(&format!("/projects/{resource}/model")))
.header("if-match", current.etag())
.json(&model)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.json::<Value>().await.unwrap()["changed"], false);
assert_eq!(std::fs::read(&filename).unwrap(), original);
let mut bundle = current.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str("// Reviewed source upgrade\n");
put(&server, &current, &bundle).await;
let accepted = server.get(resource).await;
assert_eq!(accepted.revision, 10);
server.stop().await;
let saved = std::fs::read(&filename).unwrap();
let mut envelope: Value = serde_json::from_slice(&saved).unwrap();
assert!(envelope.get("source_project").is_some());
envelope["model"]["name"] = json!("Corrupted cache");
std::fs::write(&filename, serde_json::to_vec(&envelope).unwrap()).unwrap();
assert!(Store::open(directory.path()).is_err());
let mut envelope: Value = serde_json::from_slice(&saved).unwrap();
envelope["source_project"] = Value::Null;
std::fs::write(&filename, serde_json::to_vec(&envelope).unwrap()).unwrap();
assert!(
Store::open(directory.path()).is_err(),
"Present null source is not a legacy envelope"
);
let mut envelope: Value = serde_json::from_slice(&saved).unwrap();
let entry = envelope["source_project"]["entry"]
.as_str()
.unwrap()
.to_owned();
let header_id = format!(" id \"{}\"", accepted.model.id);
let source = envelope["source_project"]["files"][&entry]
.as_str()
.unwrap();
assert!(source.contains(&header_id));
envelope["source_project"]["files"][&entry] = json!(source.replacen(&header_id, "", 1));
let unstable_bytes = serde_json::to_vec(&envelope).unwrap();
std::fs::write(&filename, &unstable_bytes).unwrap();
assert!(
Store::open(directory.path()).is_err(),
"Accepted sources must not silently acquire new IDs on restart"
);
assert_eq!(std::fs::read(&filename).unwrap(), unstable_bytes);
std::fs::write(&filename, &saved).unwrap();
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(resource).await.source_project, bundle);
server.stop().await;
}