feat: preserve nested source projects across desktop and REST
This commit is contained in:
+196
-74
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 = ¤t.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)
|
||||
}
|
||||
@@ -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(¤t.decoded.project, "owner"))
|
||||
.unwrap()
|
||||
.name = "Renamed owner".into();
|
||||
let result = apply(¤t, &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(¤t, &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(¤t.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(¤t, &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(¤t.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(¤t, &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(¤t.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(¤t, &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(¤t, &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(¤t.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(¤t.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(¤t, &project);
|
||||
assert_eq!(result.index.lexical_owners[&inner], root);
|
||||
assert_eq!(result.index.lexical_owners[&outer], inner);
|
||||
assert_eq!(comments(&result.bundle), comments(¤t.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 = ¤t.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(
|
||||
¤t,
|
||||
&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(¤t.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 = ¤t.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(¤t, &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(¤t.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(¤t, &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(¤t.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(¤t, &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(¤t.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(¤t, &project);
|
||||
assert_eq!(result.index.declarations[&leaf].path, "b.arcadia");
|
||||
assert_eq!(result.index.lexical_owners[&leaf], destination);
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
¤t.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(¤t.model, &request.commands)
|
||||
let candidate = oa_core::prepare_command(¤t.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(¤t.model, &plan)
|
||||
let candidate = oa_core::transitions::prepare_transition(¤t.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(¤t.model, &request.plan)
|
||||
let candidate = oa_core::transitions::prepare_transition(¤t.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
@@ -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(¤t.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(¤t.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() != ¤t.model || self.bundle() != ¤t.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(¤t, 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(¤t).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, ¤t.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()
|
||||
|
||||
@@ -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, ¤t, &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, ¤t, &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(¤t).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, ¤t, &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(¤t);
|
||||
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(¤t.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(¤t);
|
||||
assert_eq!(
|
||||
put(&server, ¤t, ¤t.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(¤t);
|
||||
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, ¤t, &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;
|
||||
}
|
||||
Reference in New Issue
Block a user