Test source-preserving writeback for nested authoring

This commit is contained in:
2026-09-05 19:55:05 +03:00
parent dbc8971dd2
commit 2dd17aa693
8 changed files with 1445 additions and 8 deletions
+1
View File
@@ -232,6 +232,7 @@ name = "source-authoring-experiment"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"oa-core", "oa-core",
"serde_json",
] ]
[[package]] [[package]]
+1 -1
View File
@@ -8,4 +8,4 @@ publish = false
[dependencies] [dependencies]
oa-core = { path = "../../crates/oa-core" } oa-core = { path = "../../crates/oa-core" }
serde_json = "1"
+2
View File
@@ -2,6 +2,8 @@
Status: bounded executable experiment, 2026-09-05. **Not production syntax or a release-completion claim.** Owns this directory only. The standalone Rust workspace depends on the real `oa-core` and never compiles Iced. Main checkpoint during verification: `06b33848cea5a0b1a6168d79a68e5185c190383e`. Status: bounded executable experiment, 2026-09-05. **Not production syntax or a release-completion claim.** Owns this directory only. The standalone Rust workspace depends on the real `oa-core` and never compiles Iced. Main checkpoint during verification: `06b33848cea5a0b1a6168d79a68e5185c190383e`.
The initial lowering experiment below was committed separately as `c5788fc`. The subsequent source-preserving writeback gate and its additional evidence are recorded in [WRITEBACK.md](WRITEBACK.md). The one-way-only limitation below describes the initial checkpoint, not the later experimental writer.
## Question and competing expectations ## Question and competing expectations
Does semantic ownership nesting, typed function-port/exchange shorthand and ordered occurrence blocks reduce the coordination burden of text-first OA/SA modeling without introducing different semantics or unstable identities? Does semantic ownership nesting, typed function-port/exchange shorthand and ordered occurrence blocks reduce the coordination burden of text-first OA/SA modeling without introducing different semantics or unstable identities?
+69
View File
@@ -0,0 +1,69 @@
# Bounded writeback experiment
Status: executable second gate, 2026-09-05. Production syntax remains undecided. The first gate is preserved in commit `c5788fc`; this gate changes only `experiments/source-authoring/`.
## Question
Can complete model edits write back to the tested B constructs and ordinary A fields while preserving comments, authored order, nested/concise syntax and multi-file organization? Or does the apparent simplicity of one-way lowering require destructive canonical replacement when the model changes?
Competing expectations: (1) scalar token spans and whole owned-declaration movement suffice for these workflows; (2) correct writeback requires broad syntax-tree regeneration; (3) some source organizations are ambiguous and need a declared editing policy rather than an inferred one.
## Method and implementation
`writeback::writeback(&SourceBundle, &Project)` accepts the actual complete requested model, not a hand-written textual edit recipe. It compiles the existing bundle, validates the requested candidate with core source3 validation, derives model record changes, applies bounded span edits to a cloned bundle, and recompiles the result. It returns a replacement only if that complete model equals the requested candidate. No caller state or disk files change on failure.
The index records declaration/header/value spans, B shorthand endpoints, lexical owners and steps blocks. It does not implement a second semantic model or a general concrete syntax tree. Matching object fields retain their authored order; changed scalar tokens and supported list additions retain surrounding comments/whitespace. Alias renames form one simultaneous lexical phase, followed by re-indexing; alias cycles are supported. Names/comments/ordinary strings are never interpreted as references.
Semantic ownership movement differs from source organization. An explicitly parented A declaration changes its parent field in place. A nested B declaration moves to its new semantic owner's body, including nested ports and attached comments. Allocation relations are unchanged unless the candidate explicitly changes them. Ordered B occurrence blocks reorder by stable UUID; newly created occurrences retain the candidate's supplied identity and use existing explicit-field bodies inside the steps block.
Tests use the three first-gate models, enriched with inline/leading comments, custom field order, equivalent noncanonical string escaping, an independent function root, a second item type, and a separate diagram document. They operate on explicit source bundles. The geometry and combined reparent/name tests use real core `Command::MoveNode` / `Command::EditElement` transactions to produce the candidate.
## Evidence actually obtained
**23 tests passed: 10 first-gate tests plus 13 writeback tests.** Strict all-target Clippy passes. The real CLI ran a core Rename command and printed the nested B source with only the requested name changed.
Every successful writeback test verifies complete-model equality, preservation of all input comment token text, caller-input immutability and a byte-identical second writeback of the same candidate.
| Model edit | Observed writeback and locality |
|---|---|
| Owner display-name change | Exactly the quoted name changes; only its declaration file changes. Equivalent Unicode escaping in another file remains byte-identical. |
| Owner alias change / simultaneous alias swap | Definition/reference tokens change across the expected two files; comments, prose strings and implicit ownership retain their meaning and placement. |
| Unused function port Output → Input and another declared item | `output` changes to `input`, the item is appended, and the inline rationale remains beside the original item. No raw port expansion; one file changes. |
| Existing function input accepts another declared item | Port shorthand changes; its functional-exchange line remains byte-identical. |
| Component port orientation Out → InOut | Only the orientation atom changes. The author's reversed field order and nested comment remain byte-identical. |
| Repeated occurrence moves before the Call/Reply pair | Its identity and its leading/internal rationale move together; `steps { ... }` remains; one behavior file changes. |
| Another occurrence is appended | One new block uses its supplied identity; existing blocks remain byte-identical; no redundant parent/membership list appears. |
| Function reparented into an independent aggregate | Its complete block, nested ports and attached comments move across exactly two files. Explicit work/port/exchange allocations and the view file are unchanged. |
| Reparent and display-name change in one core transaction | The scalar edit is absorbed into the moved subtree; the diagram remains byte-identical. |
| Diagram placement move | Only two numeric value tokens change in the view file; custom x/y field order and comments remain untouched. |
| A-format activity parent changes | Only the explicit parent value changes; the declaration stays in its authored file/location. |
| Invalid owner or ambiguous unattached group comment during reorder | An error returns without mutating source. There is no fallback to canonical replacement. |
An initial real regression was found: canonical parent references changed during an alias rename, and the writer incorrectly treated that as an ownership move, reordering sibling ports. The fix separates simultaneous alias renaming from semantic UUID changes. The exact minimal-change assertion and an alias-swap test now prevent this regression.
## Explicit boundaries and policy questions
- A contiguous leading `//` comment at the declaration's indentation belongs to that declaration for structural movement. Internal comments and an inline closing-line comment travel with it. A blank-separated group comment between ordered blocks has no inferred owner; reordering is rejected. This is an experimental policy to review, not a claim that all existing source comments have obvious ownership.
- Whole-line declaration boundaries are required for structural moves. Scalar edits still work in compact one-line A bodies. Simultaneous nested ownership moves, moving existing occurrences between steps blocks, semantic deletions, and creating records other than occurrences are outside this gate.
- Matching object fields and tested scalar/list edits work. Arbitrary object-key insertion/removal or commented list restructuring can be rejected. A datatype/variant migration requiring different fields is not silently translated into prose-destroying replacement.
- New text currently uses LF and four-space indentation. Existing source bytes are preserved outside edits; mixed line-ending/style behavior is not yet a production editing policy. Large-model performance, branch/fragment/state authoring, AST recovery from invalid B drafts and detailed B diagnostic mapping remain untested.
- The experiment does not prove visual usability, HTTP behavior, local multi-file persistence, concurrent external-edit handling or editor Undo behavior. Recreating an earlier model need not recover the exact earlier lexical position of an unordered declaration; exact source Undo should retain document history, not attempt to infer it from model semantics.
## Conclusion and recommendation
Expectation 1 holds for the tested edits: whole-model graphical-command results can preserve the tested B syntax without canonicalizing whole declarations. Expectation 3 also holds: comment ownership and structural movement need explicit rules. This falsifies the claim that destructive regeneration is inherently required, but does not establish universal lossless editing.
The gate supports carrying ownership nesting, explicit function-port/FE shorthand and ordered occurrence blocks into a production design review. Preserve A's bundle/admission/identity foundation. Use the production parser's source spans and a shared value-editing facility rather than copying this experiment's separate indexing pass and delta writer into a parallel language subsystem. The prototype's additional indexing/editing code is evidence of real implementation cost, not a reason to hide that cost under the word "sugar".
Before freezing the grammar, independently review (a) comment attachment and reorder rules; (b) behavior when an authored aggregate contains multiple semantic owners; (c) invalid-draft preservation and exact Undo; (d) branch/fragment/state cases. Revisit the proposed constructs if these require implicit allocation, unstable identity or frequent whole-file rewrites. Do not expand into namespaces or broader SysML constructs merely because this bounded gate passed.
## Reproduce from the repository root
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-source-authoring-experiment'
cargo test --manifest-path experiments/source-authoring/Cargo.toml --locked -- --nocapture
cargo clippy --manifest-path experiments/source-authoring/Cargo.toml --locked --all-targets -- -D warnings
cargo run --manifest-path experiments/source-authoring/Cargo.toml --locked -- --rename experiments/source-authoring/fixtures/sa-b.arcadia manage 'Manage emergency reporting'
```
The CLI prints the result and does not overwrite the fixture. Includes require the library's explicit bundle API. Windows Rust 1.98.1 was used; no Linux/native-GUI/MSRV claim is made for this isolated experiment.
+2
View File
@@ -7,6 +7,8 @@ use oa_core::{
}; };
use std::collections::BTreeMap; use std::collections::BTreeMap;
pub mod writeback;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct Record { struct Record {
kind: String, kind: String,
+27 -7
View File
@@ -4,23 +4,43 @@ use std::{collections::BTreeMap, env, fs, process::ExitCode};
fn main() -> ExitCode { fn main() -> ExitCode {
let args: Vec<_> = env::args().skip(1).collect(); let args: Vec<_> = env::args().skip(1).collect();
if args.len() != 1 { let rename = args.len() == 4 && args[0] == "--rename";
if args.len() != 1 && !rename {
eprintln!( eprintln!(
"Usage: source-authoring-experiment B_SOURCE_FILE\nIncludes require the library's explicit SourceBundle API." "Usage: source-authoring-experiment B_SOURCE_FILE\n source-authoring-experiment --rename B_SOURCE_FILE ALIAS NAME\nIncludes require the library's explicit SourceBundle API."
); );
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
let result = fs::read_to_string(&args[0]) let result = fs::read_to_string(&args[usize::from(rename)])
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
.and_then(|text| { .and_then(|text| {
source_authoring_experiment::compile(&SourceBundle { let bundle = SourceBundle {
entry: "project.arcadia".into(), entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), text)]), files: BTreeMap::from([("project.arcadia".into(), text)]),
}) };
let project = source_authoring_experiment::compile(&bundle)?;
if rename {
let id = project
.identities()
.into_iter()
.find(|(_, alias)| *alias == args[2])
.map(|(id, _)| id)
.ok_or("Unknown declaration alias")?;
let candidate = oa_core::execute_batch(
&project,
&[oa_core::Command::Rename(id, args[3].clone())],
)
.map_err(|error| error.to_string())?;
let result =
source_authoring_experiment::writeback::writeback(&bundle, &candidate)?;
Ok(result.files[&result.entry].clone())
} else {
Ok(language::format(&project))
}
}); });
match result { match result {
Ok(project) => { Ok(text) => {
print!("{}", language::format(&project)); print!("{text}");
ExitCode::SUCCESS ExitCode::SUCCESS
} }
Err(error) => { Err(error) => {
@@ -0,0 +1,887 @@
//! Bounded experiment: preserve authored bytes while writing model deltas.
//! This span index is not a production concrete syntax tree or formatter.
use crate::{Entry, Parser, Record, compile};
use oa_core::{
Id, Kind, Project,
language::{self, TokenClass},
source_project::SourceBundle,
};
use std::{
collections::{BTreeMap, BTreeSet},
ops::Range,
};
#[derive(Clone, Debug)]
struct Token {
text: String,
span: Range<usize>,
}
#[derive(Clone, Debug)]
struct Field {
name: Range<usize>,
value: Range<usize>,
}
#[derive(Clone, Debug)]
enum Syntax {
Raw,
Port {
keyword: Range<usize>,
items: Range<usize>,
},
Exchange {
source: Range<usize>,
target: Range<usize>,
items: Range<usize>,
},
}
#[derive(Clone, Debug)]
struct Site {
path: String,
span: Range<usize>,
alias: Range<usize>,
name: Option<Range<usize>>,
body: Option<Range<usize>>,
steps: Option<Range<usize>>,
fields: BTreeMap<String, Field>,
syntax: Syntax,
lexical_owner: Option<Id>,
}
#[derive(Clone, Debug)]
struct Edit {
path: String,
span: Range<usize>,
text: String,
}
fn tokens(source: &str, range: Range<usize>) -> Vec<Token> {
language::tokenize(&source[range.clone()])
.into_iter()
.filter(|token| !matches!(token.class, TokenClass::Comment | TokenClass::Whitespace))
.map(|token| Token {
text: source[range.start + token.span.start..range.start + token.span.end].into(),
span: range.start + token.span.start..range.start + token.span.end,
})
.collect()
}
fn matching(tokens: &[Token], start: usize) -> Result<usize, String> {
let mut stack = Vec::new();
for (i, token) in tokens.iter().enumerate().skip(start) {
match token.text.as_str() {
"{" => stack.push("}"),
"[" => stack.push("]"),
"}" | "]" => {
stack
.pop()
.filter(|expected| *expected == token.text)
.ok_or("Unbalanced token range")?;
if stack.is_empty() {
return Ok(i);
}
}
_ => {}
}
if stack.len() > 64 {
return Err("Index nesting limit".into());
}
}
Err("Unclosed token range".into())
}
fn scalar_end(tokens: &[Token], start: usize) -> Result<usize, String> {
let token = tokens.get(start).ok_or("Missing value")?;
match token.text.as_str() {
"{" | "[" => matching(tokens, start),
"@" => {
tokens.get(start + 1).ok_or("Missing reference alias")?;
Ok(start + 1)
}
_ => Ok(start),
}
}
fn index(bundle: &SourceBundle, model: &Project) -> Result<BTreeMap<Id, Site>, String> {
let aliases: BTreeMap<_, _> = model
.identities()
.into_iter()
.map(|(id, alias)| (alias, id))
.collect();
let mut sites = BTreeMap::new();
for (path, source) in &bundle.files {
let ts = tokens(source, 0..source.len());
for i in 2..ts.len().saturating_sub(1) {
if ts[i].text != "id" || !ts[i + 1].text.starts_with('"') {
continue;
}
let named = ts[i - 1].text.starts_with('"');
let alias_at = if named { i - 2 } else { i - 1 };
if alias_at == 0 {
continue;
}
let Some(&id) = aliases.get(ts[alias_at].text.as_str()) else {
continue;
};
let kind_at = alias_at - 1;
let next = i + 2;
let mut body = None;
let syntax;
let end;
match ts.get(next).map(|t| t.text.as_str()) {
Some("{") => {
let close = matching(&ts, next)?;
body = Some(ts[next].span.end..ts[close].span.start);
syntax = Syntax::Raw;
end = close;
}
Some("items") => {
let last = scalar_end(&ts, next + 1)?;
if ts.get(last + 1).is_none_or(|t| t.text != ";") {
return Err("Invalid port shorthand terminator".into());
}
syntax = Syntax::Port {
keyword: ts[kind_at].span.clone(),
items: ts[next + 1].span.start..ts[last].span.end,
};
end = last + 1;
}
Some("from") => {
if ts.get(next + 3).is_none_or(|t| t.text != "to")
|| ts.get(next + 6).is_none_or(|t| t.text != "items")
{
return Err("Unexpected exchange shorthand".into());
}
let last = scalar_end(&ts, next + 7)?;
syntax = Syntax::Exchange {
source: ts[next + 1].span.start..ts[next + 2].span.end,
target: ts[next + 4].span.start..ts[next + 5].span.end,
items: ts[next + 7].span.start..ts[last].span.end,
};
end = if ts.get(last + 2).is_some_and(|t| t.text == "{") {
let close = matching(&ts, last + 2)?;
body = Some(ts[last + 2].span.end..ts[close].span.start);
close
} else {
last + 1
};
}
_ => continue,
}
if sites
.insert(
id,
Site {
path: path.clone(),
span: ts[kind_at].span.start..ts[end].span.end,
alias: ts[alias_at].span.clone(),
name: named.then(|| ts[i - 1].span.clone()),
body,
steps: None,
fields: BTreeMap::new(),
syntax,
lexical_owner: None,
},
)
.is_some()
{
return Err("Duplicate indexed identity".into());
}
}
}
let spans = sites.clone();
for (&id, site) in &mut sites {
site.lexical_owner = spans
.iter()
.filter(|(other, parent)| {
**other != id
&& parent.path == site.path
&& parent.body.as_ref().is_some_and(|body| {
body.start <= site.span.start && body.end >= site.span.end
})
})
.min_by_key(|(_, parent)| parent.span.len())
.map(|(&id, _)| id);
site.fields.clear();
if let Some(body) = &site.body {
let ts = tokens(&bundle.files[&site.path], body.clone());
let mut at = 0;
while at < ts.len() {
if let Some(child) = spans
.values()
.find(|child| child.path == site.path && child.span.start == ts[at].span.start)
{
at = ts.partition_point(|token| token.span.start < child.span.end);
continue;
}
let name = ts[at].text.clone();
// A project include is not a semantic model field.
let last = scalar_end(&ts, at + 1)?;
if ts.get(last + 1).is_none_or(|token| token.text != ";") {
return Err(format!("Cannot index field {name}"));
}
if name == "steps" && ts[at + 1].text == "{" {
site.steps = Some(ts[at + 1].span.end..ts[last].span.start);
} else if name != "include" {
site.fields.insert(
name,
Field {
name: ts[at].span.clone(),
value: ts[at + 1].span.start..ts[last].span.end,
},
);
}
at = last + 2;
}
}
}
if sites.len() != model.identities().len() {
return Err("Not every semantic record has an indexed explicit header".into());
}
Ok(sites)
}
fn canonical_records(model: &Project) -> Result<BTreeMap<Id, Record>, String> {
let source = language::format(model);
let aliases: BTreeMap<_, _> = model
.identities()
.into_iter()
.map(|(id, alias)| (alias, id))
.collect();
Parser::new(&source)?
.unit(true)?
.into_iter()
.filter_map(|entry| match entry {
Entry::Record(record) => Some(record),
_ => None,
})
.map(|record| {
let id = *aliases
.get(record.alias.as_str())
.ok_or("Unknown canonical record")?;
Ok((id, record))
})
.collect()
}
fn push(edits: &mut Vec<Edit>, path: &str, span: Range<usize>, text: impl Into<String>) {
edits.push(Edit {
path: path.into(),
span,
text: text.into(),
});
}
fn comments(source: &str, range: Range<usize>) -> bool {
language::tokenize(&source[range])
.iter()
.any(|t| t.class == TokenClass::Comment)
}
fn object_fields(
source: &str,
range: Range<usize>,
) -> Result<Option<BTreeMap<String, Field>>, String> {
let ts = tokens(source, range);
if ts.first().is_none_or(|t| t.text != "{") {
return Ok(None);
}
let mut fields = BTreeMap::new();
let mut at = 1;
while at < ts.len() - 1 {
let last = scalar_end(&ts, at + 1)?;
if ts.get(last + 1).is_none_or(|t| t.text != ";") {
return Err("Unsupported object field form".into());
}
fields.insert(
ts[at].text.clone(),
Field {
name: ts[at].span.clone(),
value: ts[at + 1].span.start..ts[last].span.end,
},
);
at = last + 2;
}
Ok(Some(fields))
}
fn list_items(source: &str, range: Range<usize>) -> Result<Option<Vec<Range<usize>>>, String> {
let ts = tokens(source, range);
if ts.first().is_none_or(|t| t.text != "[") {
return Ok(None);
}
let mut items = Vec::new();
let mut at = 1;
while at < ts.len() - 1 {
let last = scalar_end(&ts, at)?;
items.push(ts[at].span.start..ts[last].span.end);
at = last + 1;
if at < ts.len() - 1 {
if ts[at].text != "," {
return Err("Unsupported list form".into());
}
at += 1;
}
}
Ok(Some(items))
}
/// Patch matched value tokens, retaining their authored ordering and all trivia.
/// Added/removed object keys or commented-list restructuring are deliberately refused.
fn value_edits(
edits: &mut Vec<Edit>,
path: &str,
source: &str,
range: Range<usize>,
old: &str,
new: &str,
) -> Result<(), String> {
if old == new {
return Ok(());
}
let old_fields = object_fields(old, 0..old.len())?;
let new_fields = object_fields(new, 0..new.len())?;
if let (Some(before), Some(after), Some(actual)) = (
old_fields.as_ref(),
new_fields.as_ref(),
object_fields(source, range.clone())?,
) {
if before.keys().eq(after.keys()) && before.keys().all(|key| actual.contains_key(key)) {
for (name, field) in before {
value_edits(
edits,
path,
source,
actual[name].value.clone(),
&old[field.value.clone()],
&new[after[name].value.clone()],
)?;
}
return Ok(());
}
if before.len() == 1 && after.len() == 1 && actual.len() == 1 {
let (old_key, old_field) = before.first_key_value().unwrap();
let (new_key, new_field) = after.first_key_value().unwrap();
let field = actual.get(old_key).ok_or("Constructor spelling mismatch")?;
push(edits, path, field.name.clone(), new_key);
return value_edits(
edits,
path,
source,
field.value.clone(),
&old[old_field.value.clone()],
&new[new_field.value.clone()],
);
}
return Err("Unsupported object-key addition/removal; no source was changed".into());
}
if let (Some(before), Some(after), Some(actual)) = (
list_items(old, 0..old.len())?,
list_items(new, 0..new.len())?,
list_items(source, range.clone())?,
) {
if before.len() != actual.len() {
return Err("Authored list shape mismatch".into());
}
if after.len() >= before.len() {
for (i, item) in before.iter().enumerate() {
value_edits(
edits,
path,
source,
actual[i].clone(),
&old[item.clone()],
&new[after[i].clone()],
)?;
}
if after.len() > before.len() {
if let Some(last) = actual.last() {
push(edits, path, last.end..last.end, ",");
}
let close = range.end - 1;
let extra = after[before.len()..]
.iter()
.map(|item| &new[item.clone()])
.collect::<Vec<_>>()
.join(", ");
push(
edits,
path,
close..close,
if source[range.clone()].contains('\n') {
format!("\n {extra}\n")
} else {
format!(" {extra}")
},
);
}
return Ok(());
}
if comments(source, range.clone()) {
return Err(
"Commented list removal requires explicit comment ownership; no source was changed"
.into(),
);
}
}
if comments(source, range.clone()) {
return Err("Unsupported commented value shape change; no source was changed".into());
}
push(edits, path, range, new);
Ok(())
}
fn line_start(source: &str, at: usize) -> usize {
source[..at].rfind('\n').map_or(0, |at| at + 1)
}
fn indent(source: &str, at: usize) -> Result<String, String> {
let prefix = &source[line_start(source, at)..at];
if prefix.chars().all(char::is_whitespace) {
Ok(prefix.into())
} else {
Err("Structural edits require declarations on their own lines".into())
}
}
fn envelope(source: &str, span: Range<usize>) -> Result<Range<usize>, String> {
let indentation = indent(source, span.start)?;
let mut start = line_start(source, span.start);
while start > 0 {
let previous_end = start - 1;
let previous_start = line_start(source, previous_end);
let previous = &source[previous_start..previous_end];
if previous.starts_with(&format!("{indentation}//")) {
start = previous_start
} else {
break;
}
}
let end = source[span.end..]
.find('\n')
.map_or(source.len(), |n| span.end + n + 1);
let suffix = source[span.end..end].trim();
if !suffix.is_empty() && !suffix.starts_with("//") {
return Err("Structural edits require a whole-line declaration boundary".into());
}
Ok(start..end)
}
fn apply_local(
source: &str,
range: Range<usize>,
path: &str,
edits: &[Edit],
) -> Result<String, String> {
let selected: Vec<_> = edits
.iter()
.filter(|edit| {
edit.path == path && edit.span.start >= range.start && edit.span.end <= range.end
})
.cloned()
.collect();
let mut text = source[range.clone()].to_owned();
let mut selected = selected;
selected.sort_by_key(|edit| (edit.span.start, edit.span.end));
let mut end = range.start;
for edit in &selected {
if edit.span.start < end {
return Err("Overlapping source patches".into());
}
end = edit.span.end;
}
for edit in selected.into_iter().rev() {
text.replace_range(
edit.span.start - range.start..edit.span.end - range.start,
&edit.text,
);
}
Ok(text)
}
fn apply(bundle: &SourceBundle, edits: &[Edit]) -> Result<SourceBundle, String> {
let mut output = bundle.clone();
for (path, source) in &bundle.files {
output.files.insert(
path.clone(),
apply_local(source, 0..source.len(), path, edits)?,
);
}
Ok(output)
}
fn reindent(text: &str, old: &str, new: &str) -> String {
text.split_inclusive('\n')
.map(|line| {
if line.trim().is_empty() {
line.into()
} else {
format!("{new}{}", line.strip_prefix(old).unwrap_or(line))
}
})
.collect()
}
fn insertion_at(source: &str, close: usize, text: &str) -> Edit {
let start = line_start(source, close);
let prefix = &source[start..close];
if prefix.chars().all(char::is_whitespace) {
Edit {
path: String::new(),
span: start..start,
text: text.into(),
}
} else {
Edit {
path: String::new(),
span: close..close,
text: format!(
"\n{text}{}",
source[start..]
.chars()
.take_while(|ch| *ch == ' ' || *ch == '\t')
.collect::<String>()
),
}
}
}
fn add_field(
edits: &mut Vec<Edit>,
site: &Site,
source: &str,
name: &str,
value: &str,
) -> Result<(), String> {
let body = site
.body
.as_ref()
.ok_or("This shorthand has no field escape body")?;
let indentation = indent(source, site.span.start)?;
let mut edit = insertion_at(
source,
body.end,
&format!("{indentation} {name} {value};\n"),
);
edit.path = site.path.clone();
edits.push(edit);
Ok(())
}
fn absorb(edits: &mut Vec<Edit>, path: &str, range: Range<usize>) -> Result<(), String> {
if edits.iter().any(|edit| {
edit.path == path
&& edit.span.start < range.end
&& edit.span.end > range.start
&& !(edit.span.start >= range.start && edit.span.end <= range.end)
}) {
return Err("Overlapping structural/scalar edits are outside the experiment".into());
}
edits.retain(|edit| {
!(edit.path == path && edit.span.start >= range.start && edit.span.end <= range.end)
});
Ok(())
}
/// Write only deltas representable by the bounded experiment. Failure returns no
/// replacement bundle; the caller's model and all input documents remain intact.
pub fn writeback(bundle: &SourceBundle, candidate: &Project) -> Result<SourceBundle, String> {
let before = compile(bundle)?;
language::parse(&language::format(candidate))
.map_err(|e| format!("Invalid candidate: {e:?}"))?;
if before.id != candidate.id || before.schema_version != candidate.schema_version {
return Err("Project identity/schema replacement is outside the experiment".into());
}
let sites = index(bundle, &before)?;
// Rename aliases as one simultaneous lexical phase before other model deltas.
// Re-index its result so alias cycles and changed token widths cannot cause
// double rewrites or turn an unchanged parent identity into a semantic move.
let candidate_aliases: BTreeMap<_, _> = candidate.identities().into_iter().collect();
let renamed: BTreeMap<_, _> = before
.identities()
.into_iter()
.filter_map(|(id, old)| {
candidate_aliases
.get(&id)
.filter(|new| **new != old)
.map(|new| (old.to_owned(), (*new).to_owned()))
})
.collect();
if !renamed.is_empty() {
let mut alias_edits = Vec::new();
for (id, old) in before.identities() {
if let Some(new) = renamed.get(old) {
let site = &sites[&id];
push(&mut alias_edits, &site.path, site.alias.clone(), new);
}
}
for (path, source) in &bundle.files {
let ts = tokens(source, 0..source.len());
for pair in ts.windows(2) {
if pair[0].text == "@"
&& let Some(new) = renamed.get(&pair[1].text)
{
push(&mut alias_edits, path, pair[1].span.clone(), new);
}
}
}
return writeback(&apply(bundle, &alias_edits)?, candidate);
}
let old = canonical_records(&before)?;
let new = canonical_records(candidate)?;
if old.keys().any(|id| !new.contains_key(id)) {
return Err("Record deletion is outside the experiment".into());
}
let new_ids: BTreeSet<_> = new
.keys()
.filter(|id| !old.contains_key(id))
.copied()
.collect();
if new_ids.iter().any(|id| {
candidate
.elements
.get(id)
.is_none_or(|e| e.kind() != Kind::Occurrence)
}) {
return Err("Only occurrence creation is in the experiment".into());
}
let mut edits = Vec::new();
let mut moves = Vec::new();
let mut orders = Vec::new();
for (id, record) in &old {
let next = &new[id];
let site = &sites[id];
let source = &bundle.files[&site.path];
if record.kind != next.kind {
return Err("Declaration kind changes are outside the experiment".into());
}
if let Some(name) = &site.name {
let old_header = tokens(&record.header, 0..record.header.len());
let new_header = tokens(&next.header, 0..next.header.len());
if old_header[2].text != new_header[2].text {
push(&mut edits, &site.path, name.clone(), &new_header[2].text);
}
}
for (field, value) in &next.fields {
let previous = record
.fields
.get(field)
.ok_or("Added constructor field is outside the experiment")?;
if previous == value {
continue;
}
if field == "parent"
&& let Some(element) = before.elements.get(id)
&& site.lexical_owner == element.parent
&& element.parent.is_some()
&& element.parent != candidate.elements[id].parent
{
moves.push((*id, candidate.elements[id].parent));
if !site.fields.contains_key("parent") {
continue;
}
}
if field == "steps" && site.steps.is_some() {
orders.push(*id);
continue;
}
match (&site.syntax, field.as_str()) {
(Syntax::Port { keyword, items }, "contract") => {
let e = &candidate.elements[id];
let Some(oa_core::SharedBody::Port { contract, .. }) = e.shared() else {
return Err("Expected port contract".into());
};
let (word, list) = match contract {
oa_core::PortContract::FunctionInput { incoming_items } => {
("input", incoming_items)
}
oa_core::PortContract::FunctionOutput { outgoing_items } => {
("output", outgoing_items)
}
_ => {
return Err(
"Only typed function input/output shorthand is supported".into()
);
}
};
push(&mut edits, &site.path, keyword.clone(), word);
let ids: BTreeMap<_, _> = candidate.identities().into_iter().collect();
let value = format!(
"[{}]",
list.iter()
.map(|id| format!("@{}", ids[id]))
.collect::<Vec<_>>()
.join(", ")
);
// The source list is already semantically the previous port item list.
let previous = &source[items.clone()];
value_edits(
&mut edits,
&site.path,
source,
items.clone(),
previous,
&value,
)?;
}
(Syntax::Exchange { source: span, .. }, "source_port")
| (Syntax::Exchange { target: span, .. }, "target_port") => {
push(&mut edits, &site.path, span.clone(), value)
}
(Syntax::Exchange { .. }, "source" | "target") => {}
(Syntax::Exchange { items, .. }, "items") => value_edits(
&mut edits,
&site.path,
source,
items.clone(),
previous,
value,
)?,
_ => {
if let Some(origin) = site.fields.get(field) {
value_edits(
&mut edits,
&site.path,
source,
origin.value.clone(),
previous,
value,
)?;
} else {
add_field(&mut edits, site, source, field, value)?;
}
}
}
}
}
let project_site = &sites[&before.id];
if before.name != candidate.name {
push(
&mut edits,
&project_site.path,
project_site.name.clone().unwrap(),
serde_json::to_string(&candidate.name).unwrap(),
);
}
if before.alias != candidate.alias {
push(
&mut edits,
&project_site.path,
project_site.alias.clone(),
&candidate.alias,
);
}
if before.description != candidate.description {
let value = serde_json::to_string(&candidate.description).unwrap();
if let Some(field) = project_site.fields.get("description") {
push(&mut edits, &project_site.path, field.value.clone(), value);
} else {
add_field(
&mut edits,
project_site,
&bundle.files[&project_site.path],
"description",
&value,
)?;
}
}
let mut handled_new = BTreeSet::new();
for id in orders {
let site = &sites[&id];
let range = site.steps.clone().unwrap();
let source = &bundle.files[&site.path];
let previous = before.elements[&id]
.steps()
.ok_or("Ordered owner missing")?;
let next = candidate.elements[&id]
.steps()
.ok_or("Ordered owner missing")?;
if previous.iter().any(|id| !next.contains(id)) {
return Err("Removing an ordered member without deleting it needs a separate authoring decision".into());
}
let mut blocks = BTreeMap::new();
let mut sorted = Vec::new();
for child in previous {
let child_site = &sites[child];
if child_site.path != site.path {
return Err("Ordered blocks cannot span included files".into());
}
let env = envelope(source, child_site.span.clone())?;
if env.start < range.start || env.end > range.end {
return Err("Ordered member is not wholly inside its steps block".into());
}
blocks.insert(
*child,
apply_local(source, env.clone(), &site.path, &edits)?,
);
sorted.push(env);
}
sorted.sort_by_key(|span| span.start);
for pair in sorted.windows(2) {
if !source[pair[0].end..pair[1].start].trim().is_empty() {
return Err("Unattached comments between ordered blocks need explicit ownership before reordering".into());
}
}
let prefix_end = sorted.first().map_or(range.end, |span| span.start);
let suffix_start = sorted.last().map_or(range.end, |span| span.end);
let indentation = format!("{} ", indent(source, site.span.start)?);
let mut content = source[range.start..prefix_end].to_owned();
for child in next {
if let Some(block) = blocks.get(child) {
content.push_str(block);
} else if new_ids.contains(child) && candidate.elements[child].parent == Some(id) {
let record = &new[child];
let mut block = format!("{indentation}{} {{\n", record.header);
for (field, value) in &record.fields {
if field == "parent"
|| (["description", "precondition", "postcondition"]
.contains(&field.as_str())
&& value == "\"\"")
{
continue;
}
block.push_str(&format!("{indentation} {field} {value};\n"));
}
block.push_str(&format!("{indentation}}}\n"));
content.push_str(&block);
handled_new.insert(*child);
} else {
return Err(
"Moving existing occurrences between blocks is outside the experiment".into(),
);
}
}
content.push_str(&source[suffix_start..range.end]);
absorb(&mut edits, &site.path, range.clone())?;
push(&mut edits, &site.path, range, content);
}
if handled_new != new_ids {
return Err("A new occurrence must be inserted into an existing B steps block".into());
}
let mut moved_ranges = Vec::new();
for (id, parent) in moves {
let site = &sites[&id];
let source = &bundle.files[&site.path];
let range = envelope(source, site.span.clone())?;
if moved_ranges
.iter()
.any(|(path, other): &(String, Range<usize>)| {
path == &site.path && range.start < other.end && range.end > other.start
})
{
return Err("Nested simultaneous ownership moves are outside the experiment".into());
}
moved_ranges.push((site.path.clone(), range.clone()));
let target = &sites[&parent.unwrap_or(before.id)];
let target_source = &bundle.files[&target.path];
let body = target.body.as_ref().ok_or("New owner has no body")?;
let old_indent = indent(source, site.span.start)?;
let new_indent = if parent.is_some() {
format!("{} ", indent(target_source, target.span.start)?)
} else {
" ".into()
};
let block = reindent(
&apply_local(source, range.clone(), &site.path, &edits)?,
&old_indent,
&new_indent,
);
absorb(&mut edits, &site.path, range.clone())?;
push(&mut edits, &site.path, range, "");
let mut insertion = insertion_at(target_source, body.end, &block);
insertion.path = target.path.clone();
edits.push(insertion);
}
let output = apply(bundle, &edits)?;
if compile(&output)? != *candidate {
return Err(
"Writeback did not reproduce the requested model; no source was changed".into(),
);
}
Ok(output)
}
@@ -0,0 +1,456 @@
use oa_core::{
Id, PortContract, Project, SharedBody,
language::{self, TokenClass},
source_project::SourceBundle,
};
use source_authoring_experiment::{compile, writeback::writeback};
use std::collections::BTreeMap;
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn fragments(source: &str, path: &str, extras: BTreeMap<String, String>) -> SourceBundle {
let start = source.find('{').unwrap() + 1;
let end = source.rfind('}').unwrap();
let mut files = extras;
files.insert(path.into(), source[start..end].into());
let includes = files
.keys()
.map(|path| format!(" include \"{path}\";\n"))
.collect::<String>();
files.insert(
"project.arcadia".into(),
format!("{}\n{includes}}}\n", &source[..start]),
);
SourceBundle {
entry: "project.arcadia".into(),
files,
}
}
fn sa() -> SourceBundle {
let source=include_str!("../fixtures/sa-b.arcadia")
.replace(" sa_function send_report", " // Keep reporting allocation explicit; this note belongs to the function.\n sa_function send_report")
.replace(" output report_out", " // This note belongs to the payload port.\n output report_out")
.replace(" sa_function assess", " // Assessment is independently owned.\n sa_function assess")
.replace(" output report_out \"Report output\" id \"00000000-0000-4000-8000-000000000208\" items [@report_data];", " output report_out \"Report output\" id \"00000000-0000-4000-8000-000000000208\" items [@report_data]; // Primary payload\n output diagnostic_out \"Diagnostic output\" id \"00000000-0000-4000-8000-000000000222\" items [\n @report_data // Keep this contract rationale\n ];");
fragments(&source,"domain/system.arcadia",BTreeMap::from([
("domain/other.arcadia".into(),"// Other analysis aggregate\nsa_function alternate \"Alternate analysis\" id \"00000000-0000-4000-8000-000000000223\" {\n description \"A separate analysis context\";\n}\nsa_exchange_item location_data \"Location data\" id \"00000000-0000-4000-8000-000000000224\" {}\n".into()),
("views/reporting.arcadia".into(),r#"// View rationale survives a geometry edit.
diagram reporting_view "Reporting view" id "00000000-0000-4000-8000-000000000220" { layer Sa; projection FunctionalDataflow; }
node send_placement id "00000000-0000-4000-8000-000000000221" {
diagram @reporting_view;
element @send_report;
position { y 32.5; // Y rationale
x -0.0; }; // Preserve the author's field order
size { width 200.0; height 100.0; };
collapsed false;
}
"#.into()),
]))
}
fn scenario() -> SourceBundle {
let source=include_str!("../fixtures/scenario-b.arcadia")
.replace(" oa_occurrence initial_report", " // Initial request retains its call identity.\n oa_occurrence initial_report")
.replace(" oa_occurrence accepted", " // Acknowledgement rationale.\n oa_occurrence accepted")
.replace(" oa_occurrence update_report", " // Newer position update is its own occurrence.\n oa_occurrence update_report")
.replace(" precondition", " // This precondition travels with the update.\n precondition");
fragments(
&source,
"behavior/reports.arcadia",
BTreeMap::from([(
"unrelated.arcadia".into(),
"// This file intentionally contains only narrative comments.\n".into(),
)]),
)
}
fn comment_strings(bundle: &SourceBundle) -> Vec<String> {
let mut comments = bundle
.files
.values()
.flat_map(|source| {
language::tokenize(source)
.into_iter()
.filter(|token| token.class == TokenClass::Comment)
.map(|token| source[token.span.start..token.span.end].to_owned())
})
.collect::<Vec<_>>();
comments.sort();
comments
}
fn verify(bundle: &SourceBundle, expected: &Project) -> SourceBundle {
let original = bundle.clone();
let output = writeback(bundle, expected).unwrap();
assert_eq!(*bundle, original, "Caller input changed");
assert_eq!(compile(&output).unwrap(), *expected);
assert_eq!(comment_strings(&output), comment_strings(bundle));
assert_eq!(
writeback(&output, expected).unwrap(),
output,
"Repeated writeback churned source"
);
output
}
fn changed_files(before: &SourceBundle, after: &SourceBundle) -> Vec<String> {
before
.files
.iter()
.filter(|(path, text)| after.files.get(*path) != Some(*text))
.map(|(path, _)| path.clone())
.collect()
}
#[test]
fn no_op_and_owner_rename_preserve_authored_structure_comments_and_other_files() {
let mut bundle = sa();
// Equivalent but noncanonical lexical forms must survive an unrelated edit.
*bundle.files.get_mut("domain/other.arcadia").unwrap() = bundle.files["domain/other.arcadia"]
.replace("Alternate analysis", r"Alternate \u0061nalysis");
let old = compile(&bundle).unwrap();
assert_eq!(writeback(&bundle, &old).unwrap(), bundle);
let mut candidate = old.clone();
candidate
.elements
.get_mut(&id(&old, "manage"))
.unwrap()
.name = "Manage emergency reporting".into();
let output = verify(&bundle, &candidate);
assert_eq!(changed_files(&bundle, &output), ["domain/system.arcadia"]);
assert_eq!(
output.files["domain/system.arcadia"],
bundle.files["domain/system.arcadia"]
.replace("\"Manage reporting\"", "\"Manage emergency reporting\"")
);
}
#[test]
fn owner_alias_rename_updates_shorthand_and_cross_file_references_only() {
let bundle = sa();
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
candidate
.elements
.get_mut(&id(&old, "send_report"))
.unwrap()
.alias = "transmit_report".into();
let output = verify(&bundle, &candidate);
assert_eq!(
changed_files(&bundle, &output),
["domain/system.arcadia", "views/reporting.arcadia"]
);
for path in &changed_files(&bundle, &output) {
assert_eq!(
output.files[path],
bundle.files[path].replace("send_report", "transmit_report")
);
}
}
#[test]
fn simultaneous_alias_swap_preserves_identity_and_does_not_rewrite_comments_or_strings() {
let mut bundle = sa();
*bundle.files.get_mut("domain/other.arcadia").unwrap() = bundle.files["domain/other.arcadia"]
.replace(
"A separate analysis context",
"@send_report and @assess are prose, not references",
);
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
candidate
.elements
.get_mut(&id(&old, "send_report"))
.unwrap()
.alias = "assess".into();
candidate
.elements
.get_mut(&id(&old, "assess"))
.unwrap()
.alias = "send_report".into();
let output = verify(&bundle, &candidate);
assert_eq!(
output.files["domain/other.arcadia"],
bundle.files["domain/other.arcadia"]
);
assert_eq!(old.relations, candidate.relations);
}
#[test]
fn raw_component_contract_edit_preserves_nested_comments_and_authored_field_order() {
let mut bundle = sa();
*bundle.files.get_mut("domain/system.arcadia").unwrap() = bundle.files["domain/system.arcadia"]
.replace(
"kind Flow; orientation Out;",
"orientation Out; // Keep orientation reasoning\nkind Flow;",
);
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
if let Some(SharedBody::Port {
contract: PortContract::Component { orientation, .. },
..
}) = candidate
.elements
.get_mut(&id(&old, "vessel_out"))
.unwrap()
.shared_mut()
{
*orientation = oa_core::ComponentPortOrientation::InOut;
}
let output = verify(&bundle, &candidate);
assert_eq!(changed_files(&bundle, &output), ["domain/system.arcadia"]);
assert_eq!(
output.files["domain/system.arcadia"],
bundle.files["domain/system.arcadia"].replace("orientation Out;", "orientation InOut;")
);
}
#[test]
fn reparent_and_rename_in_one_model_transaction_absorb_only_the_moved_subtree_edits() {
let bundle = sa();
let old = compile(&bundle).unwrap();
let mut moved = old.elements[&id(&old, "send_report")].clone();
moved.name = "Transmit emergency report".into();
moved.parent = Some(id(&old, "alternate"));
let candidate =
oa_core::execute_batch(&old, &[oa_core::Command::EditElement(moved.id, moved)]).unwrap();
let output = verify(&bundle, &candidate);
assert!(output.files["domain/other.arcadia"].contains("\"Transmit emergency report\""));
assert_eq!(
output.files["views/reporting.arcadia"],
bundle.files["views/reporting.arcadia"]
);
}
#[test]
fn function_port_direction_and_item_addition_keep_shorthand_and_inline_rationale() {
let bundle = sa();
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
let port = id(&old, "diagnostic_out");
let payload = id(&old, "report_data");
let location = id(&old, "location_data");
if let Some(SharedBody::Port { contract, .. }) =
candidate.elements.get_mut(&port).unwrap().shared_mut()
{
*contract = PortContract::FunctionInput {
incoming_items: vec![payload, location],
};
}
let output = verify(&bundle, &candidate);
assert_eq!(changed_files(&bundle, &output), ["domain/system.arcadia"]);
let text = &output.files["domain/system.arcadia"];
assert!(text.contains("input diagnostic_out"));
assert!(!text.contains("contract { FunctionInput"));
assert!(text.contains("@report_data, // Keep this contract rationale"));
assert!(text.contains("@location_data"));
let untouched_prefix = bundle.files["domain/system.arcadia"]
.split("output diagnostic_out")
.next()
.unwrap();
assert!(text.starts_with(untouched_prefix));
}
#[test]
fn widening_an_input_contract_does_not_reformat_its_existing_exchange() {
let bundle = sa();
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
// Increasing the accepted input contract does not change exchange semantics.
let input = id(&old, "report_in");
let payload = id(&old, "report_data");
let location = id(&old, "location_data");
if let Some(SharedBody::Port { contract, .. }) =
candidate.elements.get_mut(&input).unwrap().shared_mut()
{
*contract = PortContract::FunctionInput {
incoming_items: vec![payload, location],
};
}
let output = verify(&bundle, &candidate);
let before_exchange = bundle.files["domain/system.arcadia"]
.lines()
.find(|line| line.contains("functional_exchange report_flow"))
.unwrap();
assert!(
output.files["domain/system.arcadia"]
.lines()
.any(|line| line == before_exchange)
);
}
#[test]
fn reordering_occurrences_moves_their_rationale_and_retains_step_blocks() {
let bundle = scenario();
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
candidate
.elements
.get_mut(&id(&old, "reports"))
.unwrap()
.steps_mut()
.unwrap()
.rotate_right(1);
let output = verify(&bundle, &candidate);
assert_eq!(
changed_files(&bundle, &output),
["behavior/reports.arcadia"]
);
let source = &output.files["behavior/reports.arcadia"];
assert!(source.contains("steps {"));
assert!(!source.contains("steps ["));
assert!(
source.find("// Newer position update").unwrap()
< source.find("// Initial request").unwrap()
);
assert!(
source.find("// This precondition travels").unwrap()
> source.find("oa_occurrence update_report").unwrap()
);
assert_eq!(old.relations, candidate.relations);
}
#[test]
fn adding_an_occurrence_uses_its_new_identity_and_preserves_existing_blocks() {
let bundle = scenario();
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
let owner = id(&old, "reports");
let mut occurrence = candidate.elements[&id(&old, "update_report")].clone();
occurrence.id = "00000000-0000-4000-8000-000000000316".parse().unwrap();
occurrence.alias = "further_report".into();
occurrence.name = "Further report".into();
candidate
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(occurrence.id);
candidate.elements.insert(occurrence.id, occurrence);
let output = verify(&bundle, &candidate);
assert_eq!(
changed_files(&bundle, &output),
["behavior/reports.arcadia"]
);
let source = &output.files["behavior/reports.arcadia"];
assert!(source.contains("oa_occurrence further_report"));
assert!(!source.contains("parent @reports"));
assert!(source.contains("steps {"));
let original = &bundle.files["behavior/reports.arcadia"];
let before_close = original.rfind(" };").unwrap();
assert!(source.starts_with(&original[..before_close]));
}
#[test]
fn semantic_reparent_moves_nested_function_ports_and_comments_across_files() {
let bundle = sa();
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
candidate
.elements
.get_mut(&id(&old, "send_report"))
.unwrap()
.parent = Some(id(&old, "alternate"));
let output = verify(&bundle, &candidate);
assert_eq!(
changed_files(&bundle, &output),
["domain/other.arcadia", "domain/system.arcadia"]
);
assert!(!output.files["domain/system.arcadia"].contains("sa_function send_report"));
assert!(
output.files["domain/other.arcadia"].contains(" // Keep reporting allocation explicit")
);
assert!(output.files["domain/other.arcadia"].contains(" sa_function send_report"));
assert!(output.files["domain/other.arcadia"].contains(" output report_out"));
assert_eq!(
old.relations, candidate.relations,
"Reparenting changed explicit allocations"
);
}
#[test]
fn diagram_geometry_and_existing_a_fields_preserve_comments_and_field_order() {
let bundle = sa();
let old = compile(&bundle).unwrap();
let diagram = id(&old, "reporting_view");
let node = id(&old, "send_placement");
let candidate = oa_core::execute_batch(
&old,
&[oa_core::Command::MoveNode {
diagram,
node,
position: oa_core::Position { x: 72.25, y: 99.5 },
with_descendants: false,
}],
)
.unwrap();
let output = verify(&bundle, &candidate);
assert_eq!(changed_files(&bundle, &output), ["views/reporting.arcadia"]);
assert_eq!(
output.files["views/reporting.arcadia"],
bundle.files["views/reporting.arcadia"]
.replace("y 32.5", "y 99.5")
.replace("x -0.0", "x 72.25")
);
}
#[test]
fn raw_a_parent_changes_preserve_file_organization_and_declaration_comments() {
let source = include_str!("../fixtures/oa-a.arcadia").replace(
" oa_activity report",
" // Reparent the activity, keep this explanation.\n oa_activity report",
);
let bundle = fragments(&source, "operations.arcadia", BTreeMap::new());
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
candidate
.elements
.get_mut(&id(&old, "report"))
.unwrap()
.parent = None;
let output = verify(&bundle, &candidate);
assert_eq!(
output.files["operations.arcadia"],
bundle.files["operations.arcadia"].replacen("parent @respond", "parent none", 1)
);
}
#[test]
fn ambiguous_comment_ownership_and_invalid_candidates_fail_without_mutation() {
let mut bundle = scenario();
*bundle.files.get_mut("behavior/reports.arcadia").unwrap()=bundle.files["behavior/reports.arcadia"].replace(" // Acknowledgement rationale.","\n // Unattached group rationale.\n\n // Acknowledgement rationale.");
let original = bundle.clone();
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
candidate
.elements
.get_mut(&id(&old, "reports"))
.unwrap()
.steps_mut()
.unwrap()
.rotate_right(1);
assert!(
writeback(&bundle, &candidate)
.unwrap_err()
.contains("Unattached comments")
);
assert_eq!(bundle, original);
let bundle = sa();
let original = bundle.clone();
let old = compile(&bundle).unwrap();
let mut candidate = old.clone();
candidate
.elements
.get_mut(&id(&old, "send_report"))
.unwrap()
.parent = Some(id(&old, "center"));
assert!(
writeback(&bundle, &candidate)
.unwrap_err()
.contains("Invalid candidate")
);
assert_eq!(bundle, original);
}