Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu b272200b83 Sync agent instructions from main 2026-09-13 11:40:07 +03:00
sorokin.oleg.edu e44e01ac54 Sync agent instructions from main 2026-09-12 13:43:50 +03:00
sorokin.oleg.edu 01ef0f425b Sync agent instructions from main 2026-09-12 13:40:33 +03:00
sorokin.oleg.edu fd5025bf39 Sync agent instructions from main 2026-09-12 13:16:14 +03:00
sorokin.oleg.edu 5e40b775cd Sync agent instructions from main 2026-09-11 19:52:22 +03:00
sorokin.oleg.edu 881d7aa243 Sync agent instructions from main 2026-09-11 15:55:48 +03:00
sorokin.oleg.edu 65b1a0d7d0 Sync agent instructions from main 2026-09-11 14:34:48 +03:00
sorokin.oleg.edu 4fa95914d9 Point continuation state at hosted and constructor integration
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 07:57:10 +03:00
sorokin.oleg.edu 04316a75fa Update first-release integration continuation state 2026-09-06 06:57:42 +03:00
sorokin.oleg.edu 9dbe386a8e Update active release integration continuation pointer 2026-09-06 06:01:27 +03:00
sorokin.oleg.edu 4101434157 Record active release integration and native verification checkpoint 2026-09-06 05:11:20 +03:00
sorokin.oleg.edu a8f451ebae Record Windows and Linux direct state editing acceptance
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 03:31:26 +03:00
sorokin.oleg.edu 316dc559d7 Reset state camera and tools only when navigation changes view
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 03:19:18 +03:00
125 changed files with 1522 additions and 40573 deletions
+73
View File
@@ -0,0 +1,73 @@
# Agent working agreement
## Working method
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
## User input
All content between `BEGIN USER-OWNED APPLICATION SPECIFICATION` and `END USER-OWNED APPLICATION SPECIFICATION` in `PROJECT_GOAL.md` belongs to the user. Its headings and structure are unrestricted. Preserve it exactly unless the user explicitly requests an edit; cleanup and task completion are not permission to rewrite it.
Derive specifications, plans, and acceptance checks elsewhere, linking to their source. Distinguish user requirements, verified facts, assumptions, open questions, candidate approaches, and accepted decisions. Interpretations must not silently replace the user's intent. When input changes, reconcile affected work. Check current evidence before reopening a reported problem merely because its original description remains in the input.
## Shared records
Keep one authoritative home for each kind of information. Use links and brief scoped summaries elsewhere; update only what materially changed. Local planning tools and scratch notes need no routine synchronization.
| Information | Authoritative home |
| --- | --- |
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
| Published deliverables | Gitea releases, tied to source commits and verification. |
Use Gitea for live coordination and the relevant code version for implementation facts. Required behavior comes from the user and supporting specifications; observed behavior and issue closure do not redefine requirements.
## Coordination and integration
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
## Delegation and reasoning
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
## Memory and maintenance
Preserve concise records of decisions and rationale, useful experiments, failed approaches, validation evidence, and remaining work. Link substantial evidence rather than copying logs into indexes. Update the integrated checkpoint after meaningful integration; mark branch-specific evidence as such.
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
## Delivery
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
Generated
-6
View File
@@ -2155,16 +2155,10 @@ dependencies = [
name = "oa-desktop"
version = "0.2.0"
dependencies = [
"axum",
"iced",
"iced_runtime",
"oa-core",
"oa-server",
"reqwest",
"rfd",
"serde",
"serde_json",
"tempfile",
"tokio",
]
+111
View File
@@ -0,0 +1,111 @@
# Project goal
## Objective
Take the existing project to the complete, coherent, usable, and verified outcome defined by the user input below. Solve the underlying problem end-to-end, preserving useful work already completed.
Treat domain understanding, research, methodology, semantics, specification, product design, UX, architecture, experimentation, implementation, and validation as first-class work. Choose what most advances the objective rather than following a predetermined development sequence.
Follow `AGENTS.md` for execution, coordination, record ownership, and delivery. Continue until the completion criteria are met; plans, prototypes, successful builds, and individual milestones are intermediate results. Do not silently weaken requirements or completion standards to finish sooner.
## Completion criteria
Apply these criteria to the scope requested by the user:
- Required capabilities address the intended problem and support realistic user workflows.
- Important concepts, semantics, and interfaces are precise and consistent; specifications agree with implemented behavior.
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
## Final handoff
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
## USER-OWNED APPLICATION SPECIFICATION
The block below accepts all project-specific requirements, context, priorities, and feedback, with any headings or organization the user chooses.
<!-- BEGIN USER-OWNED APPLICATION SPECIFICATION -->
## Application specification
### General description
Create a modern clone of ARCADIA Capella using Rust Iced framework. It should be cross platform (supporting Windows and Linux at least). Use Rust exclusively (wherever possible & practical).
Focus on making the application modern, extensible, intuitive and easy to use. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
Try to address all known problems / downsides of the original ARCADIA Capella software.
Version 1 MVP in the form of working Operational Analysis layer is done.
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
Remaining ARCADIA layers will be added in future releases.
Transitioning of model elements between layers (in both directions) should be easy and intuitive.
Keep in mind that the target audience for the application are system engineers. And you should satisfy their needs and use cases
The application should be a general systems modeling tool, like ARCADIA Capella or SysML v2
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
The original ARCADIA Capella UI is very bloated, clunky, unintuitive and hard to use. In addition, it lacks a number of quality of life features. You should at all cost avoid making the same mistakes as the developers of the original ARCADIA Capella
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
To explore the original ARCADIA Capella UI & semantics you may use web resources / web search / official and community docs. Or open the original ARCADIA Capella installed on this desktop machine for exploration
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
The application should support both forms of editing
The textual representation should be easily editable and understandble for both humans and AI
The textual representation should be easily handled by version control tools such as Git
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
The textual representation editor should have syntax highlighting and linting
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
The desktop application should contain built-in guide / help / tutorial with clear and concise info on modeling concepts, underlying semantics, common modeling scenarios and application features / how to use the app. It should be easy to navigate and explore
@Computer
### Semantic model / metamodel
Requirements:
- Textual notation.
- Graphical notation.
- Constraints and calculations.
- Quantities and units.
- State and behavior modeling.
- Reusable model libraries.
- Strong, standardized specification.
- Strong, standardized API.
#### Textual notation
Requirements:
- Should not have visual noise and clutter.
- Should have means to reduce cognitive load when developing system models.
- Should be easily editable by humans. And AI agents.
- System engineers should be able to easily develop system models (both big and small) using textual notation only.
### UI
The focus should be on reducing system engineer's cognitive load when creating / working with a system model. Regardless of the model size (big or small).
Long, homogeneous lists in the UI that require user to scroll far or search for a desired option for too long - is a bad design.
## Active issues / current guidance
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
---
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
<!-- END USER-OWNED APPLICATION SPECIFICATION -->
+6 -12
View File
@@ -9,7 +9,7 @@ The normative [language and editing contract](docs/language.rst) defines support
Install stable Rust and your platform's native compiler. The workspace declares Rust 1.90; the recorded development builds use Rust 1.98.1. Windows requires Visual Studio Build Tools with **Desktop development with C++**. Ubuntu/Debian development dependencies are:
```sh
sudo apt-get install build-essential pkg-config libxkbcommon-dev libxkbcommon-x11-0 libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
sudo apt-get install build-essential pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev libx11-dev libxrandr-dev libxi-dev
```
From the repository root:
@@ -53,8 +53,6 @@ The example opens in an architecture view with work nested inside its performers
**Model explorer** groups roots by engineering purpose and opens one container's direct children. Breadcrumbs navigate ownership; **Allocated work** is listed separately. Search names, kinds or `@aliases` to find elements with ancestor context. Results are paged in groups of 100. **Collapse all** folds the outline; **Locate** clears the filter and reveals the selected element's ancestry.
**Data & interfaces** separates exchange nature from mechanism, supports bounded reusable types and typed defaults, and gives each interface item allocation its own protocol and artifact identity. Operation parameters have provider-side directions; qualified message paths distinguish repeated Record uses. Record/item/interface specialization preserves inherited identities. See the [data contract](docs/language.rst) for exact semantics and compatibility.
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
@@ -72,7 +70,7 @@ In the state view, **Select / move** drags vertices and containers without repar
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
```text
arcadia 4;
arcadia 3;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity respond "Respond to requests" {
@@ -102,7 +100,7 @@ Reparenting a nested element through Properties moves its declaration, descendan
## Save and reopen
Ctrl+S saves to the current local or hosted destination, Ctrl+Shift+S opens Save as, and Ctrl+O opens a local file. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
Ctrl+S saves, Ctrl+Shift+S saves as, and Ctrl+O opens. An `.arcadia` suffix selects text; other suffixes select JSON. Opening detects content rather than relying on the filename. Windows uses native dialogs; Linux uses a path-entry dialog with overwrite confirmation and no portal requirement.
**Save all** validates a snapshot and saves the source bundle, comparing previously read dependency bytes to reject detected external changes. Each file is replaced atomically; a staged transaction journal allows explicit recovery of interrupted multi-file saves. This is not an atomic multi-file filesystem operation or distributed locking. If recovery is needed, **Recover interrupted save** appears in Textual model. Dirty New/Open/Close operations offer save/discard/cancel. There is no periodic autosave of unsaved edits. Share or commit the entry file and all included source files together.
@@ -118,18 +116,14 @@ The default is `127.0.0.1:8787`, with a platform-local data directory. `--data-d
The API queries and edits complete models, canonical source, multi-file source projects, elements, relationships, artifacts and traces. Transactions and transition previews use the same core validation and source writer as the desktop. Writes require the returned ETag in `If-Match`. One revision protects the accepted model and source bundle together, including comment-only edits. The server retains the current revision only.
Use **Hosted models...** in the desktop top bar to connect, **Publish as new** to upload the current authored source project, or select a catalog entry and choose **Open selected**. Diagrams, properties and text share the same local editing history. **Save to server** / Ctrl+S explicitly sends valid changes; invalid drafts and failed writes retain your work. A revision conflict preserves local work and checks the server for review, with read-only source files and an explicit, undoable **Reload server version**. **Save as local...** switches back to a local destination after successfully saving an `.arcadia` source project; JSON export keeps the hosted destination. See the [hosted desktop walkthrough](docs/server.md#edit-hosted-models-in-the-desktop) for conflict recovery and connection setup.
The desktop keeps bearer credentials in memory and accepts HTTPS, with HTTP allowed only on localhost/loopback. Hosted editing does not require a cloud account and does not provide automatic synchronization or merges.
Scripts can also import a complete JSON model or standalone canonical source through the model interchange endpoint:
Desktop files and hosted resources transfer explicitly; the desktop has no direct server connection. Use **Save as** to export `system.json`, import it, then edit using the API. The model interchange endpoint accepts a complete JSON model or standalone canonical source:
```sh
curl --fail-with-body -X POST http://127.0.0.1:8787/api/v1/projects/import \
-H 'Content-Type: application/json' --data-binary @system.json
```
The response supplies the service `resource_id` and ETag. Refresh the desktop catalog to open that resource; after API changes to an open model, use **Check server** to review them. Alternatively, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file for local interchange. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
The response supplies the service `resource_id` and ETag. After API changes, export `/api/v1/projects/<resource-id>/source` to a new `.arcadia` file and open it in the desktop. This preserves model UUIDs, aliases, diagrams, traces and artifact metadata. Importing creates a separate hosted resource. Artifact URIs and revisions are opaque references for code, CAD, schematics, documents and other evidence; the application does not fetch or execute them.
To preserve authored files and comments, use `POST /api/v1/projects/import-source-project` with a JSON bundle containing `entry` and a `files` map of relative paths to source strings. `GET` and conditional `PUT /api/v1/projects/<resource-id>/source-project` exchange that complete accepted bundle. Include resolution uses only the submitted files; the server never opens the client's disk paths. See the [REST contract](docs/api.rst) for payloads, located diagnostics and admission limits.
@@ -146,4 +140,4 @@ cargo build --release --workspace --locked
- `crates/oa-desktop`: Iced state/drafts, typed forms, architecture/behavior views, source highlighting and file workflows.
- `crates/oa-server`: REST transport, conditional writes, bounded queries and atomic local store.
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, semantic merge tooling and production-scale collaboration remain outside this prototype.
The [architecture guide](docs/architecture.md) explains extension boundaries. Windows/Linux CI is configured; remote CI was not run in this environment. LA/PA, simulation, native Capella interchange, complete SysML/Capella conformance, direct hosted editing, semantic merge tooling and production-scale collaboration remain outside this prototype.
@@ -507,7 +507,6 @@ fn workshop() -> Project {
let baseline = TraceBaseline {
oa: p.properties(t.oa).unwrap(),
sa: p.properties(t.sa).unwrap(),
semantic: None,
};
p.traces.get_mut(&id).unwrap().baseline = Some(baseline);
}
File diff suppressed because it is too large Load Diff
+11 -25
View File
@@ -34,7 +34,10 @@ fn occurrence(
id
}
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
let items = crate::data::interface_items(p, interface);
let items = match p.elements[&interface].shared() {
Some(SharedBody::Interface { items }) => items.clone(),
_ => vec![],
};
let contract = if p.elements[&owner].kind() == Kind::Function {
if direction == PortDirection::Out {
PortContract::FunctionOutput {
@@ -111,7 +114,6 @@ fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) {
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
},
);
@@ -166,11 +168,6 @@ pub fn coastal_rescue() -> Project {
"Distress report",
None,
);
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&oa_item).unwrap().shared_mut()
{
*mechanism = ExchangeMechanism::Event;
}
p.relate(RelationKind::Allocation, "Reports distress", vessel, report);
p.relate(
RelationKind::Allocation,
@@ -311,11 +308,6 @@ pub fn coastal_rescue() -> Project {
"Digital distress report",
None,
);
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&information).unwrap().shared_mut()
{
*mechanism = ExchangeMechanism::Event;
}
let material = add(
&mut p,
Kind::ExchangeItem,
@@ -325,8 +317,6 @@ pub fn coastal_rescue() -> Project {
);
*p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
let energy = add(
&mut p,
@@ -337,8 +327,6 @@ pub fn coastal_rescue() -> Project {
);
*p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
let field = add(
&mut p,
@@ -352,9 +340,6 @@ pub fn coastal_rescue() -> Project {
minimum: 1,
maximum: Some(1),
unit: "degrees north".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
let interface = add(
&mut p,
@@ -363,12 +348,13 @@ pub fn coastal_rescue() -> Project {
"Distress reporting interface",
None,
);
p.relate(
RelationKind::InterfaceItemAllocation,
"Distress payload",
interface,
information,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface {
items: vec![information],
};
let vessel_port = port(
&mut p,
actor,
+17 -153
View File
@@ -1,6 +1,4 @@
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3/V4 migration.
#[path = "language/semantic_source.rs"]
mod semantic_source;
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration.
#[path = "migration_v2/source.rs"]
mod v2_source;
use crate::*;
@@ -342,8 +340,6 @@ fn reference_field(key: &str) -> bool {
| "referenced"
| "local"
| "field"
| "path"
| "supertype"
| "sender"
| "receiver"
| "action"
@@ -382,14 +378,10 @@ fn enum_field(key: &str) -> bool {
| "message_kind"
| "transition_kind"
| "side"
| "mechanism"
| "storage"
| "transmission"
| "acquisition"
)
}
/// Parse V5, or explicitly migrate a valid V1/V2/V3/V4 source candidate. Parsing never
/// Parse V3, or explicitly migrate a valid V1/V2 source candidate. Parsing never
/// adopts a model and never overwrites an editor draft.
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
parse_limited(source, ParseLimits::UNLIMITED)
@@ -554,7 +546,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
/// nested records are ordinary language fields; JSON documents are not embedded.
pub fn format(project: &Project) -> String {
let mut out = std::format!(
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n",
project.alias,
quote(&project.name),
project.id,
@@ -745,10 +737,6 @@ fn write_value(
indent: usize,
aliases: &BTreeMap<Id, String>,
) {
if key == "semantic" {
semantic_source::write(out, value, indent);
return;
}
match value {
Value::Null => out.push_str("none"),
Value::Bool(v) => out.push_str(if *v { "true" } else { "false" }),
@@ -792,7 +780,6 @@ enum Ast {
Literal(Value, usize),
Word(String, usize),
Reference(String, usize),
HistoricalUuid(Id, usize),
Array(Vec<Ast>, usize),
Object(BTreeMap<String, Ast>, usize),
}
@@ -802,20 +789,12 @@ impl Ast {
Self::Literal(_, p)
| Self::Word(_, p)
| Self::Reference(_, p)
| Self::HistoricalUuid(_, p)
| Self::Array(_, p)
| Self::Object(_, p) => *p,
}
}
fn resolve(&self, key: &str, aliases: &BTreeMap<String, Id>) -> Result<Value, Error> {
match self {
Self::HistoricalUuid(_, _) => Err(Error {
code: "invalid_source",
offset: self.offset(),
message:
"A uuid literal is allowed only in a typed historical semantic reference slot."
.into(),
}),
Self::Word(word, offset) => {
if !enum_field(key) {
return Err(Error {
@@ -983,7 +962,6 @@ pub(crate) struct EditValue {
pub(crate) enum EditKind {
Atom,
Reference(String),
HistoricalUuid(Id),
Record(BTreeMap<String, EditField>),
List(Vec<EditValue>),
}
@@ -1096,7 +1074,7 @@ fn edit_positions(
let span = Span {
start: ast.offset(),
end: match ast {
Ast::Reference(..) | Ast::HistoricalUuid(..) => self.tokens[index + 1].span.end,
Ast::Reference(..) => self.tokens[index + 1].span.end,
_ => self.ends[index],
},
};
@@ -1106,7 +1084,6 @@ fn edit_positions(
EditKind::List(items.iter().map(|item| self.value(item)).collect())
}
Ast::Reference(alias, _) => EditKind::Reference(alias.clone()),
Ast::HistoricalUuid(id, _) => EditKind::HistoricalUuid(*id),
_ => EditKind::Atom,
};
EditValue { span, kind }
@@ -1287,16 +1264,6 @@ impl Parser<'_> {
let token = self.current();
let offset = token.span.start;
match self.text() {
"uuid" => {
self.cursor += 1;
let value = self.quoted()?;
let id = Id::parse_str(&value).map_err(|_| Error {
code: "invalid_source",
offset,
message: "Expected a UUID after uuid.".into(),
})?;
Ok(Ast::HistoricalUuid(id, offset))
}
"@" => {
self.cursor += 1;
Ok(Ast::Reference(self.identifier()?, offset))
@@ -1370,12 +1337,12 @@ impl Parser<'_> {
.map(|(key, span)| ((d.id, key.clone()), span.start)),
);
}
let decoded =
let project =
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
.map_err(|(_, error)| error)?;
Ok(ParsedDocument {
project: decoded.project,
migration: decoded.migration,
project,
migration: None,
locations,
fields,
})
@@ -1383,11 +1350,9 @@ impl Parser<'_> {
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
let mut header = if entry {
self.take("arcadia")?;
if !matches!(self.text(), "3" | "4" | "5") {
return Err(self
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;' or 'arcadia 5;'."));
if self.text() != "3" {
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'."));
}
let schema_version = self.text().parse().expect("checked source version");
self.cursor += 1;
self.take(";")?;
let start = self.current().span.start;
@@ -1400,7 +1365,7 @@ impl Parser<'_> {
self.take("{")?;
Some(SourceHeader {
project: Project {
schema_version,
schema_version: SCHEMA_VERSION,
id,
alias,
name,
@@ -1575,21 +1540,14 @@ pub(crate) fn resolve_declarations(
declarations: &[Declaration],
aliases: &BTreeMap<String, Id>,
limits: ParseLimits,
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
let legacy = project.schema_version == 3;
let mut legacy_elements = serde_json::Map::new();
) -> Result<Project, (usize, Error)> {
let mut nodes = Vec::new();
for (index, declaration) in declarations.iter().enumerate() {
(|| -> Result<(), Error> {
let mut fields = declaration
.fields
.iter()
.map(|(k, v)| {
let value = if matches!(declaration.kind, DeclKind::Trace) && k == "baseline" {
semantic_source::resolve_baseline(v, aliases, project.schema_version)?
} else { v.resolve(k, aliases)? };
Ok((k.clone(), value))
})
.map(|(k, v)| Ok((k.clone(), v.resolve(k, aliases)?)))
.collect::<Result<Map<_, _>, Error>>()?;
let d = declaration;
let conversion_error = |message: String| Error {
@@ -1603,9 +1561,6 @@ pub(crate) fn resolve_declarations(
};
match d.kind {
DeclKind::Element(kind, layer) => {
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
return Err(conversion_error("Element constructor was not part of schema3.".into()));
}
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let explicit_parent = d.fields.contains_key("parent");
@@ -1646,17 +1601,9 @@ pub(crate) fn resolve_declarations(
*viewpoint = ScenarioViewpoint::MixedLegacy;
}
let mut body = serde_json::to_value(initial).unwrap();
let defaults = if legacy {
crate::persistence::v3::legacy_defaults(kind)
.and_then(|value| value.as_object().cloned())
.unwrap_or_else(|| payload(&body))
} else { payload(&body) };
let body_fields = merge_fields(defaults, fields).map_err(&conversion_error)?;
let body_fields =
merge_fields(payload(&body), fields).map_err(&conversion_error)?;
replace_payload(&mut body, body_fields);
if legacy {
legacy_elements.insert(d.id.to_string(), serde_json::json!({"id":d.id,"alias":d.alias,"name":d.name,"description":description,"parent":parent,"body":body}));
return Ok(());
}
let body: ElementBody = serde_json::from_value(body)
.map_err(|e| conversion_error(e.to_string()))?;
if body.kind() != kind || body.layer() != layer {
@@ -1677,9 +1624,6 @@ pub(crate) fn resolve_declarations(
);
}
DeclKind::Relation(kind) => {
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
}
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
@@ -1761,12 +1705,8 @@ pub(crate) fn resolve_declarations(
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let trace = if project.schema_version < 5 {
let old: crate::persistence::v4::Trace4 = serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?;
Trace::from(old)
} else {
serde_json::from_value(Value::Object(record)).map_err(|e| conversion_error(e.to_string()))?
};
let trace = serde_json::from_value(Value::Object(record))
.map_err(|e| conversion_error(e.to_string()))?;
project.traces.insert(d.id, trace);
}
}
@@ -1785,74 +1725,6 @@ pub(crate) fn resolve_declarations(
.map_err(|error| (index, error))?;
target.nodes.insert(node.id, node);
}
if legacy {
let mut value = serde_json::to_value(&project).expect("project serialization");
value["elements"] = Value::Object(legacy_elements);
let migrated = serde_json::from_value(value)
.map_err(crate::persistence::FileError::Json)
.and_then(|old| crate::persistence::v3::migrate(old, limits))
.map_err(|error| {
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
issues.first()
} else {
None
};
let index = issue
.and_then(|issue| issue.element)
.and_then(|id| declarations.iter().position(|d| d.id == id))
.unwrap_or(usize::MAX);
let offset = declarations.get(index).map_or(0, |d| {
issue
.and_then(|issue| issue.field.as_ref())
.and_then(|field| d.field_spans.get(field))
.map_or(d.offset, |span| span.start)
});
(
index,
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"migration_error"
},
offset,
message: error.to_string(),
},
)
})?;
for (index, d) in declarations.iter().enumerate() {
if let DeclKind::Element(kind, layer) = d.kind {
let body = &migrated.project.elements[&d.id].body;
if body.kind() != kind || body.layer() != layer {
return Err((
index,
Error {
code: "invalid_source",
offset: d.offset,
message: "Declaration kind disagrees with its typed body.".into(),
},
));
}
}
}
return Ok(migrated);
}
if project.schema_version == 4 {
return crate::persistence::v4::finish(project, limits).map_err(|error| {
(
usize::MAX,
Error {
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
"model_limit"
} else {
"migration_error"
},
offset: 0,
message: error.to_string(),
},
)
});
}
limits.check_model(&project).map_err(|message| {
(
usize::MAX,
@@ -1863,10 +1735,7 @@ pub(crate) fn resolve_declarations(
},
)
})?;
Ok(crate::persistence::DecodeResult {
project,
migration: None,
})
Ok(project)
}
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
@@ -2647,8 +2516,3 @@ pub(crate) mod legacy {
}
}
}
/// Closed historical facet identity for the targeted source writer.
pub(crate) fn semantic_row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
semantic_source::row_key(source, origins)
}
@@ -1,203 +0,0 @@
//! Typed historical source leaves; live alias resolution remains in the parent.
use super::*;
use crate::semantic::{HistoricalSourceKind, SemanticBaseline, historical_source_slots};
fn error(ast: &Ast, message: impl Into<String>) -> Error {
Error {
code: "invalid_source",
offset: ast.offset(),
message: message.into(),
}
}
fn raw(ast: &Ast) -> Result<Value, Error> {
Ok(match ast {
Ast::Literal(value, _) => value.clone(),
Ast::Word(value, _) => Value::String(value.clone()),
Ast::HistoricalUuid(value, _) => Value::String(value.to_string()),
Ast::Reference(_, _) => {
return Err(error(
ast,
"Historical semantic references require uuid \"...\", not @alias.",
));
}
Ast::Array(values, _) => Value::Array(values.iter().map(raw).collect::<Result<_, _>>()?),
Ast::Object(fields, _) => Value::Object(
fields
.iter()
.map(|(key, value)| Ok((key.clone(), raw(value)?)))
.collect::<Result<_, Error>>()?,
),
})
}
fn validate_tokens(
ast: &Ast,
path: &mut Vec<String>,
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
) -> Result<(), Error> {
match ast {
Ast::Object(fields, _) => {
for (key, value) in fields {
path.push(key.clone());
validate_tokens(value, path, slots)?;
path.pop();
}
}
Ast::Array(values, _) => {
for (index, value) in values.iter().enumerate() {
path.push(index.to_string());
validate_tokens(value, path, slots)?;
path.pop();
}
}
_ => match (slots.get(path), ast) {
(Some(HistoricalSourceKind::Reference), Ast::HistoricalUuid(..))
| (Some(HistoricalSourceKind::Enum), Ast::Word(..)) => {}
(Some(HistoricalSourceKind::Reference), _) => {
return Err(error(
ast,
"This historical reference requires uuid \"...\".",
));
}
(Some(HistoricalSourceKind::Enum), _) => {
return Err(error(
ast,
"This typed historical enum requires an unquoted enum word.",
));
}
(None, Ast::HistoricalUuid(..)) => {
return Err(error(
ast,
"A uuid literal is allowed only in a typed historical reference slot.",
));
}
(None, Ast::Word(..)) => {
return Err(error(
ast,
"This historical text value requires a quoted string.",
));
}
_ => {}
},
}
Ok(())
}
pub(super) fn resolve_baseline(
ast: &Ast,
aliases: &BTreeMap<String, Id>,
version: u32,
) -> Result<Value, Error> {
let Ast::Object(fields, _) = ast else {
return ast.resolve("baseline", aliases);
};
let mut resolved = Map::new();
for (key, value) in fields {
if key != "semantic" {
resolved.insert(key.clone(), value.resolve(key, aliases)?);
continue;
}
if version < 5 {
return Err(error(
value,
"Semantic trace baselines require schema/source5; the older baseline contains prose only.",
));
}
if matches!(value, Ast::Literal(Value::Null, _)) {
resolved.insert(key.clone(), Value::Null);
continue;
}
let json = raw(value)?;
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
let slots = historical_source_slots(&baseline).into_iter().collect();
validate_tokens(value, &mut vec![], &slots)?;
resolved.insert(key.clone(), json);
}
Ok(Value::Object(resolved))
}
pub(super) fn write(out: &mut String, value: &Value, indent: usize) {
if value.is_null() {
out.push_str("none");
return;
}
let baseline: SemanticBaseline =
serde_json::from_value(value.clone()).expect("typed semantic baseline");
let slots = historical_source_slots(&baseline).into_iter().collect();
write_at(out, value, indent, &mut vec![], &slots);
}
fn write_at(
out: &mut String,
value: &Value,
indent: usize,
path: &mut Vec<String>,
slots: &BTreeMap<Vec<String>, HistoricalSourceKind>,
) {
match value {
Value::Null => out.push_str("none"),
Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
Value::Number(value) => out.push_str(&value.to_string()),
Value::String(value) => match slots.get(path) {
Some(HistoricalSourceKind::Reference) => {
out.push_str("uuid ");
out.push_str(&quote(value));
}
Some(HistoricalSourceKind::Enum) => out.push_str(value),
None => out.push_str(&quote(value)),
},
Value::Array(values) => {
out.push('[');
for (index, value) in values.iter().enumerate() {
if index != 0 {
out.push_str(", ");
}
path.push(index.to_string());
write_at(out, value, indent, path, slots);
path.pop();
}
out.push(']');
}
Value::Object(fields) => {
out.push_str("{\n");
for (key, value) in fields {
out.push_str(&" ".repeat(indent + 1));
out.push_str(key);
out.push(' ');
path.push(key.clone());
write_at(out, value, indent + 1, path, slots);
path.pop();
out.push_str(";\n");
}
out.push_str(&" ".repeat(indent));
out.push('}');
}
}
}
/// Recognize only complete typed facet rows. Their closed selector, with real
/// identity provenance applied, owns comments when review rows reorder.
pub(super) fn row_key(source: &str, origins: &BTreeMap<Id, Id>) -> Option<String> {
let mut parser = Parser {
source,
tokens: strict_tokens(source).ok()?,
cursor: 0,
value_depth: 0,
limits: ParseLimits::UNLIMITED,
};
let ast = parser.value().ok()?;
let facet = serde_json::from_value(raw(&ast).ok()?).ok()?;
let mut baseline = SemanticBaseline {
facets: vec![facet],
};
crate::semantic::visit_semantic_references(&mut baseline, |id| {
if let Some(original) = origins.get(id) {
*id = *original;
}
});
serde_json::to_string(&baseline.facets[0].selector())
.ok()
.map(|key| format!("semantic:{key}"))
}
+10 -40
View File
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
fn project_source(body: &str) -> String {
format!(
"arcadia 5; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
"arcadia 3; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
)
}
@@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes()
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 5; project model \"Model\" { include \"children.arcadia\"; }".into(),
"arcadia 3; project model \"Model\" { include \"children.arcadia\"; }".into(),
),
(
"children.arcadia".into(),
@@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
#[test]
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
let source = "arcadia 5; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
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
@@ -406,34 +406,12 @@ fn checked_in_source_project() -> SourceBundle {
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.decoded.migration.as_ref().unwrap().from_schema, 3);
for (path, source) in &input.files {
let unit = parse_unit(source, path == &input.entry, ParseLimits::UNLIMITED).unwrap();
assert!(
unit.header
.as_ref()
.is_none_or(|header| header.id_insertion.is_none())
);
for declaration in unit.declarations {
assert!(
declaration.id_insertion.is_none(),
"every example identity is already explicit"
);
assert_eq!(
identity(&compiled.decoded.project, &declaration.alias),
declaration.id
);
}
}
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(reopened.bundle, compiled.bundle);
assert!(reopened.decoded.migration.is_none());
assert_eq!(compiled.diagnostics.len(), 2);
assert_eq!(
compiled.bundle, input,
"every example identity is already explicit"
);
assert!(
compiled
.diagnostics
.iter()
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
compiled.diagnostics.is_empty(),
"{:?}",
compiled.diagnostics
);
@@ -495,15 +473,7 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
ProjectLimits::default(),
)
.unwrap();
assert_eq!(result.diagnostics.len(), 2);
assert!(
result
.diagnostics
.iter()
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
"{:?}",
result.diagnostics
);
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
assert_eq!(
result.index.declarations[&assess].path,
"sa/assurance.arcadia"
@@ -530,6 +500,6 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
"sa/participants.arcadia",
"views.arcadia",
] {
assert_eq!(result.bundle.files[path], compiled.bundle.files[path]);
assert_eq!(result.bundle.files[path], input.files[path]);
}
}
-3
View File
@@ -1,5 +1,4 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod data;
pub mod language;
pub mod model;
pub mod persistence;
@@ -7,7 +6,6 @@ pub mod ports;
pub mod projection;
pub mod references;
pub mod routing;
pub mod semantic;
pub mod session;
pub mod source_files;
pub mod source_project;
@@ -15,7 +13,6 @@ pub mod transitions;
pub mod validation;
pub use model::*;
pub use ports::*;
pub use semantic::*;
pub use session::*;
mod demo;
mod state_validation;
+4 -8
View File
@@ -11,14 +11,10 @@ but constructs and validates the frozen DTO, preserves located old-contract
errors, then calls `adapter.rs`. Original staged scenario/state defaults and
generic port/optional FE endpoint contracts therefore remain explicit.
`adapter.rs` budgets generated data, validates V2 and transforms only the changed
port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader
then performs the data-contract upgrade into the strict schema 4 envelope. The
schema 4 review adapter preserves its prose-only trace baselines and returns
schema 5 with semantic review absent. Serialization
between compatibility DTOs copies unchanged fields; original input bytes are
never deserialized into the new `Project`. V1 normalization also constructs this
V2 DTO before the shared stages. Combined generation stays within one budget.
`adapter.rs` budgets generated data, validates V2, transforms only the changed
payloads, and validates schema 3. Serialization between the two typed DTOs copies
unchanged fields; original input bytes are never deserialized into the new
`Project`. V1 normalization also constructs this V2 DTO before the shared stage.
Do not update these frozen shapes when extending the current model. Add another
versioned migration when the persistent contract changes again. Golden V2
+13 -18
View File
@@ -62,9 +62,16 @@ pub(crate) fn migrate(
}],
});
}
let mut report = MigrationReport::data_upgrade(2);
let mut report = MigrationReport {
from_schema: 2,
to_schema: 3,
preserved_ports: vec![],
generated_endpoints: vec![],
orientation_defaults: vec![],
required_resolutions: vec![],
};
let mut value = serde_json::to_value(&old)?;
value["schema_version"] = Value::from(3);
value["schema_version"] = Value::from(SCHEMA_VERSION);
for element in old.elements.values() {
if let Some(super::model::SharedBody::Port {
direction,
@@ -197,23 +204,11 @@ pub(crate) fn migrate(
_ => {}
}
}
// Carry the endpoint-generation reference charge into the next migration;
// independent per-stage checks would undercount the combined generated graph.
let data_limits = ParseLimits {
max_step_references: limits
.max_step_references
.saturating_sub(additional_references),
..limits
};
let migrated = super::super::v3::migrate(serde_json::from_value(value)?, data_limits)?;
let data = migrated
.migration
.expect("the schema3 adapter reports its defaults");
report.semantic_defaults = data.semantic_defaults;
report.generated_interface_allocations = data.generated_interface_allocations;
report.unresolved_message_values = data.unresolved_message_values;
let project: Project = serde_json::from_value(value)?;
limits.check_model(&project).map_err(FileError::Limit)?;
super::super::validate_project(&project)?;
Ok(DecodeResult {
project: migrated.project,
project,
migration: Some(report),
})
}
-33
View File
@@ -1,33 +0,0 @@
# Schema 3 to 4 reader
Schema 3 is a real compatibility input. Its shapes were frozen at `5ce3eef`.
The small DTOs in `mod.rs` own the changed data payloads and old message values;
closed element/relation constructor lists prevent new vocabulary from entering
the old reader. Unchanged ports, diagram placements/routes, artifacts and traces
reuse their typed records. The intermediate JSON bridge rejects duplicate keys.
Nature, bounded types, field defaults/direction/storage, and single supertypes
receive explicit documented defaults. Interface lists become identified ordered
allocations with UUID v5 namespace equal to the project UUID and name
`v2-interface-item:<interface-uuid>:<item-uuid>`. The name remains the same for
V2 and V3 inputs so the migration route cannot change the generated identity.
Existing IDs never move; deterministic collisions fail with a located diagnostic.
Aliases use the normal deterministic collision suffix. Unique legacy message
paths are qualified; ambiguous annotations retain their expression and warning.
V1 first normalizes to the frozen V2 DTO. V2 port/endpoint normalization emits
an explicit schema3 candidate and then runs this reader before current decoding.
Generated object, record and reference budgets cover the combined stages.
`language::resolve_declarations` shares lexical/AST parsing, including nesting,
but chooses frozen source3 payload defaults and rejects new constructors. Both
single-file parsing and `source_project::compile_bundle` take this explicit path.
The bundle compiler first stabilizes omitted IDs, then performs targeted token
edits in `source_project/source_migration.rs`. Original files, includes, lexical
ownership and comments survive. New allocation declarations stay in the source
interface's module. The returned source and index describe schema4 bytes; loading
does not rewrite original disk files. Canonical output and second-save stability
are schema4. No whole-project formatting fallback handles authored source3.
Frozen source/JSON fixtures live under `tests/fixtures/v3-data-contract.*`; the
original V2 golden files remain unchanged.
-632
View File
@@ -1,632 +0,0 @@
//! Explicit schema-3 compatibility. Only changed payloads are frozen here;
//! unchanged typed records are shared after a closed old-constructor check.
use crate::language::ParseLimits;
use crate::persistence::{DecodeResult, FileError, MigrationReport};
use crate::*;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project3 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element3>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation3>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, crate::persistence::v4::Trace4>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Element3 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub parent: Option<Id>,
#[serde(deserialize_with = "strict_value")]
pub body: Value,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Relation3 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "strict_value")]
pub body: Value,
}
// A Value bridge must retain the strict duplicate-field behavior of typed DTOs.
fn strict_value<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Value, D::Error> {
struct Strict;
impl<'de> serde::de::Visitor<'de> for Strict {
type Value = Value;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a value with unique object fields")
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Value, E> {
Ok(v.into())
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Value, E> {
Ok(v.into())
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Value, E> {
Ok(v.into())
}
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Value, E> {
serde_json::Number::from_f64(v)
.map(Value::Number)
.ok_or_else(|| E::custom("Non-finite number"))
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Value, E> {
Ok(v.into())
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Value, E> {
Ok(v.into())
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_none<E: serde::de::Error>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
let mut out = Vec::new();
while let Some(v) = a.next_element::<StrictValue>()? {
out.push(v.0);
}
Ok(Value::Array(out))
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
let mut out = serde_json::Map::new();
while let Some((k, v)) = a.next_entry::<String, StrictValue>()? {
if out.insert(k.clone(), v.0).is_some() {
return Err(serde::de::Error::custom(format!("Duplicate field '{k}'")));
}
}
Ok(Value::Object(out))
}
}
d.deserialize_any(Strict)
}
struct StrictValue(Value);
impl<'de> Deserialize<'de> for StrictValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
strict_value(d).map(Self)
}
}
#[derive(Deserialize)]
enum ItemKind3 {
Information,
Material,
Energy,
Event,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
enum Definition3 {
Boolean,
Integer,
Real,
String,
Enumeration { literals: Vec<String> },
Record,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Item3 {
kind: ItemKind3,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Type3 {
definition: Definition3,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Field3 {
type_ref: TypeRef,
minimum: u32,
maximum: Option<u32>,
unit: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Interface3 {
items: Vec<Id>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MessageValue3 {
field: Id,
expression: String,
}
pub(crate) fn relation_allowed(name: &str) -> bool {
matches!(
name,
"Allocation"
| "PortAllocation"
| "Interaction"
| "CapabilityInvolvement"
| "ChainInvolvement"
| "RoleRealization"
| "RoleAssignment"
| "FunctionalExchange"
| "BoundaryExchange"
| "MissionCapability"
| "CapabilityInclude"
| "CapabilityExtend"
| "CapabilityGeneralization"
| "MissionInvolvement"
| "SequenceLink"
| "StateTransition"
| "Availability"
| "ExchangeAllocation"
| "InterfaceProvision"
| "InterfaceRequirement"
| "StateAction"
)
}
pub(crate) fn element_allowed(name: &str) -> bool {
matches!(
name,
"Entity"
| "Actor"
| "Role"
| "Activity"
| "Capability"
| "OperationalMission"
| "Process"
| "System"
| "SystemActor"
| "Function"
| "SystemCapability"
| "Mission"
| "FunctionalChain"
| "ExchangeItem"
| "DataType"
| "DataField"
| "Interface"
| "Port"
| "Scenario"
| "Lifeline"
| "Occurrence"
| "StateMachine"
| "Region"
| "State"
| "Mode"
| "Initial"
| "Final"
| "Choice"
| "Junction"
| "Fork"
| "Join"
| "ShallowHistory"
| "DeepHistory"
| "EntryPoint"
| "ExitPoint"
| "Terminate"
| "Event"
| "Constraint"
)
}
pub(crate) fn legacy_defaults(kind: Kind) -> Option<Value> {
Some(match kind {
Kind::ExchangeItem => json!({"kind":"Information"}),
Kind::DataType => json!({"definition":"String"}),
Kind::DataField => json!({"type_ref":"String","minimum":1,"maximum":1,"unit":""}),
Kind::Interface => json!({"items":[]}),
_ => return None,
})
}
fn at(id: Id, field: &str, code: &str, message: impl Into<String>) -> FileError {
let message = message.into();
FileError::Model {
message: format!("V3 {code} at {id}.{field}: {message}"),
issues: vec![validation::Issue {
severity: validation::Severity::Error,
element: Some(id),
code: code.into(),
field: Some(field.into()),
message,
}],
}
}
fn singleton(value: &Value) -> Option<(&str, &Value)> {
let object = value.as_object()?;
(object.len() == 1)
.then(|| object.iter().next().unwrap())
.map(|(k, v)| (k.as_str(), v))
}
fn old_payload<T: serde::de::DeserializeOwned>(
id: Id,
field: &str,
value: Value,
) -> Result<T, FileError> {
serde_json::from_value(value).map_err(|error| at(id, field, "legacy_shape", error.to_string()))
}
pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult, FileError> {
if old.schema_version != 3 {
return Err(FileError::Invalid("Expected schema_version 3.".into()));
}
let graph = old.elements.len().saturating_add(old.relations.len());
let records = graph
.saturating_add(old.diagrams.len())
.saturating_add(old.diagrams.values().map(|d| d.nodes.len()).sum::<usize>())
.saturating_add(old.traces.len())
.saturating_add(old.artifact_links.len())
.saturating_add(1);
limits
.check_counts(graph, records, 0)
.map_err(FileError::Limit)?;
let mut report = MigrationReport::data_upgrade(3);
let mut value = serde_json::to_value(&old)?;
let mut allocations = Vec::new();
for (key, e) in &old.elements {
if *key != e.id {
return Err(at(
e.id,
"id",
"identity",
"Element map key differs from its identity.",
));
}
let mut body = e.body.clone();
let (layer, inner) = singleton(&body)
.filter(|(name, _)| matches!(*name, "Oa" | "Sa"))
.ok_or_else(|| {
at(
e.id,
"body",
"legacy_shape",
"Expected one OA or SA constructor.",
)
})?;
let layer = layer.to_owned();
let outer = inner
.as_str()
.or_else(|| singleton(inner).map(|(name, _)| name));
if !outer.is_some_and(|name| {
if layer == "Oa" {
matches!(
name,
"Entity"
| "Actor"
| "Role"
| "Activity"
| "Capability"
| "Mission"
| "Process"
| "Shared"
)
} else {
matches!(
name,
"System"
| "Actor"
| "Function"
| "Capability"
| "Mission"
| "FunctionalChain"
| "Shared"
)
}
}) {
return Err(at(
e.id,
"body",
"legacy_shape",
"Unknown schema3 layer constructor.",
));
}
if let Some(("Shared", shared)) = singleton(inner) {
let name = shared
.as_str()
.or_else(|| singleton(shared).map(|(name, _)| name))
.ok_or_else(|| {
at(
e.id,
"body",
"legacy_shape",
"Expected one schema3 shared constructor.",
)
})?
.to_owned();
if !matches!(
name.as_str(),
"ExchangeItem"
| "DataType"
| "DataField"
| "Interface"
| "Port"
| "Scenario"
| "Lifeline"
| "Occurrence"
| "StateMachine"
| "Event"
| "Region"
| "State"
| "Constraint"
) {
return Err(at(
e.id,
"body",
"legacy_shape",
format!("Unknown schema3 shared constructor '{name}'."),
));
}
let payload = shared.get(&name).cloned().unwrap_or(Value::Null);
let changed = match name.as_str() {
"ExchangeItem" => {
let old: Item3 = old_payload(e.id, "body.kind", payload)?;
let (kind, mechanism) = match old.kind {
ItemKind3::Information => ("Information", "Unspecified"),
ItemKind3::Material => ("Material", "Unspecified"),
ItemKind3::Energy => ("Energy", "Unspecified"),
ItemKind3::Event => ("Information", "Event"),
};
Some(json!({"kind":kind,"mechanism":mechanism,"supertype":null}))
}
"DataType" => {
let old: Type3 = old_payload(e.id, "body.definition", payload)?;
let definition = match old.definition {
Definition3::Boolean => json!("Boolean"),
Definition3::Integer => json!({"Integer":{"minimum":null,"maximum":null}}),
Definition3::Real => json!({"Real":{"minimum":null,"maximum":null}}),
Definition3::String => {
json!({"String":{"minimum_length":0,"maximum_length":null}})
}
Definition3::Enumeration { literals } => {
json!({"Enumeration":{"literals":literals}})
}
Definition3::Record => json!({"Record":{"supertype":null}}),
};
Some(json!({"definition":definition,"default":null,"unit":""}))
}
"DataField" => {
let old: Field3 = old_payload(e.id, "body", payload)?;
Some(
json!({"type_ref":old.type_ref,"minimum":old.minimum,"maximum":old.maximum,"unit":old.unit,"direction":"Unspecified","storage":"Value","default":null}),
)
}
"Interface" => {
let old: Interface3 = old_payload(e.id, "items", payload)?;
let mut seen = BTreeSet::new();
for (order, item) in old.items.into_iter().enumerate() {
if !seen.insert(item) {
return Err(at(
e.id,
"items",
"duplicate_reference",
"Interface items must be unique.",
));
}
allocations.push((e.id, item, order));
}
Some(json!({"supertype":null}))
}
"Occurrence" => {
let mut payload = payload;
if let Some(values) = payload.pointer_mut("/body/Exchange/values") {
let old: Vec<MessageValue3> =
old_payload(e.id, "body.values", values.clone())?;
*values=Value::Array(old.into_iter().map(|v|json!({"field":v.field,"expression":v.expression,"path":[]})).collect());
}
Some(payload)
}
_ => None,
};
if let Some(changed) = changed {
let description = match name.as_str() {
"ExchangeItem" if changed["mechanism"] == "Event" => {
"Legacy Event nature becomes Information with Event mechanism; supertype is absent."
}
"ExchangeItem" => {
"Nature is retained; mechanism is Unspecified and supertype is absent."
}
"DataType" => {
"Existing type is retained with unbounded numeric/string limits, no scalar default or unit, and no Record supertype."
}
"DataField" => {
"Type, ownership, multiplicity and unit are retained; direction is Unspecified, storage is Value and default is absent."
}
"Interface" => {
"Items become identified ordered allocations with Unspecified protocols; supertype is absent."
}
_ => {
"Value expressions are retained; unique enclosing field paths are resolved and ambiguous legacy paths remain unresolved."
}
};
body[&layer]["Shared"][&name] = changed;
report
.semantic_defaults
.push(crate::persistence::SemanticDefault {
object: e.id,
field: "body".into(),
description: description.into(),
});
}
}
// Current deserialization checks the reused, unchanged outer/typed parts.
let _: ElementBody = serde_json::from_value(body.clone())
.map_err(|error| at(e.id, "body", "legacy_shape", error.to_string()))?;
value["elements"][key.to_string()]["body"] = body;
}
for (key, r) in &old.relations {
if *key != r.id {
return Err(at(
r.id,
"id",
"identity",
"Relation map key differs from its identity.",
));
}
if !singleton(&r.body).is_some_and(|(name, _)| relation_allowed(name)) {
return Err(at(
r.id,
"body",
"legacy_shape",
"Relation constructor was not part of schema3.",
));
}
}
value["schema_version"] = 4.into();
let normalized: crate::persistence::v4::Project4 = serde_json::from_value(value)?;
let mut project = normalized.into_current()?;
crate::persistence::v4::review_defaults(&project, &mut report);
let mut references = project
.elements
.values()
.map(|e| e.steps().map_or(0, Vec::len))
.sum::<usize>()
.saturating_add(allocations.len().saturating_mul(2));
limits
.check_counts(
project
.elements
.len()
.saturating_add(project.relations.len())
.saturating_add(allocations.len()),
project.identities().len().saturating_add(allocations.len()),
references,
)
.map_err(FileError::Limit)?;
let mut ids: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
for (source, target, order) in allocations {
let id = Id::new_v5(
&project.id,
format!("v2-interface-item:{source}:{target}").as_bytes(),
);
if !ids.insert(id) {
return Err(at(
source,
"items",
"identity_collision",
format!(
"Deterministic interface allocation {id} already belongs to another object."
),
));
}
let name = format!("{} · {}", project.named(source), project.named(target));
let alias = project.fresh_alias(
&format!(
"{}_{}_allocation",
project
.elements
.get(&source)
.map_or("interface", |e| e.alias.as_str()),
project
.elements
.get(&target)
.map_or("item", |e| e.alias.as_str())
),
"interface_item_allocation",
id,
);
project.relations.insert(
id,
Relation {
id,
alias: alias.clone(),
name,
description: String::new(),
body: RelationBody::InterfaceItemAllocation {
source,
target,
order: order.try_into().map_err(|_| {
at(source, "items", "model_limit", "Too many interface items.")
})?,
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
},
},
);
report.generated_interface_allocations.push(
crate::persistence::GeneratedInterfaceAllocation {
id,
source,
target,
alias,
order: order as u32,
},
);
}
let annotations: Vec<_> = project
.elements
.values()
.filter_map(|e| match e.shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
values,
..
},
..
}) => Some((e.id, *exchange, values.clone())),
_ => None,
})
.collect();
for (owner, exchange, values) in annotations {
let Some(relation) = project.relations.get(&exchange) else {
continue;
};
for (index, value) in values.into_iter().enumerate() {
let resolution =
crate::data::resolve_legacy_field_path(&project, relation, value.field)
.map_err(|message| at(owner, "body.values", "model_limit", message))?;
if let crate::data::LegacyFieldPathResolution::Unique(path) = resolution {
if !path.is_empty() {
references = references.saturating_add(path.len());
limits
.check_counts(
project
.elements
.len()
.saturating_add(project.relations.len()),
project.identities().len(),
references,
)
.map_err(FileError::Limit)?;
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = project
.elements
.get_mut(&owner)
.and_then(Element::shared_mut)
{
values[index].path = path;
}
}
} else if matches!(
resolution,
crate::data::LegacyFieldPathResolution::Ambiguous
) {
report.unresolved_message_values.push(crate::persistence::UnresolvedMessageValue { occurrence:owner,field:value.field,reason:"Several carried paths reach this legacy field; choose an explicit argument path.".into() });
}
}
}
limits.check_model(&project).map_err(FileError::Limit)?;
super::validate_project(&project)?;
Ok(DecodeResult {
project,
migration: Some(report),
})
}
-117
View File
@@ -1,117 +0,0 @@
//! Frozen schema4 review envelopes. Other model records are unchanged in schema5.
use crate::language::ParseLimits;
use crate::persistence::{DecodeResult, FileError, MigrationReport, SemanticDefault};
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TraceBaseline4 {
pub oa: TracedProperties,
pub sa: TracedProperties,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Trace4 {
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
pub oa: Id,
pub sa: Id,
pub kind: TraceKind,
pub baseline: Option<TraceBaseline4>,
}
impl From<Trace4> for Trace {
fn from(old: Trace4) -> Self {
Self {
id: old.id,
alias: old.alias,
name: old.name,
description: old.description,
oa: old.oa,
sa: old.sa,
kind: old.kind,
baseline: old.baseline.map(|baseline| TraceBaseline {
oa: baseline.oa,
sa: baseline.sa,
semantic: None,
}),
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Project4 {
pub schema_version: u32,
pub id: Id,
pub alias: String,
pub name: String,
pub description: String,
#[serde(deserialize_with = "crate::unique_id_map")]
pub elements: BTreeMap<Id, Element>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub relations: BTreeMap<Id, Relation>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub diagrams: BTreeMap<Id, Diagram>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub artifact_links: BTreeMap<Id, ArtifactLink>,
#[serde(deserialize_with = "crate::unique_id_map")]
pub traces: BTreeMap<Id, Trace4>,
}
impl Project4 {
pub(crate) fn into_current(self) -> Result<Project, FileError> {
if self.schema_version != 4 {
return Err(FileError::Invalid("Expected schema_version 4.".into()));
}
Ok(Project {
schema_version: SCHEMA_VERSION,
id: self.id,
alias: self.alias,
name: self.name,
description: self.description,
elements: self.elements,
relations: self.relations,
diagrams: self.diagrams,
artifact_links: self.artifact_links,
traces: self
.traces
.into_iter()
.map(|(id, trace)| (id, trace.into()))
.collect(),
})
}
}
pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
report.semantic_defaults.extend(
project
.traces
.values()
.filter(|trace| trace.baseline.is_some())
.map(|trace| SemanticDefault {
object: trace.id,
field: "baseline.semantic".into(),
description: "Original prose review preserved; semantic facets remain unreviewed."
.into(),
}),
);
}
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
project.schema_version = SCHEMA_VERSION;
limits.check_model(&project).map_err(FileError::Limit)?;
let mut report = MigrationReport::data_upgrade(4);
review_defaults(&project, &mut report);
Ok(DecodeResult {
project,
migration: Some(report),
})
}
pub(crate) fn migrate(old: Project4, limits: ParseLimits) -> Result<DecodeResult, FileError> {
finish(old.into_current()?, limits)
}
+15 -152
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 5;
pub const SCHEMA_VERSION: u32 = 3;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -265,79 +265,17 @@ pub enum ExchangeItemKind {
Information,
Material,
Energy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ExchangeMechanism {
#[default]
Unspecified,
Flow,
Event,
Operation,
SharedData,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ParameterDirection {
#[default]
Unspecified,
In,
Out,
InOut,
Return,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FieldStorage {
#[default]
Value,
Reference,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DataValue {
Boolean(bool),
Integer(i64),
Real(f64),
String(String),
Enumeration(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TransmissionProtocol {
#[default]
Unspecified,
Synchronous,
Asynchronous,
Unicast,
Multicast,
Broadcast,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AcquisitionProtocol {
#[default]
Unspecified,
Read,
Accept,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DataTypeDefinition {
Boolean,
Integer {
minimum: Option<i64>,
maximum: Option<i64>,
},
Real {
minimum: Option<f64>,
maximum: Option<f64>,
},
String {
minimum_length: u32,
maximum_length: Option<u32>,
},
Enumeration {
literals: Vec<String>,
},
Record {
supertype: Option<Id>,
},
Integer,
Real,
String,
Enumeration { literals: Vec<String> },
Record,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -515,8 +453,6 @@ pub struct LifelineBinding {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MessageValue {
#[serde(default)]
pub path: Vec<Id>,
pub field: Id,
pub expression: String,
}
@@ -566,25 +502,18 @@ pub enum OccurrenceBody {
pub enum SharedBody {
ExchangeItem {
kind: ExchangeItemKind,
mechanism: ExchangeMechanism,
supertype: Option<Id>,
},
DataType {
definition: DataTypeDefinition,
default: Option<DataValue>,
unit: String,
},
DataField {
type_ref: TypeRef,
minimum: u32,
maximum: Option<u32>,
unit: String,
direction: ParameterDirection,
storage: FieldStorage,
default: Option<Vec<DataValue>>,
},
Interface {
supertype: Option<Id>,
items: Vec<Id>,
},
Port {
contract: PortContract,
@@ -743,27 +672,17 @@ impl ElementBody {
match k {
Kind::ExchangeItem => SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Unspecified,
supertype: None,
},
Kind::DataType => SharedBody::DataType {
definition: DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
default: None,
unit: String::new(),
definition: DataTypeDefinition::String,
},
Kind::DataField => SharedBody::DataField {
type_ref: TypeRef::String,
minimum: 1,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
},
Kind::Interface => SharedBody::Interface { supertype: None },
Kind::Interface => SharedBody::Interface { items: vec![] },
// Port creation requires an explicit contract and owner.
Kind::Port => return None,
Kind::Scenario => SharedBody::Scenario {
@@ -833,8 +752,6 @@ impl SharedBody {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationKind {
InterfaceItemAllocation,
DataAssociation,
Allocation,
PortAllocation,
Interaction,
@@ -858,9 +775,7 @@ pub enum RelationKind {
StateAction,
}
impl RelationKind {
pub const ALL: [Self; 23] = [
Self::InterfaceItemAllocation,
Self::DataAssociation,
pub const ALL: [Self; 21] = [
Self::Allocation,
Self::PortAllocation,
Self::Interaction,
@@ -885,8 +800,6 @@ impl RelationKind {
];
pub fn permits(self, from: Kind, to: Kind) -> bool {
match self {
Self::InterfaceItemAllocation => from == Kind::Interface && to == Kind::ExchangeItem,
Self::DataAssociation => from == Kind::DataType && to == Kind::DataType,
Self::Allocation => from.is_performer() && to.is_work(),
Self::PortAllocation => from == Kind::Port && to == Kind::Port,
Self::Interaction => from == Kind::Activity && to == Kind::Activity,
@@ -942,8 +855,6 @@ impl RelationKind {
}
pub fn hint(self) -> &'static str {
match self {
Self::InterfaceItemAllocation => "Interface → allocated exchange item",
Self::DataAssociation => "Record type → record type",
Self::Allocation => "Performer → leaf activity/function",
Self::PortAllocation => "Component port → allocated function port",
Self::Interaction => "OA activity → OA activity",
@@ -975,8 +886,6 @@ impl RelationKind {
impl fmt::Display for RelationKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::InterfaceItemAllocation => "Interface item allocation",
Self::DataAssociation => "Data association",
Self::Allocation => "Allocation",
Self::PortAllocation => "Port allocation",
Self::Interaction => "Interaction",
@@ -1013,25 +922,6 @@ pub struct Relation {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum RelationBody {
InterfaceItemAllocation {
source: Id,
target: Id,
order: u32,
transmission: TransmissionProtocol,
acquisition: AcquisitionProtocol,
},
DataAssociation {
source: Id,
target: Id,
source_role: String,
target_role: String,
source_minimum: u32,
source_maximum: Option<u32>,
target_minimum: u32,
target_maximum: Option<u32>,
source_navigable: bool,
target_navigable: bool,
},
Allocation {
source: Id,
target: Id,
@@ -1149,8 +1039,6 @@ impl Relation {
impl RelationBody {
pub fn kind(&self) -> RelationKind {
match self {
Self::InterfaceItemAllocation { .. } => RelationKind::InterfaceItemAllocation,
Self::DataAssociation { .. } => RelationKind::DataAssociation,
Self::Allocation { .. } => RelationKind::Allocation,
Self::PortAllocation { .. } => RelationKind::PortAllocation,
Self::Interaction { .. } => RelationKind::Interaction,
@@ -1176,9 +1064,7 @@ impl RelationBody {
}
pub fn endpoints(&self) -> (Id, Id) {
match self {
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
Self::Allocation { source, target }
| Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target }
@@ -1203,9 +1089,7 @@ impl RelationBody {
}
pub fn set_endpoints(&mut self, a: Id, b: Id) {
match self {
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
Self::Allocation { source, target }
| Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target }
@@ -1241,25 +1125,6 @@ impl RelationBody {
}
pub fn new(kind: RelationKind, source: Id, target: Id) -> Self {
match kind {
RelationKind::InterfaceItemAllocation => Self::InterfaceItemAllocation {
source,
target,
order: 0,
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
},
RelationKind::DataAssociation => Self::DataAssociation {
source,
target,
source_role: String::new(),
target_role: String::new(),
source_minimum: 0,
source_maximum: None,
target_minimum: 0,
target_maximum: None,
source_navigable: false,
target_navigable: false,
},
RelationKind::Allocation => Self::Allocation { source, target },
RelationKind::PortAllocation => Self::PortAllocation { source, target },
RelationKind::Interaction => Self::Interaction {
@@ -1492,15 +1357,13 @@ pub struct TracedProperties {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TraceBaseline {
pub oa: TracedProperties,
pub sa: TracedProperties,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<crate::semantic::SemanticBaseline>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Trace {
pub id: Id,
+2 -52
View File
@@ -5,10 +5,6 @@ use thiserror::Error;
#[path = "migration_v2/mod.rs"]
pub(crate) mod v2;
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
#[path = "migration_v3/mod.rs"]
pub(crate) mod v3;
#[path = "migration_v4.rs"]
pub(crate) mod v4;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -19,50 +15,6 @@ pub struct MigrationReport {
pub generated_endpoints: Vec<GeneratedEndpoint>,
pub orientation_defaults: Vec<OrientationDefault>,
pub required_resolutions: Vec<Id>,
#[serde(default)]
pub semantic_defaults: Vec<SemanticDefault>,
#[serde(default)]
pub generated_interface_allocations: Vec<GeneratedInterfaceAllocation>,
#[serde(default)]
pub unresolved_message_values: Vec<UnresolvedMessageValue>,
}
impl MigrationReport {
pub(crate) fn data_upgrade(from_schema: u32) -> Self {
Self {
from_schema,
to_schema: SCHEMA_VERSION,
preserved_ports: vec![],
generated_endpoints: vec![],
orientation_defaults: vec![],
required_resolutions: vec![],
semantic_defaults: vec![],
generated_interface_allocations: vec![],
unresolved_message_values: vec![],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SemanticDefault {
pub object: Id,
pub field: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GeneratedInterfaceAllocation {
pub id: Id,
pub source: Id,
pub target: Id,
pub alias: String,
pub order: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UnresolvedMessageValue {
pub occurrence: Id,
pub field: Id,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -149,15 +101,13 @@ pub fn decode_with_report_limited(
migrate_v1_with_report_limited(old, limits)?
}
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(5) => DecodeResult {
Some(3) => DecodeResult {
project: serde_json::from_slice(bytes)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3, 4 or 5.".into(),
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
));
}
}
+18 -8
View File
@@ -64,9 +64,9 @@ pub struct LegacyWorkPortSplitRequest {
pub outgoing_items: Vec<Id>,
}
/// A specialized carried item satisfies a declared base item.
pub fn item_satisfies(project: &Project, carried: Id, declared: Id) -> bool {
crate::data::item_satisfies(project, carried, declared)
/// R6 specialization has one explicit seam; current models support identity only.
pub fn item_satisfies(_project: &Project, carried: Id, declared: Id) -> bool {
carried == declared
}
pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool {
carried.iter().all(|item| {
@@ -130,7 +130,13 @@ fn declared_interface_items(p: &Project, port: Id) -> Option<Vec<Id>> {
Some(
interfaces
.into_iter()
.flat_map(|id| crate::data::interface_items(p, id))
.filter_map(|id| p.elements.get(&id))
.filter_map(|e| match e.shared() {
Some(SharedBody::Interface { items }) => Some(items),
_ => None,
})
.flatten()
.copied()
.collect(),
)
}
@@ -897,10 +903,14 @@ fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mu
..
}) => {
let mut allowed = declared_interface_items(p, port.id);
if let Some(interface) = legacy.as_ref().and_then(|l| l.interface) {
let items = crate::data::interface_items(p, interface);
if let Some(SharedBody::Interface { items }) = legacy
.as_ref()
.and_then(|l| l.interface)
.and_then(|id| p.elements.get(&id))
.and_then(Element::shared)
{
if r.kind() == RelationKind::BoundaryExchange
&& !items_satisfy(p, r.body.items(), &items)
&& !items_satisfy(p, r.body.items(), items)
{
error(
out,
@@ -1163,7 +1173,7 @@ pub(crate) fn validate_boundary_interface_requirements(
if !p.relations.values().any(|role| {
role.kind() == RelationKind::InterfaceProvision
&& role.source() == peer.id
&& crate::data::interface_satisfies(p, role.target(), required.target())
&& role.target() == required.target()
}) {
warning(
out,
+2 -8
View File
@@ -335,11 +335,6 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
.collect();
let mut edges = Vec::new();
for r in p.relations.values() {
// Interface allocations are selectable nested dictionary rows. Their
// independently identified contracts do not add noisy diagram arrows.
if r.kind() == RelationKind::InterfaceItemAllocation {
continue;
}
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
continue;
}
@@ -375,8 +370,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
&& (source != target
|| ((d.projection.state_machine().is_some()
|| r.kind() == RelationKind::DataAssociation)
|| (d.projection.state_machine().is_some()
&& r.source() == r.target()
&& nodes
.iter()
@@ -402,7 +396,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
RelationBody::BoundaryExchange {
oriented: false,
..
} | RelationBody::DataAssociation { .. }
}
),
source_port: source_port.filter(|id| visible_ports.contains(id)),
target_port: target_port.filter(|id| visible_ports.contains(id)),
+10 -50
View File
@@ -33,13 +33,8 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
type_ref: TypeRef::Named(id),
..
} => visit(id),
SharedBody::Interface { supertype }
| SharedBody::ExchangeItem { supertype, .. }
| SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
..
} => {
if let Some(id) = supertype {
SharedBody::Interface { items } => {
for id in items {
visit(id);
}
}
@@ -88,9 +83,6 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
visit(id);
}
for value in values {
for id in &mut value.path {
visit(id);
}
visit(&mut value.field);
}
}
@@ -222,13 +214,6 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
if t.sa == old {
t.sa = new;
}
if let Some(baseline) = t.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
crate::semantic::visit_semantic_references(baseline, |id| {
if *id == old {
*id = new;
}
});
}
}
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
@@ -392,12 +377,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::Interface { supertype }
| SharedBody::ExchangeItem { supertype, .. }
| SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
..
} => clear(supertype),
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
SharedBody::Event {
body: EventBody::Signal { items },
} => items.retain(|id| !removed.contains(id)),
@@ -433,10 +413,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
clear(sender);
clear(receiver);
clear(reply_to);
values.retain(|value| {
!removed.contains(&value.field)
&& value.path.iter().all(|id| !removed.contains(id))
});
values.retain(|value| !removed.contains(&value.field));
}
OccurrenceBody::Action { action, lifeline } => {
clear(action);
@@ -508,37 +485,20 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
exchange: Some(id), ..
},
..
}) => p.relations.get(id).map(|r| {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = e.shared()
else {
unreachable!()
};
let retained: Vec<_> = values
.iter()
.filter(|value| {
crate::data::carried_value_path(p, r, &value.path, value.field).is_some()
|| (value.path.is_empty()
&& crate::data::carried_field_paths(p, r)
.iter()
.any(|f| f.field == value.field))
})
.cloned()
.collect();
(e.id, retained)
}),
}) => p
.relations
.get(id)
.map(|r| (e.id, crate::validation::carried_fields(p, r))),
_ => None,
})
.collect();
for (id, retained) in allowed {
for (id, fields) in allowed {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
{
*values = retained;
values.retain(|value| fields.contains(&value.field));
}
}
}
+3 -128
View File
@@ -1,7 +1,7 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use crate::{Diagram, Id, Position, Project, RelationKind, Size};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -55,122 +55,6 @@ pub struct EdgeLabelGeometry {
pub bounds: RoutingBounds,
}
/// Derived labels and navigation markers for the two named association ends.
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
#[derive(Debug, Clone, PartialEq)]
pub struct AssociationEndGeometry {
pub source_end: bool,
pub label: EdgeLabelGeometry,
pub tip: Position,
pub inward: Position,
pub navigable: bool,
}
pub fn association_end_geometry(
relation: &Relation,
routed: &RoutedEdge,
) -> Vec<AssociationEndGeometry> {
let RelationBody::DataAssociation {
source_navigable,
target_navigable,
..
} = &relation.body
else {
return Vec::new();
};
let mut ends: Vec<AssociationEndGeometry> = Vec::new();
for (source_end, navigable) in [(true, *source_navigable), (false, *target_navigable)] {
let Some(tip) = (if source_end {
routed.points.first()
} else {
routed.points.last()
})
.copied() else {
continue;
};
let inward = if source_end {
routed.points.iter().find(|p| **p != tip)
} else {
routed.points.iter().rev().find(|p| **p != tip)
};
let Some(inward) = inward.copied() else {
continue;
};
let full = crate::data::association_end_label(relation, source_end).unwrap_or_default();
let text = if let Some((role, multiplicity)) = full.rsplit_once(" [") {
let role = if role.chars().count() > 32 {
role.chars()
.take(31)
.chain(std::iter::once('…'))
.collect::<String>()
} else {
role.to_owned()
};
format!("{role} [{multiplicity}")
} else {
full
};
let size = Size {
width: text.chars().count() as f32 * 7.0 + 12.0,
height: 22.0,
};
let dx = inward.x - tip.x;
let dy = inward.y - tip.y;
let length = dx.hypot(dy);
let direction = pos(dx / length, dy / length);
// One normal sign puts opposite endpoints of a straight edge on
// opposite sides because their inward tangent directions are reversed.
let along =
direction.x.abs() * size.width / 2.0 + direction.y.abs() * size.height / 2.0 + 12.0;
let normal =
direction.y.abs() * size.width / 2.0 + direction.x.abs() * size.height / 2.0 + 8.0;
let mut center = pos(
tip.x + direction.x * along - direction.y * normal,
tip.y + direction.y * along + direction.x * normal,
);
if let Some(other) = ends.first()
&& (center.x - other.label.center.x).abs()
< (size.width + other.label.bounds.size.width) / 2.0 + 6.0
&& (center.y - other.label.center.y).abs()
< (size.height + other.label.bounds.size.height) / 2.0 + 6.0
{
// Same-side anchors on a self-loop can still be close. Separate
// their complete bounds along this end's normal in one step.
let perpendicular = pos(-direction.y, direction.x);
let distance = (center.x - other.label.center.x) * perpendicular.x
+ (center.y - other.label.center.y) * perpendicular.y;
let clearance = (perpendicular.x.abs() * (size.width + other.label.bounds.size.width)
+ perpendicular.y.abs() * (size.height + other.label.bounds.size.height))
/ 2.0
+ 8.0;
let shift = if distance < 0.0 {
-clearance - distance
} else {
clearance - distance
};
center.x += perpendicular.x * shift;
center.y += perpendicular.y * shift;
}
ends.push(AssociationEndGeometry {
source_end,
label: EdgeLabelGeometry {
text,
fraction: if source_end { 0.0 } else { 1.0 },
anchor: tip,
center,
bounds: RoutingBounds {
position: pos(center.x - size.width / 2.0, center.y - size.height / 2.0),
size,
},
},
tip,
inward,
navigable,
});
}
ends
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
PinInsideObstacle { pin: usize, obstacle: Id },
@@ -582,14 +466,6 @@ fn route_diagram_impl(
// never changes paths or authored metadata. Previews retain other cached
// labels and avoid the paths actually shown during the gesture.
let mut previous_labels = Vec::new();
for routed in edges.values() {
if let Some(relation) = project.relations.get(&routed.id) {
for end in association_end_geometry(relation, routed) {
bounds.rect(end.label.bounds.position, end.label.bounds.size);
previous_labels.push(end.label.bounds);
}
}
}
for edge in ordered {
let Some(routed) = edges.get_mut(&edge.id) else {
continue;
@@ -703,7 +579,7 @@ fn route_edge(
let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
// A real self-relation must leave and return at distinct anchors.
// A real self-transition must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
@@ -1231,8 +1107,7 @@ fn label_text(project: &Project, edge: Id) -> String {
let text = match relation.kind() {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::DataAssociation => relation.name.as_str(),
| RelationKind::BoundaryExchange => relation.name.as_str(),
RelationKind::CapabilityInclude => "«include»",
RelationKind::CapabilityExtend => "«extend»",
RelationKind::CapabilityGeneralization => "specializes",
-16
View File
@@ -1,16 +0,0 @@
//! Closed typed facts for deliberately reviewed OA/SA semantics.
//!
//! Historical facts are independently acknowledged observations, not a synthetic
//! live model. Their UUIDs are not live references and survive ordinary deletion.
mod catalog;
mod historical;
mod linked;
mod mutate;
mod review;
mod types;
pub use catalog::*;
pub(crate) use historical::{HistoricalSourceKind, historical_source_slots};
pub use historical::{validate_semantic_baseline, visit_semantic_references};
pub use linked::*;
pub use review::*;
pub use types::*;
File diff suppressed because it is too large Load Diff
-404
View File
@@ -1,404 +0,0 @@
use super::types::*;
use crate::model::*;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HistoricalSourceKind {
Reference,
Enum,
}
const MAX_FACTS: usize = 16_384;
const MAX_LEAVES: usize = 1_000_000;
const MAX_PATH: usize = 128;
struct Walk<'a> {
path: Vec<String>,
slots: Vec<(Vec<String>, HistoricalSourceKind)>,
errors: Vec<String>,
leaves: usize,
visitor: &'a mut dyn FnMut(&mut Id),
}
impl Walk<'_> {
fn at(&mut self, key: impl ToString, f: impl FnOnce(&mut Self)) {
if self.leaves >= MAX_LEAVES {
return;
}
self.leaves += 1;
self.path.push(key.to_string());
f(self);
self.path.pop();
}
fn slot(&mut self, kind: HistoricalSourceKind) {
self.slots.push((self.path.clone(), kind));
}
fn error(&mut self, message: &str) {
self.errors
.push(format!("{}: {message}", self.path.join(".")));
}
}
trait Historical {
fn walk(&mut self, w: &mut Walk<'_>);
}
impl Historical for Id {
fn walk(&mut self, w: &mut Walk<'_>) {
w.slot(HistoricalSourceKind::Reference);
(w.visitor)(self);
}
}
macro_rules! plain {
($($ty:ty),*) => { $( impl Historical for $ty { fn walk(&mut self, _: &mut Walk<'_>) {} } )* };
}
plain!(bool, u32, i64);
impl Historical for String {
fn walk(&mut self, w: &mut Walk<'_>) {
if !crate::validation::valid_text(self) {
w.error("historical text contains an invalid character");
}
if self.len() > 1_000_000 {
w.error("historical text work limit exceeded");
}
}
}
impl Historical for f64 {
fn walk(&mut self, w: &mut Walk<'_>) {
if !self.is_finite() {
w.error("historical real must be finite");
}
}
}
macro_rules! enums {
($($ty:ty),*) => { $( impl Historical for $ty {
fn walk(&mut self, w: &mut Walk<'_>) { w.slot(HistoricalSourceKind::Enum); }
} )* };
}
enums!(
ExchangeItemKind,
ExchangeMechanism,
ParameterDirection,
FieldStorage,
ComponentPortKind,
ComponentPortOrientation,
PortDirection,
ScenarioViewpoint,
ControlKind,
FragmentOperator,
TimeEventKind,
StateKind,
TransmissionProtocol,
AcquisitionProtocol,
TransitionKind,
StateActionKind,
ArtifactRelation,
ReferenceCollection,
OrderedCollection,
IncidentEnd,
DataConstructor,
PortConstructor,
OccurrenceConstructor,
EventConstructor,
MessageConstructor
);
impl<T: Historical> Historical for Option<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if let Some(v) = self {
v.walk(w);
}
}
}
impl<T: Historical> Historical for Vec<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
for (i, v) in self.iter_mut().enumerate().take(MAX_LEAVES) {
w.at(i, |w| v.walk(w));
}
if self.len() > MAX_LEAVES {
w.error("historical collection work limit exceeded");
}
}
}
impl<T: Historical> Historical for Observed<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::NotApplicable => w.slot(HistoricalSourceKind::Enum),
Self::Value(v) => w.at("Value", |w| v.walk(w)),
}
}
}
impl<T: Historical> Historical for Reviewed<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if matches!(
(&self.oa, &self.sa),
(Observed::NotApplicable, Observed::NotApplicable)
) {
w.error("a reviewed facet must apply on at least one side");
}
w.at("oa", |w| self.oa.walk(w));
w.at("sa", |w| self.sa.walk(w));
}
}
impl<T: Historical> Historical for KeyPair<T> {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.oa.is_none() && self.sa.is_none() {
w.error("key pair cannot be empty on both sides");
}
w.at("oa", |w| self.oa.walk(w));
w.at("sa", |w| self.sa.walk(w));
}
}
impl Historical for AnnotationKey {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.path.len() > MAX_PATH {
w.error("historical annotation path limit exceeded");
}
w.at("path", |w| self.path.walk(w));
w.at("field", |w| self.field.walk(w));
}
}
impl Historical for MessageValue {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.path.len() > MAX_PATH {
w.error("historical annotation path limit exceeded");
}
w.at("path", |w| self.path.walk(w));
w.at("field", |w| self.field.walk(w));
w.at("expression", |w| self.expression.walk(w));
}
}
impl Historical for LifelineBinding {
fn walk(&mut self, w: &mut Walk<'_>) {
w.at("referenced", |w| self.referenced.walk(w));
w.at("local", |w| self.local.walk(w));
}
}
impl Historical for DataValue {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Real(value) => w.at("Real", |w| value.walk(w)),
Self::String(value) => w.at("String", |w| value.walk(w)),
Self::Enumeration(value) => w.at("Enumeration", |w| value.walk(w)),
Self::Boolean(_) | Self::Integer(_) => {}
}
}
}
impl Historical for TypeRef {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Named(id) => w.at("Named", |w| id.walk(w)),
_ => w.slot(HistoricalSourceKind::Enum),
}
}
}
impl Historical for ArtifactReview {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Presence(v) => w.at("Presence", |w| v.walk(w)),
Self::Name(v) => w.at("Name", |w| v.walk(w)),
Self::Description(v) => w.at("Description", |w| v.walk(w)),
Self::Owner(v) => w.at("Owner", |w| v.walk(w)),
Self::Relation(v) => w.at("Relation", |w| v.walk(w)),
Self::Uri(v) => w.at("Uri", |w| v.walk(w)),
Self::Revision(v) => w.at("Revision", |w| v.walk(w)),
Self::Fragment(v) => w.at("Fragment", |w| v.walk(w)),
}
}
}
macro_rules! family_visitor {
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
impl Historical for FacetReview {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
$( Self::$family(v) => w.at(stringify!($family), |w| match v {
$( $review::$leaf(v) => w.at(stringify!($leaf), |w| v.walk(w)), )*
}), )*
Self::Member { collection, keys, review } => w.at("Member", |w| {
w.at("collection", |w| collection.walk(w)); w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Order { collection, review } => w.at("Order", |w| {
w.at("collection", |w| collection.walk(w)); w.at("review", |w| review.walk(w));
for side in [&review.oa, &review.sa] { if let Observed::Value(order) = side {
if order.iter().collect::<BTreeSet<_>>().len() != order.len() { w.error("duplicate historical order member"); }
}}
}),
Self::Literal { keys, review } => w.at("Literal", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Annotation { keys, review } => w.at("Annotation", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
if let Observed::Value(Some(row)) = value {
if key.as_ref() != Some(&AnnotationKey::from(row)) { w.error("historical annotation key and payload disagree"); }
}
}
}),
Self::Binding { keys, review } => w.at("Binding", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] {
if let Observed::Value(Some(row)) = value {
if *key != Some(row.referenced) { w.error("historical binding key and payload disagree"); }
}
}
}),
Self::Child { keys, review } => w.at("Child", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Incident { keys, end, review } => w.at("Incident", |w| {
w.at("keys", |w| keys.walk(w)); w.at("end", |w| end.walk(w)); w.at("review", |w| review.walk(w));
}),
Self::Artifact { keys, review } => w.at("Artifact", |w| {
w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w));
}),
}
}
}
};
}
scalar_families!(family_visitor);
fn walk_baseline<'a>(
baseline: &mut SemanticBaseline,
visitor: &'a mut dyn FnMut(&mut Id),
) -> Walk<'a> {
let mut walk = Walk {
path: vec!["facets".into()],
slots: vec![],
errors: vec![],
leaves: 0,
visitor,
};
if baseline.facets.len() > MAX_FACTS {
walk.error("historical facet count limit exceeded");
}
for (i, facet) in baseline.facets.iter_mut().enumerate().take(MAX_FACTS) {
walk.at(i, |w| facet.walk(w));
}
if walk.leaves >= MAX_LEAVES {
walk.error("historical reference work limit exceeded");
}
walk
}
/// Historical UUIDs are visited only on explicit identity remapping, never deletion.
pub fn visit_semantic_references(
baseline: &mut SemanticBaseline,
mut visitor: impl FnMut(&mut Id),
) {
walk_baseline(baseline, &mut visitor);
}
pub(crate) fn historical_source_slots(
baseline: &SemanticBaseline,
) -> Vec<(Vec<String>, HistoricalSourceKind)> {
walk_baseline(&mut baseline.clone(), &mut |_| {}).slots
}
/// Checks persisted facts without demanding that historical references still exist.
pub fn validate_semantic_baseline(baseline: &SemanticBaseline) -> Result<(), Vec<String>> {
let mut errors = walk_baseline(&mut baseline.clone(), &mut |_| {}).errors;
let mut slots = BTreeSet::new();
for selector in baseline
.facets
.iter()
.take(MAX_FACTS)
.map(FacetReview::selector)
{
if selector_slots(&selector)
.into_iter()
.any(|slot| !slots.insert(slot))
{
errors.push(format!(
"duplicate or overlapping historical facet: {selector:?}"
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub(super) fn selector_slots(selector: &FacetSelector) -> Vec<FacetSelector> {
fn split<T: Clone>(keys: &KeyPair<T>) -> Vec<KeyPair<T>> {
let mut result = vec![];
if let Some(oa) = &keys.oa {
result.push(KeyPair {
oa: Some(oa.clone()),
sa: None,
});
}
if let Some(sa) = &keys.sa {
result.push(KeyPair {
oa: None,
sa: Some(sa.clone()),
});
}
result
}
match selector {
FacetSelector::Member { collection, keys } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Member {
collection: *collection,
keys,
})
.collect(),
FacetSelector::Literal(keys) => split(keys)
.into_iter()
.map(FacetSelector::Literal)
.collect(),
FacetSelector::Annotation(keys) => split(keys)
.into_iter()
.map(FacetSelector::Annotation)
.collect(),
FacetSelector::Binding(keys) => split(keys)
.into_iter()
.map(FacetSelector::Binding)
.collect(),
FacetSelector::Child(keys) => split(keys).into_iter().map(FacetSelector::Child).collect(),
FacetSelector::Incident { keys, end } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Incident { keys, end: *end })
.collect(),
FacetSelector::Artifact { keys, facet } => split(keys)
.into_iter()
.map(|keys| FacetSelector::Artifact {
keys,
facet: *facet,
})
.collect(),
value => vec![value.clone()],
}
}
fn pair_overlap<T: Eq>(a: &KeyPair<T>, b: &KeyPair<T>) -> bool {
a.oa.as_ref()
.zip(b.oa.as_ref())
.is_some_and(|(a, b)| a == b)
|| a.sa
.as_ref()
.zip(b.sa.as_ref())
.is_some_and(|(a, b)| a == b)
}
/// Slot identity is each actual side's key, not equality of the complete pair.
pub(crate) fn keyed_slots_overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
match (a, b) {
(
FacetSelector::Member {
collection: a,
keys: ak,
},
FacetSelector::Member {
collection: b,
keys: bk,
},
) => a == b && pair_overlap(ak, bk),
(FacetSelector::Literal(a), FacetSelector::Literal(b)) => pair_overlap(a, b),
(FacetSelector::Annotation(a), FacetSelector::Annotation(b)) => pair_overlap(a, b),
(FacetSelector::Binding(a), FacetSelector::Binding(b)) => pair_overlap(a, b),
(FacetSelector::Child(a), FacetSelector::Child(b)) => pair_overlap(a, b),
(
FacetSelector::Incident { keys: ak, end: a },
FacetSelector::Incident { keys: bk, end: b },
) => a == b && pair_overlap(ak, bk),
(
FacetSelector::Artifact { keys: ak, facet: a },
FacetSelector::Artifact { keys: bk, facet: b },
) => a == b && pair_overlap(ak, bk),
_ => a == b,
}
}
-174
View File
@@ -1,174 +0,0 @@
use super::*;
use crate::model::*;
use crate::transitions::Drift;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LinkedSemanticImpact {
pub objects: KeyPair<Id>,
pub traces: Vec<Id>,
pub drift: Drift,
pub unreviewed: bool,
}
const MAX_LINKED_OBJECTS: usize = 4_096;
const MAX_LINKED_EDGES: usize = 32_768;
/// Derived dependency diagnostics, never another historical baseline. Follow
/// direct references/children, then only reusable dictionary/event definitions.
/// Association endpoints and ordinary architecture neighborhoods do not expand.
pub fn linked_semantic_impacts(
project: &Project,
trace: &Trace,
references: &BTreeMap<Id, Id>,
) -> Result<Vec<LinkedSemanticImpact>, String> {
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for element in project.elements.values() {
if let Some(parent) = element.parent {
children.entry(parent).or_default().push(element.id);
}
}
let mut contracts: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
for relation in project.relations.values() {
if let RelationBody::InterfaceItemAllocation { source, .. } = relation.body {
contracts.entry(source).or_default().push(relation.id);
}
}
let direct = |id| -> Vec<Id> {
let mut out = vec![];
if let Some(element) = project.elements.get(&id) {
out.extend(
crate::references::element_references(element)
.into_iter()
.filter(|r| Some(*r) != element.parent),
);
}
if let Some(relation) = project.relations.get(&id) {
out.extend(crate::references::relation_references(relation));
}
out.extend(children.get(&id).into_iter().flatten().copied());
out.extend(contracts.get(&id).into_iter().flatten().copied());
out
};
let expandable = |id| {
project.elements.get(&id).is_some_and(|e| {
matches!(
e.kind(),
Kind::DataType
| Kind::DataField
| Kind::ExchangeItem
| Kind::Interface
| Kind::Event
)
}) || project
.relations
.get(&id)
.is_some_and(|r| matches!(r.body, RelationBody::InterfaceItemAllocation { .. }))
};
let mut edges = 0usize;
let mut traverse = |root| -> Result<BTreeSet<Id>, String> {
let mut seen = BTreeSet::from([root]);
let first = direct(root);
edges += first.len();
if edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope".into(),
);
}
let mut queue: VecDeque<_> = first.into();
while let Some(id) = queue.pop_front() {
if !seen.insert(id) {
continue;
}
if seen.len() > MAX_LINKED_OBJECTS || edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope"
.into(),
);
}
if expandable(id) {
let next = direct(id);
edges += next.len();
if edges > MAX_LINKED_EDGES {
return Err(
"Linked semantic dependency work limit exceeded; narrow the review scope"
.into(),
);
}
queue.extend(next);
}
}
seen.remove(&root);
Ok(seen)
};
let oa = traverse(trace.oa)?;
let sa = traverse(trace.sa)?;
let mut traced: BTreeMap<(Id, Id), Vec<&Trace>> = BTreeMap::new();
for t in project.traces.values() {
traced.entry((t.oa, t.sa)).or_default().push(t);
}
let mut pairs = vec![];
let mut used = BTreeSet::new();
for id in oa {
let target = references.get(&id).copied();
if let Some(id) = target {
used.insert(id);
}
pairs.push(KeyPair {
oa: Some(id),
sa: target,
});
}
for id in sa.difference(&used) {
pairs.push(KeyPair {
oa: None,
sa: Some(*id),
});
}
let mut result = vec![];
for objects in pairs {
if objects.oa == Some(trace.oa) && objects.sa == Some(trace.sa) {
continue;
}
let links = objects
.oa
.zip(objects.sa)
.and_then(|pair| traced.get(&pair));
let mut flags = (false, false, links.is_none());
let mut ids = vec![];
for linked in links.into_iter().flatten() {
ids.push(linked.id);
let facts = super::catalog::own_semantic_facets(project, linked, references)?;
if facts.is_empty() {
flags.2 = true;
}
for fact in facts {
match fact.drift {
Drift::OaChanged => flags.0 = true,
Drift::SaChanged => flags.1 = true,
Drift::BothChanged => {
flags.0 = true;
flags.1 = true;
}
Drift::Unreviewed => flags.2 = true,
_ => {}
}
}
}
let drift = match flags {
(true, true, _) => Drift::BothChanged,
(true, false, _) => Drift::OaChanged,
(false, true, _) => Drift::SaChanged,
(false, false, true) => Drift::Unreviewed,
_ => Drift::Unchanged,
};
result.push(LinkedSemanticImpact {
objects,
traces: ids,
drift,
unreviewed: flags.2,
});
}
Ok(result)
}
-592
View File
@@ -1,592 +0,0 @@
use super::catalog::{collection, order_collection, sides, write_scalar};
use super::*;
use crate::model::*;
use crate::transitions::TransitionDirection;
use std::collections::{BTreeMap, BTreeSet};
pub(crate) fn set_collection(
p: &mut Project,
id: Id,
which: ReferenceCollection,
list: Vec<Id>,
) -> Result<(), String> {
if list.iter().collect::<BTreeSet<_>>().len() != list.len() {
return Err("Collection contains duplicate members".into());
}
use ReferenceCollection as C;
if which == C::Steps {
let e = p.elements.get_mut(&id).ok_or("Missing sequence")?;
if let Some(steps) = e.steps_mut() {
*steps = list;
return Ok(());
}
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { occurrences, .. },
..
}) = e.shared_mut()
{
*occurrences = list;
return Ok(());
}
return Err("Target has no ordered steps".into());
}
let slot = match which {
C::Steps => unreachable!(),
C::FragmentBranches => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => Some(branches),
_ => None,
},
C::FunctionInputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
}) => Some(incoming_items),
_ => None,
},
C::FunctionOutputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
}) => Some(outgoing_items),
_ => None,
},
C::SignalItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Event {
body: EventBody::Signal { items },
}) => Some(items),
_ => None,
},
C::ExchangeItems => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(
RelationBody::Interaction { items, .. }
| RelationBody::FunctionalExchange { items, .. }
| RelationBody::BoundaryExchange { items, .. },
) => Some(items),
_ => None,
},
C::TransitionTriggers => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(RelationBody::StateTransition { triggers, .. }) => Some(triggers),
_ => None,
},
C::TransitionEffects => match p.relations.get_mut(&id).map(|r| &mut r.body) {
Some(RelationBody::StateTransition { effects, .. }) => Some(effects),
_ => None,
},
C::Constrained => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained),
_ => None,
},
}
.ok_or("Collection is inapplicable on target")?;
*slot = list;
Ok(())
}
pub(crate) fn chosen<T>(pair: &KeyPair<T>, layer: Layer) -> Option<&T> {
match layer {
Layer::Oa => pair.oa.as_ref(),
Layer::Sa => pair.sa.as_ref(),
}
}
pub(crate) fn value<T>(review: &Reviewed<T>, layer: Layer) -> Result<&T, String> {
match match layer {
Layer::Oa => &review.oa,
Layer::Sa => &review.sa,
} {
Observed::Value(v) => Ok(v),
Observed::NotApplicable => {
Err("Facet does not exist in the selected constructor branch".into())
}
}
}
pub(crate) fn directional_map(
map: &BTreeMap<Id, Id>,
direction: TransitionDirection,
) -> Result<BTreeMap<Id, Id>, String> {
if direction == TransitionDirection::OaToSa {
return Ok(map.clone());
}
let mut inverse = BTreeMap::new();
for (oa, sa) in map {
if inverse.insert(*sa, *oa).is_some_and(|old| old != *oa) {
return Err(format!(
"Reverse reference {sa} has multiple OA correspondences; supply an unambiguous mapping"
));
}
}
Ok(inverse)
}
pub(crate) fn remap_record(
record: &mut SemanticRecord,
map: &BTreeMap<Id, Id>,
) -> Result<(), String> {
let mut missing = BTreeSet::new();
let mut remap = |id: &mut Id| {
if let Some(new) = map.get(id) {
*id = *new;
} else {
missing.insert(*id);
}
};
match record {
SemanticRecord::Element(e) => crate::references::visit_element_refs(e, &mut remap),
SemanticRecord::Relation(r) => crate::references::visit_relation_refs(r, &mut remap),
SemanticRecord::Artifact(a) => remap(&mut a.owner),
}
if missing.is_empty() {
Ok(())
} else {
Err(format!(
"Missing explicit reference correspondence: {missing:?}"
))
}
}
pub(crate) fn remap_fact(
fact: &FacetReview,
map: &BTreeMap<Id, Id>,
source: Layer,
) -> Result<FacetReview, String> {
// Remap only the selected observation. Keys remain in canonical OA/SA space.
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
let base = SemanticBaseline {
facets: vec![fact.clone()],
};
let side = match source {
Layer::Oa => "oa",
Layer::Sa => "sa",
};
let mut missing = BTreeSet::new();
for (path, kind) in historical_source_slots(&base) {
if kind != HistoricalSourceKind::Reference {
continue;
}
let rest = &path[2..];
if !rest.windows(2).any(|w| w[0] == side && w[1] == "Value") {
continue;
}
let mut leaf = &mut json;
for part in rest {
leaf = if leaf.is_array() {
leaf.get_mut(
part.parse::<usize>()
.map_err(|_| "Invalid historical array index")?,
)
} else {
leaf.get_mut(part)
}
.ok_or("Invalid historical source slot")?;
}
let id: Id = serde_json::from_value(leaf.clone()).map_err(|e| e.to_string())?;
if let Some(new) = map.get(&id) {
*leaf = serde_json::to_value(new).unwrap();
} else {
missing.insert(id);
}
}
if !missing.is_empty() {
return Err(format!(
"Missing explicit reference correspondence: {missing:?}"
));
}
serde_json::from_value(json).map_err(|e| e.to_string())
}
pub(crate) fn check_keys(selector: &FacetSelector, map: &BTreeMap<Id, Id>) -> Result<(), String> {
fn pair(keys: &KeyPair<Id>, map: &BTreeMap<Id, Id>) -> Result<(), String> {
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa)
&& map.get(&oa) != Some(&sa)
{
return Err("Key pair disagrees with frozen reference correspondence".into());
}
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty key pair".into());
}
Ok(())
}
match selector {
FacetSelector::Member { keys, .. }
| FacetSelector::Binding(keys)
| FacetSelector::Child(keys)
| FacetSelector::Incident { keys, .. }
| FacetSelector::Artifact { keys, .. } => pair(keys, map),
FacetSelector::Annotation(keys) => {
if let (Some(oa), Some(sa)) = (&keys.oa, &keys.sa) {
if oa.path.len() != sa.path.len() {
return Err("Annotation path pairing disagrees".into());
}
for (oa, sa) in oa
.path
.iter()
.chain([&oa.field])
.zip(sa.path.iter().chain([&sa.field]))
{
pair(
&KeyPair {
oa: Some(*oa),
sa: Some(*sa),
},
map,
)?;
}
}
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty annotation pair".into());
}
Ok(())
}
FacetSelector::Literal(keys) => {
if keys
.oa
.as_ref()
.zip(keys.sa.as_ref())
.is_some_and(|(oa, sa)| oa != sa)
{
return Err(
"Enum literal identity is exact text; renaming is removal/addition".into(),
);
}
Ok(())
}
_ => Ok(()),
}
}
/// Apply just one selected fact to an in-memory expected project. The caller
/// subsequently prepares ordinary Commands and checks every induced difference.
pub(crate) fn write_fact(
p: &mut Project,
target: Id,
fact: &FacetReview,
from: Layer,
target_layer: Layer,
preserve_target_only: bool,
) -> Result<(), String> {
match fact {
FacetReview::Member {
collection: which,
keys,
review,
} => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let present = *value(review, from)?;
let key = chosen(keys, target_layer)
.copied()
.ok_or("Map the target collection member explicitly")?;
let mut list = collection(p, target, *which).ok_or("Inapplicable target collection")?;
if present {
if !list.contains(&key) {
list.push(key);
}
} else {
list.retain(|id| *id != key);
}
set_collection(p, target, *which, list)
}
FacetReview::Order {
collection: which,
review,
} => {
let supplied = value(review, from)?.clone();
let current = collection(p, target, order_collection(*which))
.ok_or("Inapplicable target order")?;
let supplied_set: BTreeSet<_> = supplied.iter().copied().collect();
let current_set: BTreeSet<_> = current.iter().copied().collect();
if supplied_set != current_set {
if preserve_target_only
&& supplied_set.is_subset(&current_set)
&& current
.iter()
.filter(|id| supplied_set.contains(id))
.copied()
.collect::<Vec<_>>()
== supplied
{
return Ok(());
}
return Err("Order must contain every surviving target member exactly once; target-only insertion requires an explicit final order".into());
}
set_collection(p, target, order_collection(*which), supplied)
}
FacetReview::Literal { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let present = *value(review, from)?;
let key = chosen(keys, target_layer).ok_or("Missing literal key on target")?;
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target is not an enumeration".into());
};
if present {
if !literals.contains(key) {
literals.push(key.clone());
}
} else {
literals.retain(|v| v != key);
}
Ok(())
}
FacetReview::Annotation { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Missing target annotation key")?;
let row = value(review, from)?.clone();
if row.as_ref().is_some_and(|v| AnnotationKey::from(v) != *key) {
return Err(
"Annotation candidate changes its row key; review removal/addition".into(),
);
}
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target has no message annotations".into());
};
values.retain(|v| AnnotationKey::from(v) != *key);
if let Some(row) = row {
values.push(row);
}
Ok(())
}
FacetReview::Binding { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = *chosen(keys, target_layer).ok_or("Missing target binding key")?;
let row = value(review, from)?.clone();
if row.as_ref().is_some_and(|v| v.referenced != key) {
return Err(
"Binding candidate changes its row key; review removal/addition".into(),
);
}
let Some(SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { bindings, .. },
..
}) = p.elements.get_mut(&target).and_then(Element::shared_mut)
else {
return Err("Target has no scenario bindings".into());
};
bindings.retain(|v| v.referenced != key);
if let Some(row) = row {
bindings.push(row);
}
Ok(())
}
FacetReview::Child { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map target child explicitly")?;
let e = p
.elements
.get_mut(key)
.ok_or("Target child does not exist; create it through transition mapping")?;
if *value(review, from)? {
e.parent = Some(target);
} else if e.parent == Some(target) {
e.parent = None;
}
Ok(())
}
FacetReview::Incident { keys, end, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map incident relation explicitly")?;
if !*value(review, from)? {
return Err("Unlinking an incident relation requires an explicit final endpoint through the relation trace".into());
}
let relation = p
.relations
.get_mut(key)
.ok_or("Mapped incident relation is absent")?;
let (a, b) = relation.body.endpoints();
match end {
IncidentEnd::Source => relation.body.set_endpoints(target, b),
IncidentEnd::Target => relation.body.set_endpoints(a, target),
}
Ok(())
}
FacetReview::Artifact { keys, review } => {
if preserve_target_only && chosen(keys, from).is_none() {
return Ok(());
}
let key = chosen(keys, target_layer).ok_or("Map artifact explicitly")?;
if let ArtifactReview::Presence(review) = review {
if !*value(review, from)? {
p.artifact_links.remove(key);
return Ok(());
}
if p.artifact_links.get(key).is_some_and(|a| a.owner == target) {
return Ok(());
}
return Err("Artifact creation requires a complete typed candidate".into());
}
let a = p
.artifact_links
.get_mut(key)
.ok_or("Artifact candidate is absent")?;
if a.owner != target {
return Err(
"Artifact metadata can only be reviewed on its currently attached owner trace"
.into(),
);
}
match review {
ArtifactReview::Presence(_) => unreachable!(),
ArtifactReview::Name(r) => a.name = value(r, from)?.clone(),
ArtifactReview::Description(r) => a.description = value(r, from)?.clone(),
ArtifactReview::Owner(r) => a.owner = *value(r, from)?,
ArtifactReview::Relation(r) => a.relation = *value(r, from)?,
ArtifactReview::Uri(r) => a.uri = value(r, from)?.clone(),
ArtifactReview::Revision(r) => a.revision = value(r, from)?.clone(),
ArtifactReview::Fragment(r) => a.fragment = value(r, from)?.clone(),
}
Ok(())
}
_ => {
let (oa, sa) = sides(fact);
let side = match from {
Layer::Oa => oa,
Layer::Sa => sa,
};
let value = side
.get("Value")
.ok_or("Selected branch is not applicable")?
.clone();
write_scalar(p, target, &fact.selector(), value)
}
}
}
pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector) -> bool {
use FacetSelector as F;
match group {
F::Data(DataFacet::Constructor) => {
matches!(facet,F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_))
}
F::Port(PortFacet::Constructor) => matches!(
facet,
F::Port(_)
| F::Member {
collection: ReferenceCollection::FunctionInputItems
| ReferenceCollection::FunctionOutputItems,
..
}
),
F::Occurrence(OccurrenceFacet::Constructor) => {
matches!(facet,F::Occurrence(f) if !matches!(f,OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition))
|| matches!(
facet,
F::Annotation(_)
| F::Binding(_)
| F::Member {
collection: ReferenceCollection::Steps
| ReferenceCollection::FragmentBranches,
..
}
| F::Order(_)
)
}
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
facet,
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
),
F::Event(EventFacet::Constructor) => matches!(
facet,
F::Event(_)
| F::Member {
collection: ReferenceCollection::SignalItems,
..
}
),
F::State(StateFacet::Kind) => facet == group,
_ => false,
}
}
pub(crate) fn copy_constructor(
p: &mut Project,
target: Id,
source: &SemanticRecord,
group: &FacetSelector,
) -> Result<(), String> {
let SemanticRecord::Element(source) = source else {
return Err("Constructor source must be an element".into());
};
let target = p
.elements
.get_mut(&target)
.ok_or("Missing target element")?;
let a = source.shared().ok_or("Source has no shared constructor")?;
let b = target
.shared_mut()
.ok_or("Target has no shared constructor")?;
match (group, a, b) {
(
FacetSelector::Data(DataFacet::Constructor),
SharedBody::DataType {
definition,
default,
..
},
SharedBody::DataType {
definition: d,
default: v,
..
},
) => {
*d = definition.clone();
*v = default.clone();
}
(
FacetSelector::Port(PortFacet::Constructor),
SharedBody::Port { contract, legacy },
SharedBody::Port {
contract: c,
legacy: l,
},
) => {
*c = contract.clone();
*l = legacy.clone();
}
(
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
SharedBody::Occurrence { body, .. },
SharedBody::Occurrence { body: b, .. },
) => *b = body.clone(),
(
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
},
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: k, ..
},
..
},
) => *k = message_kind.clone(),
(
FacetSelector::Event(EventFacet::Constructor),
SharedBody::Event { body },
SharedBody::Event { body: b },
) => *b = body.clone(),
(
FacetSelector::State(StateFacet::Kind),
SharedBody::State { kind, .. },
SharedBody::State { kind: k, .. },
) => *k = *kind,
_ => return Err("Constructor family mismatch".into()),
}
Ok(())
}
File diff suppressed because it is too large Load Diff
-255
View File
@@ -1,255 +0,0 @@
use crate::model::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Observed<T> {
NotApplicable,
Value(T),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reviewed<T> {
pub oa: Observed<T>,
pub sa: Observed<T>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyPair<K> {
pub oa: Option<K>,
pub sa: Option<K>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AnnotationKey {
pub path: Vec<Id>,
pub field: Id,
}
impl From<&MessageValue> for AnnotationKey {
fn from(value: &MessageValue) -> Self {
Self {
path: value.path.clone(),
field: value.field,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataConstructor {
Boolean,
Integer,
Real,
String,
Enumeration,
Record,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortConstructor {
FunctionInput,
FunctionOutput,
Component,
Legacy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OccurrenceConstructor {
Exchange,
Action,
Control,
SequenceReference,
ScenarioUse,
State,
Fragment,
Branch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EventConstructor {
Signal,
Time,
Change,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageConstructor {
Asynchronous,
Call,
Reply,
Create,
Delete,
Timer,
}
// This finite catalog is also consumed by the typed historical leaf visitor.
// Each selector fixes its payload type; there is no arbitrary value/path schema.
macro_rules! scalar_families {
($m:ident) => { $m! {
Header, HeaderFacet, HeaderReview {
Name: String, Description: String, Parent: Option<Id>
}
ExchangeItem, ExchangeItemFacet, ExchangeItemReview {
Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option<Id>
}
Interface, InterfaceFacet, InterfaceReview { Supertype: Option<Id> }
Data, DataFacet, DataReview {
Constructor: DataConstructor,
IntegerMinimum: Option<i64>, IntegerMaximum: Option<i64>,
RealMinimum: Option<f64>, RealMaximum: Option<f64>,
StringMinimumLength: u32, StringMaximumLength: Option<u32>,
RecordSupertype: Option<Id>, Default: Option<DataValue>, Unit: String
}
Field, FieldFacet, FieldReview {
Type: TypeRef, Minimum: u32, Maximum: Option<u32>, Unit: String,
Direction: ParameterDirection, Storage: FieldStorage, Default: Option<Vec<DataValue>>
}
Port, PortFacet, PortReview {
Constructor: PortConstructor, ComponentKind: ComponentPortKind,
Orientation: ComponentPortOrientation, LegacyDirection: PortDirection,
LegacyInterface: Option<Id>
}
Scenario, ScenarioFacet, ScenarioReview {
Viewpoint: ScenarioViewpoint, Context: Option<Id>, Precondition: String, Postcondition: String
}
Lifeline, LifelineFacet, LifelineReview { Represented: Option<Id> }
Occurrence, OccurrenceFacet, OccurrenceReview {
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
}
Event, EventFacet, EventReview {
Constructor: EventConstructor, TimeKind: TimeEventKind, TimeExpression: String, ChangeExpression: String
}
State, StateFacet, StateReview { Kind: StateKind, Entry: String, Exit: String, DoActivity: String }
Constraint, ConstraintFacet, ConstraintReview { Expression: String }
Relation, RelationFacet, RelationReview {
Source: Id, Target: Id, AllocationOrder: u32, Transmission: TransmissionProtocol,
Acquisition: AcquisitionProtocol, SourceRole: String, TargetRole: String,
SourceMinimum: u32, SourceMaximum: Option<u32>, TargetMinimum: u32,
TargetMaximum: Option<u32>, SourceNavigable: bool, TargetNavigable: bool,
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
Guard: String, Effect: String, StateActionPhase: StateActionKind
}
}};
}
pub(super) use scalar_families;
macro_rules! define_families {
($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => {
$(
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum $selector { $( $leaf ),* }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum $review { $( $leaf(Reviewed<$ty>) ),* }
)*
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FacetSelector {
$( $family($selector), )*
Member { collection: ReferenceCollection, keys: KeyPair<Id> },
Order(OrderedCollection),
Literal(KeyPair<String>),
Annotation(KeyPair<AnnotationKey>),
Binding(KeyPair<Id>),
Child(KeyPair<Id>),
Incident { keys: KeyPair<Id>, end: IncidentEnd },
Artifact { keys: KeyPair<Id>, facet: ArtifactFacet },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FacetReview {
$( $family($review), )*
Member { collection: ReferenceCollection, keys: KeyPair<Id>, review: Reviewed<bool> },
Order { collection: OrderedCollection, review: Reviewed<Vec<Id>> },
Literal { keys: KeyPair<String>, review: Reviewed<bool> },
Annotation { keys: KeyPair<AnnotationKey>, review: Reviewed<Option<MessageValue>> },
Binding { keys: KeyPair<Id>, review: Reviewed<Option<LifelineBinding>> },
Child { keys: KeyPair<Id>, review: Reviewed<bool> },
Incident { keys: KeyPair<Id>, end: IncidentEnd, review: Reviewed<bool> },
Artifact { keys: KeyPair<Id>, review: ArtifactReview },
}
impl FacetReview {
pub fn selector(&self) -> FacetSelector {
match self {
$( Self::$family(value) => FacetSelector::$family(match value {
$( $review::$leaf(_) => $selector::$leaf, )*
}), )*
Self::Member { collection, keys, .. } => FacetSelector::Member { collection: *collection, keys: keys.clone() },
Self::Order { collection, .. } => FacetSelector::Order(*collection),
Self::Literal { keys, .. } => FacetSelector::Literal(keys.clone()),
Self::Annotation { keys, .. } => FacetSelector::Annotation(keys.clone()),
Self::Binding { keys, .. } => FacetSelector::Binding(keys.clone()),
Self::Child { keys, .. } => FacetSelector::Child(keys.clone()),
Self::Incident { keys, end, .. } => FacetSelector::Incident { keys: keys.clone(), end: *end },
Self::Artifact { keys, review } => FacetSelector::Artifact { keys: keys.clone(), facet: review.facet() },
}
}
}
};
}
scalar_families!(define_families);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ReferenceCollection {
Steps,
FragmentBranches,
FunctionInputItems,
FunctionOutputItems,
SignalItems,
ExchangeItems,
TransitionTriggers,
TransitionEffects,
Constrained,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum OrderedCollection {
Steps,
FragmentBranches,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum IncidentEnd {
Source,
Target,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ArtifactFacet {
Presence,
Name,
Description,
Owner,
Relation,
Uri,
Revision,
Fragment,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ArtifactReview {
Presence(Reviewed<bool>),
Name(Reviewed<String>),
Description(Reviewed<String>),
Owner(Reviewed<Id>),
Relation(Reviewed<ArtifactRelation>),
Uri(Reviewed<String>),
Revision(Reviewed<Option<String>>),
Fragment(Reviewed<Option<String>>),
}
impl ArtifactReview {
pub fn facet(&self) -> ArtifactFacet {
match self {
Self::Presence(_) => ArtifactFacet::Presence,
Self::Name(_) => ArtifactFacet::Name,
Self::Description(_) => ArtifactFacet::Description,
Self::Owner(_) => ArtifactFacet::Owner,
Self::Relation(_) => ArtifactFacet::Relation,
Self::Uri(_) => ArtifactFacet::Uri,
Self::Revision(_) => ArtifactFacet::Revision,
Self::Fragment(_) => ArtifactFacet::Fragment,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SemanticBaseline {
pub facets: Vec<FacetReview>,
}
-17
View File
@@ -114,7 +114,6 @@ pub struct PreparedCommand {
struct IdentityProvenance {
original: BTreeSet<Id>,
current_to_original: BTreeMap<Id, Id>,
explicit_replacement: bool,
}
impl IdentityProvenance {
@@ -124,12 +123,10 @@ impl IdentityProvenance {
Self {
original,
current_to_original,
explicit_replacement: false,
}
}
fn replace(&mut self, project: &Project) {
self.explicit_replacement = true;
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
self.current_to_original = self
.original
@@ -183,13 +180,6 @@ pub fn prepare_command(
)?;
crate::references::finish_deletions(&mut candidate, &pending_deletions);
provenance.retain_present(&candidate);
if !provenance.explicit_replacement {
crate::data::prune_removed_inheritance(
project,
&mut candidate,
&provenance.current_to_original,
);
}
validate_candidate(&candidate)?;
Ok(PreparedCommand {
project: candidate,
@@ -297,13 +287,6 @@ pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project,
)?;
}
crate::references::finish_deletions(&mut candidate, &pending_deletions);
if !provenance.explicit_replacement {
crate::data::prune_removed_inheritance(
project,
&mut candidate,
&provenance.current_to_original,
);
}
validate_candidate(&candidate)?;
Ok(candidate)
}
+1 -1
View File
@@ -999,7 +999,7 @@ mod transaction_tests {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
)
.unwrap();
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
+21 -97
View File
@@ -1,11 +1,9 @@
//! Pure, bounded source projects. All includes resolve against supplied UTF-8
//! Pure, bounded source3 projects. All includes resolve against supplied UTF-8
//! documents; this module never reads a filesystem, URL, or process directory.
use crate::{Id, Kind, Project, SCHEMA_VERSION, language, persistence::DecodeResult, validation};
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_migration.rs"]
mod source_migration;
#[path = "source_project/source_structure.rs"]
mod source_structure;
@@ -280,24 +278,21 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
}
}
/// Compile reachable source3/4/5 files, explicitly upgrade contracts, and stabilize UUIDs.
/// Compile all reachable files as one source3 model and stabilize omitted UUIDs.
/// Missing/unreachable files and every admission or engineering error leave the
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
pub fn compile_bundle(
bundle: &SourceBundle,
limits: ProjectLimits,
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
let (mut compiled, insertions) = compile_once(bundle, limits)?;
if !insertions.is_empty() {
let mut stable = bundle.clone();
apply_edits(&mut stable, insertions)?;
// Re-index actual returned bytes before performing the schema upgrade.
compiled = compile_once(&stable, limits)?.0;
let (compiled, insertions) = compile_once(bundle, limits)?;
if insertions.is_empty() {
return Ok(compiled);
}
if compiled.decoded.migration.is_some() {
return source_migration::upgrade(&compiled, limits);
}
Ok(compiled)
let mut stable = bundle.clone();
apply_edits(&mut stable, insertions)?;
// Re-index the actual returned bytes. Never expose offsets into pre-ID text.
compile_once(&stable, limits).map(|(compiled, _)| compiled)
}
fn compile_once(
@@ -573,7 +568,7 @@ fn compile_once(
span,
}),
};
let decoded =
let project =
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
.map_err(|(index, error)| {
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
@@ -583,7 +578,7 @@ fn compile_once(
related: Vec::new(),
}]
})?;
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
let diagnostics = model_diagnostics(bundle, &index, &project);
let errors: Vec<_> = diagnostics
.iter()
.filter(|issue| issue.diagnostic.severity == validation::Severity::Error)
@@ -595,7 +590,10 @@ fn compile_once(
Ok((
CompiledSourceProject {
bundle: bundle.clone(),
decoded,
decoded: DecodeResult {
project,
migration: None,
},
index,
diagnostics,
},
@@ -720,7 +718,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
Ok(())
}
/// Canonical, readable current-version export. Like `language::format`, the caller is
/// Canonical, readable source3 export. Like `language::format`, the caller is
/// responsible for supplying a valid model; compilation checks the result.
/// Existing source layout is preserved by `update_bundle`, not this formatter.
pub fn format_bundle(project: &Project) -> SourceBundle {
@@ -736,7 +734,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
.push_str(&unindent(&declaration));
}
let mut entry = format!(
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
"arcadia 3;\n{}{{\n description {};\n",
project_header(project),
quote(&project.description)
);
@@ -925,8 +923,6 @@ pub fn update_bundle_with_identity_map(
after: new_text,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
identity_origins: Some(&origins),
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
@@ -955,8 +951,6 @@ pub fn update_bundle_with_identity_map(
after: &after,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
identity_origins: Some(&origins),
};
let mut local = Vec::new();
update.header(
@@ -1125,10 +1119,6 @@ struct SyntaxUpdate<'a> {
after: &'a str,
old_aliases: &'a BTreeMap<String, String>,
new_aliases: &'a BTreeMap<String, String>,
// Source3 migration only adds path qualification; the existing field identity
// must keep its authored argument trivia during that one-time transformation.
legacy_value_paths: bool,
identity_origins: Option<&'a BTreeMap<Id, Id>>,
}
impl SyntaxUpdate<'_> {
fn header(
@@ -1224,18 +1214,6 @@ impl SyntaxUpdate<'_> {
alias.clone(),
));
}
(EditKind::HistoricalUuid(_), EditKind::HistoricalUuid(id)) => {
let token = significant(&self.source[actual.span.start..actual.span.end])
.pop()
.expect("historical UUID string");
edits.push((
Span {
start: actual.span.start + token.span.start,
end: actual.span.start + token.span.end,
},
quote(&id.to_string()),
));
}
(EditKind::Atom, EditKind::Atom) => edits.push((
actual.span,
self.after[after.span.start..after.span.end].into(),
@@ -1360,27 +1338,11 @@ impl SyntaxUpdate<'_> {
let previous = before.unwrap_or(&[]);
let old_keys: Option<Vec<_>> = previous
.iter()
.map(|item| {
item_key(
item,
self.before,
self.old_aliases,
self.legacy_value_paths,
None,
)
})
.map(|item| item_key(item, self.before, self.old_aliases))
.collect();
let new_keys: Option<Vec<_>> = after
.iter()
.map(|item| {
item_key(
item,
self.after,
self.new_aliases,
self.legacy_value_paths,
self.identity_origins,
)
})
.map(|item| item_key(item, self.after, self.new_aliases))
.collect();
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
old.iter().collect::<BTreeSet<_>>().len() == old.len()
@@ -1487,23 +1449,9 @@ fn item_key(
value: &language::EditValue,
source: &str,
aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
origins: Option<&BTreeMap<Id, Id>>,
) -> Option<String> {
use language::EditKind;
if matches!(value.kind, EditKind::Record(_))
&& let Some(key) = language::semantic_row_key(
&source[value.span.start..value.span.end],
origins.unwrap_or(&BTreeMap::new()),
)
{
return Some(key);
}
match &value.kind {
EditKind::HistoricalUuid(id) => Some(format!(
"uuid:{}",
origins.and_then(|map| map.get(id)).unwrap_or(id)
)),
EditKind::Reference(alias) => aliases.get(alias).map(|id| format!("ref:{id}")),
EditKind::Atom => Some(format!(
"literal:{}",
@@ -1514,31 +1462,7 @@ fn item_key(
.find_map(|key| {
fields.get(key).and_then(|field| {
if let EditKind::Reference(alias) = &field.value.kind {
aliases.get(alias).map(|id| {
if key == "field" && !legacy_value_paths {
let path = fields
.get("path")
.and_then(|field| {
if let EditKind::List(path) = &field.value.kind {
path.iter()
.map(|hop| {
if let EditKind::Reference(alias) = &hop.kind {
aliases.get(alias).cloned()
} else {
None
}
})
.collect::<Option<Vec<_>>>()
} else {
None
}
})
.unwrap_or_default();
format!("field:{}:{id}", path.join("/"))
} else {
format!("{key}:{id}")
}
})
aliases.get(alias).map(|id| format!("{key}:{id}"))
} else {
None
}
@@ -1,127 +0,0 @@
//! Source3/4 upgrade through token edits; authored modules, nesting and trivia survive.
use super::*;
pub(super) fn upgrade(
current: &CompiledSourceProject,
limits: ProjectLimits,
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
let report = current
.decoded
.migration
.as_ref()
.expect("an explicit source migration report");
let project = &current.decoded.project;
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
let changed: BTreeSet<_> = report
.semantic_defaults
.iter()
.filter(|default| default.field != "baseline.semantic")
.map(|default| default.object)
.collect();
let aliases: BTreeMap<_, _> = project
.identities()
.into_iter()
.map(|(id, alias)| (alias.to_owned(), id.to_string()))
.collect();
let mut edits: Edits = BTreeMap::new();
for (path, source) in &current.bundle.files {
let declarations =
language::edit_declarations(source, path == &current.bundle.entry, limits.model)
.map_err(|diagnostic| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *diagnostic,
related: vec![],
}]
})?;
for (id, actual) in declarations {
if !changed.contains(&id) {
continue;
}
let Some(after) = canonical.get(&id) else {
continue;
};
let mut next = language::edit_declaration(after).map_err(|diagnostic| {
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: *diagnostic,
related: vec![],
}]
})?;
if actual.lexical_owner.is_some()
&& let language::EditKind::Record(fields) = &actual.fields.kind
&& !fields.contains_key("parent")
&& let language::EditKind::Record(fields) = &mut next.fields.kind
{
fields.remove("parent");
}
let update = SyntaxUpdate {
source,
before: source,
after,
old_aliases: &aliases,
new_aliases: &aliases,
legacy_value_paths: true,
identity_origins: None,
};
update.value(
&actual.fields,
Some(&actual.fields),
&next.fields,
edits.entry(path.clone()).or_default(),
);
}
}
let source = &current.bundle.files[&current.bundle.entry];
let version = significant(source)
.get(1)
.map(|token| token.span)
.expect("a parsed version token");
edits
.entry(current.bundle.entry.clone())
.or_default()
.push((version, crate::SCHEMA_VERSION.to_string()));
// New allocation relations are emitted into the allocating interface's
// existing module, outside its lexical element body. Existing source never moves.
let mut added: BTreeMap<String, String> = BTreeMap::new();
for allocation in &report.generated_interface_allocations {
let path = current
.index
.declarations
.get(&allocation.source)
.map(|location| location.path.clone())
.unwrap_or_else(|| current.bundle.entry.clone());
let ending = newline(&current.bundle.files[&path]);
let text = added.entry(path).or_default();
text.push_str(ending);
text.push_str(&canonical[&allocation.id].replace('\n', ending));
}
for (path, text) in added {
let offset = if path == current.bundle.entry {
current.index.project_body.span.end
} else {
current.bundle.files[&path].len()
};
edits.entry(path).or_default().push((
Span {
start: offset,
end: offset,
},
text,
));
}
let mut bundle = current.bundle.clone();
apply_edits(&mut bundle, edits)?;
let (mut result, insertions) = compile_once(&bundle, limits)?;
if !insertions.is_empty() || result.decoded.project != *project {
return Err(vec![problem(
&bundle,
&bundle.entry,
Span::default(),
"migration_source_mismatch",
"Targeted source upgrade did not reproduce the migrated project.",
)]);
}
result.decoded.migration = Some(report.clone());
Ok(result)
}
+2 -53
View File
@@ -202,15 +202,9 @@ fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
return None;
}
if let Some(shared) = e.shared()
&& (kind == e.kind() || (kind.is_state() && e.kind().is_state()))
&& kind == e.kind()
{
let mut shared = shared.clone();
if let SharedBody::State {
kind: state_kind, ..
} = &mut shared
{
*state_kind = kind.state_kind()?;
}
if let SharedBody::Scenario { viewpoint, .. } = &mut shared {
*viewpoint = match (*viewpoint, layer) {
(ScenarioViewpoint::OperationalActivity, Layer::Sa) => {
@@ -257,8 +251,6 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
}
(
RelationBody::Allocation { .. }
| RelationBody::InterfaceItemAllocation { .. }
| RelationBody::DataAssociation { .. }
| RelationBody::ExchangeAllocation { .. }
| RelationBody::CapabilityInvolvement { .. }
| RelationBody::CapabilityInclude { .. }
@@ -395,30 +387,11 @@ pub fn plan_transition(
commands: vec![],
};
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
let mut omitted = if request.direction == TransitionDirection::SaToOa {
let omitted = if request.direction == TransitionDirection::SaToOa {
sa_port_omissions(project)
} else {
BTreeSet::new()
};
// An explicit existing Legacy counterpart can be compared and reviewed
// without inventing an OA typed port. Only this port is retained: dependent
// SA implementation records and new counterpart requests stay omitted.
for mapping in &request.mappings {
if project
.elements
.get(&mapping.source)
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
&& mapping
.target
.and_then(|target| project.elements.get(&target))
.is_some_and(|e| {
e.layer() == Layer::Oa
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
})
{
omitted.remove(&mapping.source);
}
}
let mut reported_omissions = BTreeSet::new();
if selected.len() != request.sources.len() {
conflict(
@@ -448,24 +421,6 @@ pub fn plan_transition(
}
if let Some(e) = project.elements.get(&id) {
selected.extend(crate::references::element_references(e));
// Allocations are owned by interfaces. Independent data
// associations travel only when both endpoint types are
// already required; they do not expand the dictionary.
if matches!(e.kind(), Kind::Interface | Kind::DataType) {
let contracts: Vec<_> = project
.relations
.values()
.filter_map(|r| {
((r.kind() == RelationKind::InterfaceItemAllocation
&& r.source() == id)
|| (r.kind() == RelationKind::DataAssociation
&& selected.contains(&r.source())
&& selected.contains(&r.target())))
.then_some(r.id)
})
.collect();
selected.extend(contracts);
}
// Contextual links are owned by the process/chain shared by
// their endpoint involvements, independently of list order.
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
@@ -952,7 +907,6 @@ pub fn plan_transition(
.unwrap_or(TraceBaseline {
oa: current_oa.clone(),
sa: current_sa.clone(),
semantic: None,
});
for field in &reviewed_fields {
set(&mut baseline.oa, *field, value(&current_oa, *field).clone());
@@ -1107,11 +1061,6 @@ pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Ki
}
fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool {
// State kind is a reviewed structural facet of one state family. An existing
// counterpart remains selectable after either side changes its vertex kind.
if source.is_state() && target.is_state() {
return true;
}
if matches!(
(direction, source, target),
(
+94 -133
View File
@@ -388,10 +388,68 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
if let Some(shared) = e.shared() {
match shared {
SharedBody::DataType { .. }
| SharedBody::DataField { .. }
| SharedBody::Interface { .. }
| SharedBody::ExchangeItem { .. } => crate::data::validate_element(p, e, &mut out),
SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals },
} => {
let mut seen = BTreeSet::new();
for literal in literals {
text(&mut out, e.id, "literals", literal);
if !seen.insert(literal) {
error(
&mut out,
e.id,
"duplicate_literal",
"literals",
"Enumeration literals must be unique.",
);
}
}
}
SharedBody::DataField {
type_ref,
minimum,
maximum,
unit,
} => {
if maximum.is_some_and(|max| max < *minimum) {
error(
&mut out,
e.id,
"invalid_multiplicity",
"maximum",
"Maximum multiplicity must be at least minimum, or unbounded.",
);
}
text(&mut out, e.id, "unit", unit);
if let TypeRef::Named(id) = type_ref {
expect_element(p, &mut out, e.id, *id, "type_ref", |k| k == Kind::DataType);
}
if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id))
&& parent.kind() == Kind::DataType
&& !matches!(
parent.shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Record
})
)
{
error(
&mut out,
e.id,
"field_owner",
"parent",
"Fields under a data type require a Record type.",
);
}
}
SharedBody::Interface { items } => {
check_ids(&mut out, e.id, "items", items);
for id in items {
expect_element(p, &mut out, e.id, *id, "items", |k| {
k == Kind::ExchangeItem
});
}
}
SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out),
SharedBody::Scenario {
viewpoint,
@@ -585,11 +643,9 @@ pub fn validate(p: &Project) -> Vec<Issue> {
text(&mut out, r.id, "name", &r.name);
text(&mut out, r.id, "description", &r.description);
validate_relation(p, r, &mut out);
crate::data::validate_relation(p, r, &mut out);
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
validate_relation_context(p, r, &mut out);
}
crate::data::validate_record_cycles(p, &mut out);
for (key, d) in &p.diagrams {
if *key != d.id {
error(
@@ -876,19 +932,6 @@ pub fn validate(p: &Project) -> Vec<Issue> {
text(&mut out, t.id, "baseline.name", &props.name);
text(&mut out, t.id, "baseline.description", &props.description);
}
if let Some(semantic) = &b.semantic
&& let Err(errors) = crate::semantic::validate_semantic_baseline(semantic)
{
for message in errors {
error(
&mut out,
t.id,
"semantic_baseline",
"baseline.semantic",
message,
);
}
}
}
match crate::transitions::trace_drift(p, t) {
crate::transitions::Drift::Unchanged => {}
@@ -1393,10 +1436,23 @@ fn enclosing_loops(p: &Project, occurrence: Id) -> BTreeSet<Id> {
}
/// Fields carried by an exchange, including fields of nested named records.
pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet<Id> {
crate::data::carried_field_paths(p, relation)
.into_iter()
.map(|f| f.field)
.collect()
let mut pending = relation.body.items().to_vec();
let mut seen = BTreeSet::new();
let mut fields = BTreeSet::new();
while let Some(owner) = pending.pop() {
if !seen.insert(owner) {
continue;
}
for field in p.children(owner) {
if let Some(SharedBody::DataField { type_ref, .. }) = field.shared() {
fields.insert(field.id);
if let TypeRef::Named(id) = type_ref {
pending.push(*id);
}
}
}
}
fields
}
fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool {
match body {
@@ -1627,125 +1683,36 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
}
}
}
let relation = exchange.and_then(|id| p.relations.get(&id));
if message_kind == &MessageKind::Call
&& relation.is_some_and(|r| {
!r.body
.items()
.iter()
.any(|id| crate::data::item_mechanism(p, *id) == Some(ExchangeMechanism::Operation))
})
{
warning(
out,
e.id,
"call_without_operation",
"exchange",
"This generic call carries no Operation item; define an operation contract when appropriate.",
);
}
let allowed = exchange
.and_then(|id| p.relations.get(&id))
.map(|r| carried_fields(p, r));
let mut fields = BTreeSet::new();
for value in values {
expect_element(p, out, e.id, value.field, "values.field", |kind| {
kind == Kind::DataField
});
for hop in &value.path {
expect_element(p, out, e.id, *hop, "values.path", |kind| {
kind == Kind::DataField
});
}
text(out, e.id, "values.expression", &value.expression);
if !fields.insert((&value.path, value.field)) {
if !fields.insert(value.field) {
error(
out,
e.id,
"duplicate_message_value",
"values",
"Each qualified carried field has at most one contextual value annotation.",
"Each carried field has at most one contextual value annotation in a message.",
);
}
if value.path.len() > crate::data::MAX_VALUE_PATH_DEPTH {
if allowed
.as_ref()
.is_some_and(|allowed| !allowed.contains(&value.field))
{
error(
out,
e.id,
"message_value_path_limit",
"values.path",
"The value path exceeds the bounded authoring depth.",
"message_value_field",
"values.field",
"Value annotations must reference fields carried by this message's exchange, including nested record fields.",
);
}
if let Some(r) = relation {
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
let permitted = match message_kind {
MessageKind::Call => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::In
| ParameterDirection::InOut
),
MessageKind::Reply => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::Out
| ParameterDirection::InOut
| ParameterDirection::Return
),
_ => true,
};
if !permitted {
error(
out,
e.id,
"message_parameter_phase",
"values.path",
"Call values address In/InOut parameters; replies address Out/InOut/Return parameters.",
);
}
if path.direction == ParameterDirection::Unspecified {
warning(
out,
e.id,
"message_parameter_direction",
"values.path",
"The containing operation parameter has no direction yet.",
);
}
}
} else {
let legacy = value
.path
.is_empty()
.then(|| crate::data::resolve_legacy_field_path(p, r, value.field).ok())
.flatten();
if matches!(
legacy,
Some(
crate::data::LegacyFieldPathResolution::Unique(_)
| crate::data::LegacyFieldPathResolution::Ambiguous
)
) {
warning(
out,
e.id,
if legacy == Some(crate::data::LegacyFieldPathResolution::Ambiguous) {
"message_value_ambiguous_path"
} else {
"message_value_missing_path"
},
"values.path",
"This legacy nested field annotation needs an explicit containing parameter path; its expression is preserved.",
);
} else {
error(
out,
e.id,
"message_value_field",
"values.path",
"The qualified field must be carried by the exchange, following Value record fields only.",
);
}
}
}
if value.expression.trim().is_empty() {
warning(
out,
@@ -2163,11 +2130,7 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
};
if a.layer() != b.layer()
|| !r.kind().permits(a.kind(), b.kind())
|| (a.id == b.id
&& !matches!(
r.kind(),
RelationKind::StateTransition | RelationKind::DataAssociation
))
|| (a.id == b.id && r.kind() != RelationKind::StateTransition)
{
error(
out,
@@ -2418,10 +2381,8 @@ pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option<Id
owner.layer() == layer
&& parent_permits(owner.kind(), child_kind)
&& match owner.shared() {
Some(SharedBody::DataType { definition, .. })
if child_kind == Kind::DataField =>
{
matches!(definition, DataTypeDefinition::Record { .. })
Some(SharedBody::DataType { definition }) if child_kind == Kind::DataField => {
*definition == DataTypeDefinition::Record
}
Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => {
matches!(
-38
View File
@@ -1,38 +0,0 @@
// Frozen authored schema3 compatibility input. Do not rewrite as current syntax.
arcadia 3;
project legacy_data "Legacy data contract" id "00000000-0000-0000-0000-000000000001" {
sa_function produce "Produce" id "00000000-0000-0000-0000-000000000002" {}
sa_function consume "Consume" id "00000000-0000-0000-0000-000000000003" {}
sa_exchange_item packet "Position event" id "00000000-0000-0000-0000-000000000004" {
kind Event;
// Reused record parameters retain their own identities.
sa_data_field origin "Origin" id "00000000-0000-0000-0000-000000000007" { type_ref { Named @coordinate; }; }
sa_data_field destination "Destination" id "00000000-0000-0000-0000-000000000008" { type_ref { Named @coordinate; }; }
}
sa_data_type coordinate "Coordinate" id "00000000-0000-0000-0000-000000000005" {
definition Record;
sa_data_field latitude "Latitude" id "00000000-0000-0000-0000-000000000006" { type_ref Real; unit "deg"; }
}
sa_interface sender_contract "Sender contract" id "00000000-0000-0000-0000-000000000009" { items [@packet]; }
sa_interface receiver_contract "Receiver contract" id "00000000-0000-0000-0000-00000000000a" { items [@packet]; }
sa_port output "Output" id "00000000-0000-0000-0000-00000000000b" {
parent @produce; contract { FunctionOutput { outgoing_items [@packet]; }; }; legacy none;
}
sa_port input "Input" id "00000000-0000-0000-0000-00000000000c" {
parent @consume; contract { FunctionInput { incoming_items [@packet]; }; }; legacy none;
}
functional_exchange position_exchange "Position exchange" id "00000000-0000-0000-0000-00000000000d" {
source @produce; target @consume; source_port @output; target_port @input; items [@packet];
}
sa_scenario scenario "Position scenario" id "00000000-0000-0000-0000-00000000000e" {
viewpoint Functional; steps [@message];
sa_lifeline sender "Sender" id "00000000-0000-0000-0000-00000000000f" { represented @produce; }
sa_lifeline receiver "Receiver" id "00000000-0000-0000-0000-000000000010" { represented @consume; }
sa_occurrence message "Position" id "00000000-0000-0000-0000-000000000011" {
body { Exchange {
exchange @position_exchange; sender @sender; receiver @receiver;
values [{ field @latitude; expression "navigation.latitude"; }];
}; };
}
}
}
-312
View File
@@ -1,312 +0,0 @@
{
"schema_version": 3,
"id": "00000000-0000-0000-0000-000000000001",
"alias": "legacy_data",
"name": "Legacy data contract",
"description": "",
"elements": {
"00000000-0000-0000-0000-000000000002": {
"id": "00000000-0000-0000-0000-000000000002",
"alias": "produce",
"name": "Produce",
"description": "",
"parent": null,
"body": {
"Sa": "Function"
}
},
"00000000-0000-0000-0000-000000000003": {
"id": "00000000-0000-0000-0000-000000000003",
"alias": "consume",
"name": "Consume",
"description": "",
"parent": null,
"body": {
"Sa": "Function"
}
},
"00000000-0000-0000-0000-000000000004": {
"id": "00000000-0000-0000-0000-000000000004",
"alias": "packet",
"name": "Position event",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"ExchangeItem": {
"kind": "Event"
}
}
}
}
},
"00000000-0000-0000-0000-000000000005": {
"id": "00000000-0000-0000-0000-000000000005",
"alias": "coordinate",
"name": "Coordinate",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"DataType": {
"definition": "Record"
}
}
}
}
},
"00000000-0000-0000-0000-000000000006": {
"id": "00000000-0000-0000-0000-000000000006",
"alias": "latitude",
"name": "Latitude",
"description": "",
"parent": "00000000-0000-0000-0000-000000000005",
"body": {
"Sa": {
"Shared": {
"DataField": {
"type_ref": "Real",
"minimum": 1,
"maximum": 1,
"unit": "deg"
}
}
}
}
},
"00000000-0000-0000-0000-000000000007": {
"id": "00000000-0000-0000-0000-000000000007",
"alias": "origin",
"name": "Origin",
"description": "",
"parent": "00000000-0000-0000-0000-000000000004",
"body": {
"Sa": {
"Shared": {
"DataField": {
"type_ref": {
"Named": "00000000-0000-0000-0000-000000000005"
},
"minimum": 1,
"maximum": 1,
"unit": ""
}
}
}
}
},
"00000000-0000-0000-0000-000000000008": {
"id": "00000000-0000-0000-0000-000000000008",
"alias": "destination",
"name": "Destination",
"description": "",
"parent": "00000000-0000-0000-0000-000000000004",
"body": {
"Sa": {
"Shared": {
"DataField": {
"type_ref": {
"Named": "00000000-0000-0000-0000-000000000005"
},
"minimum": 1,
"maximum": 1,
"unit": ""
}
}
}
}
},
"00000000-0000-0000-0000-000000000009": {
"id": "00000000-0000-0000-0000-000000000009",
"alias": "sender_contract",
"name": "Sender contract",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"Interface": {
"items": [
"00000000-0000-0000-0000-000000000004"
]
}
}
}
}
},
"00000000-0000-0000-0000-00000000000a": {
"id": "00000000-0000-0000-0000-00000000000a",
"alias": "receiver_contract",
"name": "Receiver contract",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"Interface": {
"items": [
"00000000-0000-0000-0000-000000000004"
]
}
}
}
}
},
"00000000-0000-0000-0000-00000000000b": {
"id": "00000000-0000-0000-0000-00000000000b",
"alias": "output",
"name": "Output",
"description": "",
"parent": "00000000-0000-0000-0000-000000000002",
"body": {
"Sa": {
"Shared": {
"Port": {
"contract": {
"FunctionOutput": {
"outgoing_items": [
"00000000-0000-0000-0000-000000000004"
]
}
},
"legacy": null
}
}
}
}
},
"00000000-0000-0000-0000-00000000000c": {
"id": "00000000-0000-0000-0000-00000000000c",
"alias": "input",
"name": "Input",
"description": "",
"parent": "00000000-0000-0000-0000-000000000003",
"body": {
"Sa": {
"Shared": {
"Port": {
"contract": {
"FunctionInput": {
"incoming_items": [
"00000000-0000-0000-0000-000000000004"
]
}
},
"legacy": null
}
}
}
}
},
"00000000-0000-0000-0000-00000000000e": {
"id": "00000000-0000-0000-0000-00000000000e",
"alias": "scenario",
"name": "Position scenario",
"description": "",
"parent": null,
"body": {
"Sa": {
"Shared": {
"Scenario": {
"viewpoint": "Functional",
"context": null,
"precondition": "",
"postcondition": "",
"steps": [
"00000000-0000-0000-0000-000000000011"
]
}
}
}
}
},
"00000000-0000-0000-0000-00000000000f": {
"id": "00000000-0000-0000-0000-00000000000f",
"alias": "sender",
"name": "Sender",
"description": "",
"parent": "00000000-0000-0000-0000-00000000000e",
"body": {
"Sa": {
"Shared": {
"Lifeline": {
"represented": "00000000-0000-0000-0000-000000000002"
}
}
}
}
},
"00000000-0000-0000-0000-000000000010": {
"id": "00000000-0000-0000-0000-000000000010",
"alias": "receiver",
"name": "Receiver",
"description": "",
"parent": "00000000-0000-0000-0000-00000000000e",
"body": {
"Sa": {
"Shared": {
"Lifeline": {
"represented": "00000000-0000-0000-0000-000000000003"
}
}
}
}
},
"00000000-0000-0000-0000-000000000011": {
"id": "00000000-0000-0000-0000-000000000011",
"alias": "message",
"name": "Position",
"description": "",
"parent": "00000000-0000-0000-0000-00000000000e",
"body": {
"Sa": {
"Shared": {
"Occurrence": {
"body": {
"Exchange": {
"exchange": "00000000-0000-0000-0000-00000000000d",
"sender": "00000000-0000-0000-0000-00000000000f",
"receiver": "00000000-0000-0000-0000-000000000010",
"message_kind": "Asynchronous",
"reply_to": null,
"values": [
{
"field": "00000000-0000-0000-0000-000000000006",
"expression": "navigation.latitude"
}
]
}
},
"precondition": "",
"postcondition": ""
}
}
}
}
}
},
"relations": {
"00000000-0000-0000-0000-00000000000d": {
"id": "00000000-0000-0000-0000-00000000000d",
"alias": "position_exchange",
"name": "Position exchange",
"description": "",
"body": {
"FunctionalExchange": {
"source": "00000000-0000-0000-0000-000000000002",
"target": "00000000-0000-0000-0000-000000000003",
"source_port": "00000000-0000-0000-0000-00000000000b",
"target_port": "00000000-0000-0000-0000-00000000000c",
"items": [
"00000000-0000-0000-0000-000000000004"
]
}
}
}
},
"diagrams": {},
"artifact_links": {},
"traces": {}
}
-27
View File
@@ -1,27 +0,0 @@
// Frozen schema4 input: reviewed prose is deliberately different from live prose.
arcadia 4;
project reviewed_prose "Reviewed dimensions" id "40000000-0000-4000-8000-000000000001" {
description "Preserve the original review without inventing semantic history.";
oa_data_type oa_dimension "OA dimension now" id "40000000-0000-4000-8000-000000000002" {
description "Unreviewed OA description";
definition { Integer { minimum 0; maximum 100; }; };
default { Integer 20; };
unit "m";
}
sa_data_type sa_dimension "SA dimension now" id "40000000-0000-4000-8000-000000000003" {
description "Unreviewed SA description";
definition { Real { minimum 0.0; maximum 100.0; }; };
default { Real 20.0; };
unit "ft";
}
trace dimension_review "Dimension rationale" id "40000000-0000-4000-8000-000000000004" {
description "The target unit and representation are intentional.";
oa @oa_dimension;
sa @sa_dimension;
kind Item;
baseline {
oa { name "Approved OA name"; description "Approved OA\nprose"; };
sa { name "Approved SA name"; description "Approved SA prose"; };
};
}
}
-51
View File
@@ -1,51 +0,0 @@
{
"schema_version": 4,
"id": "40000000-0000-4000-8000-000000000001",
"alias": "reviewed_prose",
"name": "Reviewed dimensions",
"description": "Preserve the original review without inventing semantic history.",
"elements": {
"40000000-0000-4000-8000-000000000002": {
"id": "40000000-0000-4000-8000-000000000002",
"alias": "oa_dimension",
"name": "OA dimension now",
"description": "Unreviewed OA description",
"parent": null,
"body": {"Oa": {"Shared": {"DataType": {
"definition": {"Integer": {"minimum": 0, "maximum": 100}},
"default": {"Integer": 20},
"unit": "m"
}}}}
},
"40000000-0000-4000-8000-000000000003": {
"id": "40000000-0000-4000-8000-000000000003",
"alias": "sa_dimension",
"name": "SA dimension now",
"description": "Unreviewed SA description",
"parent": null,
"body": {"Sa": {"Shared": {"DataType": {
"definition": {"Real": {"minimum": 0.0, "maximum": 100.0}},
"default": {"Real": 20.0},
"unit": "ft"
}}}}
}
},
"relations": {},
"diagrams": {},
"artifact_links": {},
"traces": {
"40000000-0000-4000-8000-000000000004": {
"id": "40000000-0000-4000-8000-000000000004",
"alias": "dimension_review",
"name": "Dimension rationale",
"description": "The target unit and representation are intentional.",
"oa": "40000000-0000-4000-8000-000000000002",
"sa": "40000000-0000-4000-8000-000000000003",
"kind": "Item",
"baseline": {
"oa": {"name": "Approved OA name", "description": "Approved OA\nprose"},
"sa": {"name": "Approved SA name", "description": "Approved SA prose"}
}
}
}
}
+9 -103
View File
@@ -168,20 +168,6 @@ project mission "Harbor safety — 港 🌊" {
.find(|e| e.alias == "event_record")
.unwrap()
.id;
let associated = p
.add_element(Kind::DataType, Layer::Sa, "Associated record", None)
.unwrap();
if let Some(SharedBody::DataType { definition, .. }) =
p.elements.get_mut(&associated).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Record { supertype: None };
}
p.relate(
RelationKind::DataAssociation,
"Related record",
record,
associated,
);
for (index, type_ref) in [
TypeRef::Boolean,
TypeRef::Integer,
@@ -204,30 +190,18 @@ project mission "Harbor safety — 港 🌊" {
minimum: 1,
maximum: Some(5),
unit: "unit".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
}
for definition in [
DataTypeDefinition::Boolean,
DataTypeDefinition::Integer {
minimum: None,
maximum: None,
},
DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
DataTypeDefinition::Integer,
DataTypeDefinition::String,
] {
let id = p
.add_element(Kind::DataType, Layer::Sa, "Primitive type", None)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition,
default: None,
unit: String::new(),
};
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
SharedBody::DataType { definition };
}
for kind in [
ExchangeItemKind::Material,
@@ -242,11 +216,7 @@ project mission "Harbor safety — 港 🌊" {
None,
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind,
mechanism: ExchangeMechanism::Unspecified,
supertype: None,
};
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind };
}
let scenario = p
.elements
@@ -491,7 +461,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
#[test]
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
for (source, expected) in [
("arcadia 6; project mission \"x\" {}", "Unsupported schema"),
("arcadia 4; project mission \"x\" {}", "Unsupported schema"),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
"Unknown field",
@@ -795,27 +765,9 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
assert_eq!(from_source, from_json);
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
let mut retained = serde_json::to_value(&from_json).unwrap();
assert_eq!(retained["schema_version"], 5);
assert_eq!(retained["schema_version"], 3);
retained["schema_version"] = 2.into();
let allocations: Vec<_> = from_json
.relations
.values()
.filter_map(|relation| {
if let RelationBody::InterfaceItemAllocation {
source,
target,
order,
..
} = relation.body
{
Some((source, target, order))
} else {
None
}
})
.collect();
for element in retained["elements"].as_object_mut().unwrap().values_mut() {
let element_id: Id = element["id"].as_str().unwrap().parse().unwrap();
for layer in ["Oa", "Sa"] {
let Some(shared) = element["body"]
.get_mut(layer)
@@ -827,46 +779,6 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
assert_eq!(port["contract"], "Legacy");
*port = port["legacy"].clone();
}
if let Some(item) = shared
.get_mut("ExchangeItem")
.and_then(serde_json::Value::as_object_mut)
{
if item["mechanism"] == "Event" {
item.insert("kind".into(), "Event".into());
}
item.remove("mechanism");
item.remove("supertype");
}
if let Some(kind) = shared
.get_mut("DataType")
.and_then(serde_json::Value::as_object_mut)
{
kind.remove("default");
kind.remove("unit");
let definition = &kind["definition"];
if let Some(name) = ["Integer", "Real", "String", "Record"]
.into_iter()
.find(|name| definition.get(name).is_some())
{
kind.insert("definition".into(), name.into());
}
}
if let Some(field) = shared
.get_mut("DataField")
.and_then(serde_json::Value::as_object_mut)
{
for key in ["direction", "storage", "default"] {
field.remove(key);
}
}
if let Some(interface) = shared.get_mut("Interface") {
let mut entries: Vec<_> = allocations
.iter()
.filter(|(source, _, _)| *source == element_id)
.collect();
entries.sort_by_key(|(_, _, order)| *order);
*interface = serde_json::json!({"items":entries.into_iter().map(|(_,target,_)|target).collect::<Vec<_>>()});
}
if let Some(scenario) = shared
.get_mut("Scenario")
.and_then(serde_json::Value::as_object_mut)
@@ -885,10 +797,6 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
}
}
}
retained["relations"]
.as_object_mut()
.unwrap()
.retain(|_, relation| relation["body"].get("InterfaceItemAllocation").is_none());
for relation in retained["relations"].as_object_mut().unwrap().values_mut() {
if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") {
assert_eq!(boundary["oriented"], true);
@@ -918,7 +826,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
);
assert_eq!(
from_json.relations.len(),
original["relations"].as_object().unwrap().len() + allocations.len()
original["relations"].as_object().unwrap().len()
);
assert_eq!(
from_json.diagrams.len(),
@@ -991,7 +899,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
assert_eq!(from_text, from_json);
assert_eq!(read(text), from_text);
assert_eq!(persistence::decode(json).unwrap(), from_json);
assert_eq!(from_text.schema_version, 5);
assert_eq!(from_text.schema_version, 3);
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
for (id, record) in raw["elements"].as_object().unwrap() {
@@ -1192,8 +1100,6 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
}
if code.trim_start().starts_with("arcadia 2;")
|| code.trim_start().starts_with("arcadia 3;")
|| code.trim_start().starts_with("arcadia 4;")
|| code.trim_start().starts_with("arcadia 5;")
{
read(&code);
examples += 1;
+5 -12
View File
@@ -39,7 +39,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
assert_eq!(decoded.project, from_source.project);
assert_eq!(decoded.migration, from_source.migration);
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (2, 5));
assert_eq!((report.from_schema, report.to_schema), (2, 3));
assert_eq!(report.preserved_ports.len(), 4);
assert!(report.generated_endpoints.is_empty());
assert_eq!(report.orientation_defaults.len(), 1);
@@ -56,7 +56,6 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
+ old["traces"].as_object().unwrap().len()
+ old["artifact_links"].as_object().unwrap().len()
+ 1
+ report.generated_interface_allocations.len()
);
let mut current = serde_json::to_value(&decoded.project).unwrap();
for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() {
@@ -144,7 +143,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
}
}
let canonical = language::format(&first.project);
assert!(canonical.starts_with("arcadia 5;"));
assert!(canonical.starts_with("arcadia 3;"));
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
assert!(reopened.migration.is_none());
assert_eq!(reopened.project, first.project);
@@ -224,13 +223,7 @@ fn migration_admission_counts_generated_objects_records_and_references() {
_ => 0,
})
})
.sum::<usize>()
+ decode(&old)
.migration
.unwrap()
.generated_interface_allocations
.len()
* 2;
.sum::<usize>();
for limits in [
ParseLimits {
max_graph_objects: graph + 1,
@@ -251,7 +244,7 @@ fn migration_admission_counts_generated_objects_records_and_references() {
));
}
let accepted = ParseLimits {
max_graph_objects: migrated.elements.len() + migrated.relations.len(),
max_graph_objects: graph + 2,
max_records: records,
max_step_references: reference_count,
..ParseLimits::UNLIMITED
@@ -274,7 +267,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
assert!(error.contains("V2") && error.contains(&fe), "{error}");
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
let result = persistence::decode_with_report(v1).unwrap();
assert_eq!(result.project.schema_version, 5);
assert_eq!(result.project.schema_version, 3);
assert_eq!(result.migration.unwrap().from_schema, 1);
assert_eq!(persistence::decode(v1).unwrap(), result.project);
}
+6 -8
View File
@@ -1,6 +1,6 @@
use oa_core::{language, persistence, *};
const SOURCE: &str = r#"arcadia 4;
const SOURCE: &str = r#"arcadia 3;
project typed_ports "Typed ports" {
sa_system producer "Producer" {}
sa_actor consumer "Consumer" {}
@@ -9,8 +9,7 @@ project typed_ports "Typed ports" {
allocation sends "Produces" { source @producer; target @send; }
allocation receives "Consumes" { source @consumer; target @receive; }
sa_exchange_item data "Data" {}
sa_interface service "Service" {}
interface_item_allocation service_data "Service data" { source @service; target @data; }
sa_interface service "Service" { items [@data]; }
sa_port output "Output" { parent @send; contract { FunctionOutput { outgoing_items [@data]; }; }; }
sa_port input "Input" { parent @receive; contract { FunctionInput { incoming_items [@data]; }; }; }
sa_port provider "Provider port" { parent @producer; contract { Component { kind Standard; orientation Unset; }; }; }
@@ -27,10 +26,9 @@ project typed_ports "Typed ports" {
"#;
#[test]
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
let decoded = language::parse_with_report(SOURCE).unwrap();
let migration = decoded.migration.as_ref().unwrap();
assert_eq!((migration.from_schema, migration.to_schema), (4, 5));
assert!(decoded.migration.is_none());
let canonical = language::format(&decoded.project);
for field in [
"incoming_items [@data]",
@@ -71,7 +69,7 @@ fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references
}
#[test]
fn source4_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
for (remove, expected) in [
("source_port @output; ", "source_port"),
("target_port @input; ", "target_port"),
@@ -92,7 +90,7 @@ fn source4_requires_explicit_port_contract_and_fe_endpoints_without_generation()
}
#[test]
fn source4_rejects_mixed_component_kinds_and_nested_reference_typos() {
fn source3_rejects_mixed_component_kinds_and_nested_reference_typos() {
let mixed = SOURCE.replace(
"parent @consumer; contract { Component { kind Standard; orientation Unset;",
"parent @consumer; contract { Component { kind Flow; orientation Unset;",
-680
View File
@@ -1,680 +0,0 @@
use oa_core::{transitions::*, *};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Family {
Data,
Port,
Occurrence,
Message,
Event,
State,
}
const FAMILIES: [Family; 6] = [
Family::Data,
Family::Port,
Family::Occurrence,
Family::Message,
Family::Event,
Family::State,
];
fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option<Id>) -> Id {
p.add_element(kind, layer, "reviewed", parent).unwrap()
}
fn trace(p: &mut Project, oa: Id, sa: Id) -> Id {
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("review_{}", id.simple()),
name: "Review".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
},
);
id
}
fn set(p: &mut Project, id: Id, body: SharedBody) {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
}
fn integer(p: &mut Project, id: Id) {
set(
p,
id,
SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(0),
maximum: Some(10),
},
default: None,
unit: "m".into(),
},
);
}
fn fixture(family: Family) -> (Project, Id, Id, Id, FacetSelector, FacetSelector) {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let id = match family {
Family::Data => {
let id = add(&mut p, Kind::DataType, layer, None);
integer(&mut p, id);
id
}
Family::Port => {
let kind = if layer == Layer::Oa {
Kind::Actor
} else {
Kind::SystemActor
};
let owner = add(&mut p, kind, layer, None);
let id = Id::new_v4();
let mut port = make_port(&p, id, owner, "port", PortContract::Legacy).unwrap();
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
*legacy = Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
});
}
p.elements.insert(id, port);
id
}
Family::Occurrence | Family::Message => {
let owner = add(&mut p, Kind::Scenario, layer, None);
let id = add(&mut p, Kind::Occurrence, layer, Some(owner));
p.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
set(
&mut p,
id,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Timer {
expression: "10s".into(),
},
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
},
);
id
}
Family::Event => {
let id = add(&mut p, Kind::Event, layer, None);
set(
&mut p,
id,
SharedBody::Event {
body: EventBody::Time {
kind: TimeEventKind::After,
expression: "10s".into(),
},
},
);
id
}
Family::State => {
let machine = add(&mut p, Kind::StateMachine, layer, None);
let region = add(&mut p, Kind::Region, layer, Some(machine));
add(&mut p, Kind::State, layer, Some(region))
}
};
pair.push(id);
}
let (oa, sa) = (pair[0], pair[1]);
let t = trace(&mut p, oa, sa);
let (constructor, descendant) = match family {
Family::Data => (
FacetSelector::Data(DataFacet::Constructor),
FacetSelector::Data(DataFacet::IntegerMaximum),
),
Family::Port => (
FacetSelector::Port(PortFacet::Constructor),
FacetSelector::Port(PortFacet::LegacyDirection),
),
Family::Occurrence => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
),
Family::Message => (
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression),
),
Family::Event => (
FacetSelector::Event(EventFacet::Constructor),
FacetSelector::Event(EventFacet::TimeExpression),
),
Family::State => (
FacetSelector::State(StateFacet::Kind),
FacetSelector::State(StateFacet::Entry),
),
};
(p, oa, sa, t, constructor, descendant)
}
fn change_descendant(p: &mut Project, id: Id) {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::DataType {
definition: DataTypeDefinition::Integer { maximum, .. },
..
} => *maximum = Some(20),
SharedBody::Port {
legacy: Some(LegacyPortContract { direction, .. }),
..
} => *direction = PortDirection::Out,
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Timer { expression },
..
},
..
}
| SharedBody::Event {
body: EventBody::Time { expression, .. },
} => *expression = "20s".into(),
SharedBody::State { entry, .. } => *entry = "initialize".into(),
_ => panic!("Unexpected test family"),
}
}
fn request(
oa: Id,
sa: Id,
reverse: bool,
resolutions: Vec<(FacetSelector, SemanticDecision)>,
) -> SemanticTransitionRequest {
let (direction, source, target) = if reverse {
(TransitionDirection::SaToOa, sa, oa)
} else {
(TransitionDirection::OaToSa, oa, sa)
};
let mut transition = TransitionRequest::new(direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut r = SemanticTransitionRequest::new(transition);
r.reviews.push(SemanticReviewRequest {
source,
resolutions: resolutions
.into_iter()
.map(|(facet, decision)| FacetResolution { facet, decision })
.collect(),
reset: vec![],
});
r
}
fn apply(p: &Project, r: SemanticTransitionRequest) -> (Project, SemanticTransitionPlan) {
let plan = plan_semantic_transition(p, r).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
(apply_semantic_transition(p, &plan).unwrap(), plan)
}
fn drift(p: &Project, t: Id, selector: &FacetSelector) -> Drift {
semantic_facets(p, &p.traces[&t], &BTreeMap::new())
.unwrap()
.into_iter()
.find(|f| f.selector == *selector)
.unwrap()
.drift
}
#[test]
fn first_equal_constructor_acknowledges_only_discriminator_in_every_family_and_direction() {
for family in FAMILIES {
for reverse in [false, true] {
for decision in [
SemanticDecision::KeepTarget,
SemanticDecision::TakeSource,
SemanticDecision::Candidate,
] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(family);
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
change_descendant(&mut p, target);
let mut r = request(oa, sa, reverse, vec![(constructor.clone(), decision)]);
if decision == SemanticDecision::Candidate {
r.candidates
.push(SemanticRecord::Element(p.elements[&target].clone()));
}
let (q, plan) = apply(&p, r.clone());
assert_eq!(q.elements, p.elements, "{family:?} {decision:?}");
assert_eq!(plan.reviews[0].acknowledged, vec![constructor.clone()]);
assert!(plan.reviews[0].retired.is_empty());
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert!(facts.iter().all(|f| f.drift
== if f.selector == constructor {
Drift::Unchanged
} else {
Drift::Unreviewed
}));
let (mut q, _) = apply(
&q,
request(
oa,
sa,
reverse,
vec![(descendant.clone(), SemanticDecision::KeepTarget)],
),
);
change_descendant(&mut q, source);
assert_eq!(
drift(&q, t, &descendant),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
let repeated = plan_semantic_transition(&q, r).unwrap();
assert!(
repeated
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&q, &repeated).is_err());
}
}
}
}
#[test]
fn independent_constructor_and_leaf_selections_complete_missing_or_empty_baselines() {
for family in FAMILIES {
for reverse in [false, true] {
for history in 0..3 {
let (mut p, oa, sa, t, _, _) = fixture(family);
if history == 0 {
p.traces.get_mut(&t).unwrap().baseline = None;
} else if history == 2 {
p.traces
.get_mut(&t)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline::default());
}
let selectors: Vec<_> = semantic_facets(&p, &p.traces[&t], &BTreeMap::new())
.unwrap()
.into_iter()
.map(|f| f.selector)
.collect();
let (q, plan) = apply(
&p,
request(
oa,
sa,
reverse,
selectors
.iter()
.cloned()
.map(|s| (s, SemanticDecision::KeepTarget))
.collect(),
),
);
assert_eq!(q.elements, p.elements);
assert_eq!(plan.reviews[0].acknowledged.len(), selectors.len());
assert!(plan.reviews[0].retired.is_empty());
let status = trace_semantic_drift(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert_eq!(status.own, Drift::Unchanged, "{family:?} {history}");
assert!(!status.own_unreviewed);
}
}
}
}
#[test]
fn migrated_equal_constructor_can_complete_semantic_review_without_model_changes() {
let json = include_str!("fixtures/v4-reviewed-prose.json")
.replace("\"Real\"", "\"Integer\"")
.replace("0.0", "0")
.replace("100.0", "100")
.replace("20.0", "20");
for reverse in [false, true] {
let decoded = persistence::decode_with_report(json.as_bytes()).unwrap();
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (4, 5));
let p = decoded.project;
let t = p.traces.values().next().unwrap();
assert!(t.baseline.as_ref().unwrap().semantic.is_none());
let original_prose = t.baseline.clone().unwrap();
let (oa, sa, id) = (t.oa, t.sa, t.id);
let selections = semantic_facets(&p, t, &BTreeMap::new())
.unwrap()
.into_iter()
.map(|f| (f.selector, SemanticDecision::KeepTarget))
.collect();
let (q, _) = apply(&p, request(oa, sa, reverse, selections));
assert_eq!(q.elements, p.elements);
let baseline = q.traces[&id].baseline.as_ref().unwrap();
assert_eq!(baseline.oa, original_prose.oa);
assert_eq!(baseline.sa, original_prose.sa);
let status = trace_semantic_drift(&q, &q.traces[&id], &BTreeMap::new()).unwrap();
assert_eq!(status.own, Drift::Unchanged);
assert!(!status.own_unreviewed);
}
}
#[test]
fn equal_constructor_candidate_requires_independent_selection_of_changed_descendants() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let target = if reverse { oa } else { sa };
let mut changed = p.clone();
change_descendant(&mut changed, target);
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::Candidate)],
);
r.candidates
.push(SemanticRecord::Element(changed.elements[&target].clone()));
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
assert!(
invalid
.conflicts
.iter()
.any(|c| c.contains("changes an unselected facet"))
);
assert!(apply_semantic_transition(&p, &invalid).is_err());
r.reviews[0].resolutions.push(FacetResolution {
facet: descendant.clone(),
decision: SemanticDecision::Candidate,
});
let (q, plan) = apply(&p, r);
assert_eq!(q.elements, changed.elements);
assert_eq!(plan.reviews[0].acknowledged.len(), 2);
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
assert_eq!(
drift(&q, t, &FacetSelector::Data(DataFacet::Default)),
Drift::Unreviewed
);
}
}
#[test]
fn reset_equal_constructor_does_not_refresh_previously_reviewed_descendants() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let (mut p, _) = apply(
&p,
request(
oa,
sa,
reverse,
vec![
(constructor.clone(), SemanticDecision::KeepTarget),
(descendant.clone(), SemanticDecision::KeepTarget),
],
),
);
let original = p.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.clone();
change_descendant(&mut p, if reverse { sa } else { oa });
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.reviews[0].reset.push(constructor.clone());
let (q, plan) = apply(&p, r);
assert_eq!(q.elements, p.elements);
assert_eq!(plan.reviews[0].acknowledged, vec![constructor]);
assert!(plan.reviews[0].retired.is_empty());
assert_eq!(
q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap(),
&original
);
assert_eq!(
drift(&q, t, &descendant),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
}
}
#[test]
fn first_constructor_acknowledgement_agrees_only_on_its_canonical_discriminator_slot() {
for switch in [false, true] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Data);
let second = add(&mut p, Kind::DataType, Layer::Oa, None);
integer(&mut p, second);
if switch {
set(
&mut p,
second,
SharedBody::DataType {
definition: DataTypeDefinition::Real {
minimum: Some(0.),
maximum: Some(30.),
},
default: None,
unit: "m".into(),
},
);
} else {
change_descendant(&mut p, second);
}
trace(&mut p, second, sa);
let mut r = request(
oa,
sa,
false,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.transition.sources.push(second);
r.transition.mappings.push(TransitionMapping {
source: second,
target: Some(sa),
target_kind: None,
});
r.reviews.push(SemanticReviewRequest {
source: second,
reset: vec![],
resolutions: vec![FacetResolution {
facet: if switch {
constructor.clone()
} else {
descendant.clone()
},
decision: SemanticDecision::TakeSource,
}],
});
for reversed in [false, true] {
let mut r = r.clone();
if reversed {
r.reviews.reverse();
}
let plan = plan_semantic_transition(&p, r).unwrap();
if switch {
assert!(plan.conflicts.iter().any(|c| c.contains("Contradictory")));
assert!(apply_semantic_transition(&p, &plan).is_err());
} else {
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let q = apply_semantic_transition(&p, &plan).unwrap();
assert_eq!(drift(&q, t, &constructor), Drift::Unchanged);
assert_eq!(drift(&q, t, &descendant), Drift::Unreviewed);
assert!(matches!(
q.elements[&sa].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer {
maximum: Some(20),
..
},
..
})
));
}
}
}
}
#[test]
fn one_sided_or_disappeared_message_constructor_is_not_an_equal_scalar_acknowledgement() {
for reverse in [false, true] {
let (mut p, oa, sa, t, constructor, descendant) = fixture(Family::Message);
let source = if reverse { sa } else { oa };
let target = if reverse { oa } else { sa };
let mut action = p.elements[&source].shared().unwrap().clone();
if let SharedBody::Occurrence { body, .. } = &mut action {
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
}
set(&mut p, source, action.clone());
let (q, plan) = apply(
&p,
request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
),
);
// One-sided applicability still has a constructor-group scope and records its timer leaf.
assert_eq!(
plan.reviews[0].acknowledged,
vec![constructor.clone(), descendant.clone()]
);
assert_eq!(q.elements, p.elements);
assert_eq!(drift(&q, t, &descendant), Drift::Unchanged);
let mut p = q;
set(&mut p, target, action);
let mut r = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
r.reviews[0].reset.push(constructor);
let plan = plan_semantic_transition(&p, r).unwrap();
assert!(!plan.conflicts.is_empty());
assert!(apply_semantic_transition(&p, &plan).is_err());
}
}
#[test]
fn historical_switches_retain_groups_and_completed_requests_still_reject_in_both_directions() {
for reverse in [false, true] {
let (p, oa, sa, t, constructor, _) = fixture(Family::Data);
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
let keep = request(
oa,
sa,
reverse,
vec![(constructor.clone(), SemanticDecision::KeepTarget)],
);
let (mut p, _) = apply(&p, keep.clone());
set(
&mut p,
source,
SharedBody::DataType {
definition: DataTypeDefinition::Real {
minimum: Some(0.),
maximum: Some(20.),
},
default: None,
unit: "m".into(),
},
);
let (mut q, plan) = apply(&p, keep.clone());
let maximum = FacetSelector::Data(DataFacet::RealMaximum);
assert!(plan.reviews[0].acknowledged.contains(&maximum));
assert!(
plan.reviews[0]
.acknowledged
.contains(&FacetSelector::Data(DataFacet::Default))
);
assert!(
!plan.reviews[0]
.acknowledged
.contains(&FacetSelector::Data(DataFacet::Unit))
);
assert_eq!(q.elements, p.elements);
if let Some(SharedBody::DataType {
definition: DataTypeDefinition::Real { maximum, .. },
..
}) = q.elements.get_mut(&source).unwrap().shared_mut()
{
*maximum = Some(30.);
}
let repeated = plan_semantic_transition(&q, keep).unwrap();
assert!(
repeated
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&q, &repeated).is_err());
assert_eq!(
drift(&q, t, &maximum),
if reverse {
Drift::SaChanged
} else {
Drift::OaChanged
}
);
let take = request(
oa,
sa,
reverse,
vec![(constructor, SemanticDecision::TakeSource)],
);
let (p, _) = apply(&q, take.clone());
assert!(matches!(
p.elements[&target].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Real {
maximum: Some(30.),
..
},
..
})
));
let completed = plan_semantic_transition(&p, take).unwrap();
assert!(
completed
.conflicts
.iter()
.any(|c| c.contains("No constructor switch"))
);
assert!(apply_semantic_transition(&p, &completed).is_err());
}
}
-169
View File
@@ -1,169 +0,0 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::Value;
use std::collections::BTreeMap;
const JSON4: &[u8] = include_bytes!("fixtures/v4-reviewed-prose.json");
const SOURCE4: &str = include_str!("fixtures/v4-reviewed-prose.arcadia");
#[test]
fn frozen_v4_json_and_source_preserve_the_old_review_without_fabricating_semantics() {
let json = persistence::decode_with_report(JSON4).unwrap();
let source = language::parse_with_report(SOURCE4).unwrap();
assert_eq!(json.project, source.project);
for decoded in [json, source] {
assert_eq!(decoded.project.schema_version, 5);
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (4, 5));
let trace = decoded.project.traces.values().next().unwrap();
let baseline = trace.baseline.as_ref().unwrap();
assert!(baseline.semantic.is_none());
assert_eq!(baseline.oa.name, "Approved OA name");
assert_eq!(baseline.oa.description, "Approved OA\nprose");
assert_eq!(baseline.sa.name, "Approved SA name");
assert_ne!(baseline.oa.name, decoded.project.named(trace.oa));
let mut expected: Value = serde_json::from_slice(JSON4).unwrap();
expected["schema_version"] = 5.into();
assert_eq!(serde_json::to_value(&decoded.project).unwrap(), expected);
let reopened =
persistence::decode_with_report(&serde_json::to_vec(&decoded.project).unwrap())
.unwrap();
assert!(reopened.migration.is_none());
assert_eq!(reopened.project, decoded.project);
}
}
#[test]
fn older_headers_cannot_admit_new_semantic_baselines() {
let mut value: Value = serde_json::from_slice(JSON4).unwrap();
let trace = value["traces"]
.as_object_mut()
.unwrap()
.values_mut()
.next()
.unwrap();
trace["baseline"]["semantic"] = serde_json::json!({"facets": []});
for version in [3, 4] {
value["schema_version"] = version.into();
let error = persistence::decode(&serde_json::to_vec(&value).unwrap())
.unwrap_err()
.to_string();
assert!(error.contains("semantic"), "{error}");
}
let source = SOURCE4.replace("baseline {", "baseline { semantic { facets []; };");
assert!(
language::parse(&source)
.unwrap_err()
.iter()
.any(|d| d.message.contains("require schema/source5"))
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default())
.unwrap_err()
.iter()
.any(|d| d.diagnostic.message.contains("require schema/source5"))
);
}
#[test]
fn frozen_v4_trace_envelope_keeps_duplicate_and_unknown_field_rejection() {
let json = std::str::from_utf8(JSON4).unwrap();
let duplicate = json.replace("\"baseline\": {", "\"baseline\": null, \"baseline\": {");
assert!(
persistence::decode(duplicate.as_bytes())
.unwrap_err()
.to_string()
.contains("duplicate field")
);
let unknown = json.replace("\"baseline\": {", "\"baseline\": { \"reviewed\": true,");
assert!(
persistence::decode(unknown.as_bytes())
.unwrap_err()
.to_string()
.contains("unknown field")
);
}
#[test]
fn authored_v4_modules_upgrade_only_the_version_and_preserve_nested_comments() {
let start = SOURCE4.find(" oa_data_type").unwrap();
let end = SOURCE4.rfind('}').unwrap();
let entry = format!(
"{} include \"contracts.arcadia\";\n}}\n",
&SOURCE4[..start]
);
let mut module =
SOURCE4[start..end].replace("baseline {", "baseline { // original review rationale");
module.push_str(
r#"
oa_data_type record "Independent record" id "40000000-0000-4000-8000-000000000011" {
definition { Record { supertype none; }; };
// This field remains inside its authored record.
oa_data_field flag "Flag" id "40000000-0000-4000-8000-000000000012" { type_ref Boolean; }
}
"#,
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), entry.clone()),
("contracts.arcadia".into(), module.clone()),
]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle.files["contracts.arcadia"], module);
assert_eq!(
compiled.bundle.files["project.arcadia"],
entry.replacen("arcadia 4;", "arcadia 5;", 1)
);
assert_eq!(
compiled.index.lexical_owners
[&Id::parse_str("40000000-0000-4000-8000-000000000012").unwrap()],
Id::parse_str("40000000-0000-4000-8000-000000000011").unwrap()
);
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert!(reopened.decoded.migration.is_none());
assert_eq!(reopened.bundle, compiled.bundle);
assert_eq!(reopened.decoded.project, compiled.decoded.project);
assert_eq!(
bundle.files["project.arcadia"], entry,
"caller draft is unchanged"
);
}
#[test]
fn original_v1_v2_v3_inputs_complete_the_explicit_upgrade_to_five() {
for (version, bytes) in [
(
1,
include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json").as_slice(),
),
(
2,
include_bytes!("fixtures/v2-coastal-rescue.json").as_slice(),
),
(
3,
include_bytes!("fixtures/v3-data-contract.json").as_slice(),
),
] {
let decoded = persistence::decode_with_report(bytes).unwrap();
let report = decoded.migration.unwrap();
assert_eq!((report.from_schema, report.to_schema), (version, 5));
assert_eq!(decoded.project.schema_version, 5);
assert!(
decoded
.project
.traces
.values()
.all(|t| t.baseline.as_ref().is_none_or(|b| b.semantic.is_none()))
);
assert_eq!(
language::parse(&language::format(&decoded.project)).unwrap(),
decoded.project
);
}
}
File diff suppressed because it is too large Load Diff
-431
View File
@@ -1,431 +0,0 @@
use oa_core::{language, persistence, semantic::*, source_project::*, *};
use std::collections::BTreeMap;
fn id(n: u128) -> Id {
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
}
fn reviewed<T>(oa: T, sa: T) -> Reviewed<T> {
Reviewed {
oa: Observed::Value(oa),
sa: Observed::Value(sa),
}
}
fn project(facets: Vec<FacetReview>) -> Project {
let mut project =
persistence::decode(include_bytes!("fixtures/v4-reviewed-prose.json")).unwrap();
project
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline { facets });
project
}
fn facets(project: &mut Project) -> &mut Vec<FacetReview> {
&mut project
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic
.as_mut()
.unwrap()
.facets
}
fn compile(source: String) -> CompiledSourceProject {
compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
},
ProjectLimits::default(),
)
.unwrap()
}
#[test]
fn typed_historical_facets_roundtrip_deleted_references_and_all_leaf_categories() {
let row = MessageValue {
path: vec![id(91), id(92)],
field: id(93),
expression: "x + 1".into(),
};
let model = project(vec![
FacetReview::Data(DataReview::Constructor(reviewed(
DataConstructor::Integer,
DataConstructor::Real,
))),
FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
oa: Observed::Value(None),
sa: Observed::NotApplicable,
})),
FacetReview::Data(DataReview::RealMaximum(Reviewed {
oa: Observed::NotApplicable,
sa: Observed::Value(Some(1.5)),
})),
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
))),
FacetReview::Field(FieldReview::Default(reviewed(
Some(vec![DataValue::String(id(90).to_string())]),
None,
))),
FacetReview::Member {
collection: ReferenceCollection::Steps,
keys: KeyPair {
oa: Some(id(91)),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Order {
collection: OrderedCollection::Steps,
review: reviewed(vec![id(91), id(92)], vec![]),
},
FacetReview::Annotation {
keys: KeyPair {
oa: Some(AnnotationKey::from(&row)),
sa: None,
},
review: reviewed(Some(row), None),
},
FacetReview::Binding {
keys: KeyPair {
oa: Some(id(94)),
sa: None,
},
review: reviewed(
Some(LifelineBinding {
referenced: id(94),
local: id(95),
}),
None,
),
},
FacetReview::Literal {
keys: KeyPair {
oa: Some(id(90).to_string()),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Artifact {
keys: KeyPair {
oa: Some(id(96)),
sa: None,
},
review: ArtifactReview::Relation(reviewed(
ArtifactRelation::References,
ArtifactRelation::References,
)),
},
]);
assert!(!model.contains(id(90)));
let source = language::format(&model);
assert!(source.starts_with("arcadia 5;"));
assert!(
source.contains(&format!("Named uuid \"{}\"", id(90))),
"{source}"
);
assert!(source.contains("sa NotApplicable;"));
assert!(source.contains("Value none;"));
assert!(source.contains(&format!("String \"{}\"", id(90))));
assert_eq!(language::parse(&source).unwrap(), model);
assert_eq!(compile(source).decoded.project, model);
assert_eq!(
persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(),
model
);
}
#[test]
fn historical_uuid_and_enum_tokens_are_contextual_and_text_remains_quoted() {
let model = project(vec![
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(2)),
TypeRef::Boolean,
))),
FacetReview::Data(DataReview::Unit(reviewed(id(2).to_string(), "m".into()))),
]);
let source = language::format(&model);
let reference = format!("Named uuid \"{}\"", id(2));
for replacement in [format!("Named \"{}\"", id(2)), "Named @oa_dimension".into()] {
let invalid = source.replace(&reference, &replacement);
assert!(language::parse(&invalid).is_err(), "{invalid}");
assert!(
compile_bundle(
&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), invalid)])
},
ProjectLimits::default()
)
.is_err()
);
}
let invalid_text = source.replace("Value \"m\";", &format!("Value uuid \"{}\";", id(2)));
assert!(language::parse(&invalid_text).is_err());
let invalid_enum = source.replace("Value Boolean;", "Value \"Boolean\";");
assert!(language::parse(&invalid_enum).is_err());
let invalid_live = source.replace("oa @oa_dimension;", &format!("oa uuid \"{}\";", id(2)));
assert!(language::parse(&invalid_live).is_err());
let invalid_prose = source.replace(
"name \"Approved OA name\";",
&format!("name uuid \"{}\";", id(2)),
);
assert!(language::parse(&invalid_prose).is_err());
// `uuid` remains an ordinary legal declaration alias after @.
assert!(language::parse(&source.replace("oa_dimension", "uuid")).is_ok());
}
#[test]
fn semantic_values_remain_closed_typed_records_in_both_formats() {
let model = project(vec![FacetReview::Data(DataReview::IntegerMinimum(
reviewed(Some(0), None),
))]);
let source = language::format(&model);
for invalid in [
source.replace("IntegerMinimum", "UnknownFacet"),
source.replace("Value 0;", "Value \"0\";"),
source.replace("facets [", "unexpected true; facets ["),
] {
assert!(language::parse(&invalid).is_err(), "{invalid}");
}
let mut json = serde_json::to_value(&model).unwrap();
json["traces"][id(4).to_string()]["baseline"]["semantic"] = serde_json::json!({"facets": [{"Data": {"IntegerMinimum": {"oa": {"Value": "0"}, "sa": {"Value": null}}}}]});
assert!(persistence::decode(&serde_json::to_vec(&json).unwrap()).is_err());
}
#[test]
fn facet_reordering_and_reidentification_keep_comments_with_typed_row_identity() {
let row = MessageValue {
path: vec![id(2)],
field: id(90),
expression: id(2).to_string(),
};
let model = project(vec![
FacetReview::Annotation {
keys: KeyPair {
oa: Some(AnnotationKey::from(&row)),
sa: None,
},
review: reviewed(Some(row), None),
},
FacetReview::Literal {
keys: KeyPair {
oa: Some(id(2).to_string()),
sa: None,
},
review: reviewed(true, false),
},
FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(2)),
TypeRef::Boolean,
))),
]);
let source = language::format(&model)
.replace("Annotation {", "Annotation { // annotation rationale")
.replace("Literal {", "Literal { // literal rationale")
.replace("Named uuid ", "Named uuid // historical type rationale\n");
let current = compile(source);
let mut prepared = prepare_command(
&current.decoded.project,
&Command::Reidentify(id(2), id(20)),
)
.unwrap();
facets(&mut prepared.project).reverse();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
let text = &updated.bundle.files["project.arcadia"];
assert_eq!(updated.decoded.project, prepared.project);
assert!(
text.contains(&format!(
"Named uuid // historical type rationale\n\"{}\"",
id(20)
)),
"{text}"
);
let type_note = text.find("// historical type rationale").unwrap();
let literal_note = text.find("// literal rationale").unwrap();
let annotation_note = text.find("// annotation rationale").unwrap();
assert!(
type_note < literal_note && literal_note < annotation_note,
"{text}"
);
assert!(text.contains(&format!("expression \"{}\"", id(2))));
let FacetReview::Literal { keys, .. } = &facets(&mut prepared.project)[1] else {
panic!()
};
assert_eq!(
keys.oa,
Some(id(2).to_string()),
"UUID-shaped literal keys are text"
);
assert_eq!(
compile_bundle(&updated.bundle, ProjectLimits::default())
.unwrap()
.bundle,
updated.bundle
);
}
#[test]
fn historical_reference_deletion_does_not_remove_the_review_or_create_a_live_alias() {
let mut model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
)))]);
let mut former_type = model.elements[&id(2)].clone();
former_type.id = id(90);
former_type.alias = "former_type".into();
model.elements.insert(id(90), former_type);
let current = compile(language::format(&model));
let prepared = prepare_command(&current.decoded.project, &Command::Delete(id(90))).unwrap();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
let source = &updated.bundle.files["project.arcadia"];
assert!(!updated.decoded.project.contains(id(90)));
assert!(!source.contains("@former_type"));
assert!(source.contains(&format!("Named uuid \"{}\"", id(90))));
assert_eq!(updated.decoded.project.traces, model.traces);
assert_eq!(language::parse(source).unwrap(), prepared.project);
}
#[test]
fn included_historical_review_edits_save_and_reopen_without_rewriting_other_modules() {
use oa_core::source_files::{load_project, save_project};
let model = project(vec![FacetReview::Field(FieldReview::Type(reviewed(
TypeRef::Named(id(90)),
TypeRef::Boolean,
)))]);
let source = language::format(&model);
let trace_at = source.find(" trace ").unwrap();
let end = source.rfind('}').unwrap();
let entry = format!(
"{} include \"reviews.arcadia\";\n}}\n",
&source[..trace_at]
);
let review =
source[trace_at..end].replace("Named uuid ", "Named uuid // deleted type retained\n");
let directory = tempfile::tempdir().unwrap();
let entry_path = directory.path().join("project.arcadia");
std::fs::write(&entry_path, &entry).unwrap();
std::fs::write(directory.path().join("reviews.arcadia"), &review).unwrap();
let snapshot = load_project(&entry_path, ProjectLimits::default()).unwrap();
let compiled = compile_bundle(&snapshot.reachable_bundle(), ProjectLimits::default()).unwrap();
assert_eq!(compiled.index.declarations[&id(4)].path, "reviews.arcadia");
let mut changed = compiled.decoded.project.clone();
let FacetReview::Field(FieldReview::Type(value)) = &mut facets(&mut changed)[0] else {
panic!()
};
value.sa = Observed::Value(TypeRef::String);
let updated = update_bundle(&compiled, &changed, ProjectLimits::default()).unwrap();
assert_eq!(updated.bundle.files["project.arcadia"], entry);
let saved = save_project(&snapshot, &updated, ProjectLimits::default()).unwrap();
let reopened = load_project(&entry_path, ProjectLimits::default()).unwrap();
assert_eq!(saved.bundle, reopened.bundle);
assert_eq!(
compile_bundle(&reopened.reachable_bundle(), ProjectLimits::default())
.unwrap()
.decoded
.project,
changed
);
assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), entry);
assert!(reopened.bundle.files["reviews.arcadia"].contains("uuid // deleted type retained"));
assert!(!changed.contains(id(90)));
}
#[test]
fn prepared_semantic_review_writes_back_and_preserves_the_accepted_target_difference() {
use oa_core::transitions::{TransitionDirection, TransitionMapping, TransitionRequest};
let model = project(vec![]);
let current = compile(language::format(&model).replace(
"baseline {",
"baseline { // prior prose review is independent",
));
let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![id(2)]);
transition.mappings.push(TransitionMapping {
source: id(2),
target: Some(id(3)),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.reviews.push(SemanticReviewRequest {
source: id(2),
resolutions: vec![
FacetResolution {
facet: FacetSelector::Data(DataFacet::Unit),
decision: SemanticDecision::KeepTarget,
},
FacetResolution {
facet: FacetSelector::Header(HeaderFacet::Name),
decision: SemanticDecision::TakeSource,
},
],
reset: vec![],
});
let plan = plan_semantic_transition(&current.decoded.project, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let prepared = prepare_semantic_transition(&current.decoded.project, &plan).unwrap();
let updated = update_bundle_with_identity_map(
&current,
&prepared.project,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(
updated.decoded.project.elements[&id(3)].name,
"OA dimension now"
);
assert!(
matches!(updated.decoded.project.elements[&id(3)].shared(), Some(SharedBody::DataType { unit, .. }) if unit == "ft")
);
assert!(
updated.bundle.files["project.arcadia"].contains("// prior prose review is independent")
);
let baseline = updated.decoded.project.traces[&id(4)]
.baseline
.as_ref()
.unwrap();
assert_eq!(baseline.oa.name, "Approved OA name");
assert!(
baseline
.semantic
.as_ref()
.unwrap()
.facets
.contains(&FacetReview::Data(DataReview::Unit(reviewed(
"m".into(),
"ft".into()
))))
);
assert_eq!(
language::parse(&language::format(&updated.decoded.project)).unwrap(),
prepared.project
);
assert_eq!(
persistence::decode(&serde_json::to_vec(&updated.decoded.project).unwrap()).unwrap(),
prepared.project
);
}
File diff suppressed because it is too large Load Diff
-228
View File
@@ -1,228 +0,0 @@
use oa_core::{language, persistence, *};
use serde_json::{Value, json};
const SOURCE3: &str = include_str!("fixtures/v3-data-contract.arcadia");
fn id(n: u128) -> Id {
Id::from_u128(n)
}
// Frozen JSON uses the same authored schema3 contract as the source fixture;
// this helper never serializes current defaults under version3.
fn original3() -> Value {
serde_json::from_str(include_str!("fixtures/v3-data-contract.json")).unwrap()
}
#[test]
fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships() {
let old = original3();
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
let source = language::parse_with_report(SOURCE3).unwrap();
assert_eq!(decoded.project, source.project);
assert_eq!(decoded.migration, source.migration);
let project = &decoded.project;
let report = decoded.migration.as_ref().unwrap();
assert_eq!((report.from_schema, report.to_schema), (3, 5));
assert_eq!(project.identities().len(), 19);
assert!(report.preserved_ports.is_empty());
assert!(report.generated_endpoints.is_empty());
assert_eq!(report.generated_interface_allocations.len(), 2);
for allocation in &report.generated_interface_allocations {
assert_eq!(
allocation.id,
Id::new_v5(
&id(1),
format!(
"v2-interface-item:{}:{}",
allocation.source, allocation.target
)
.as_bytes()
)
);
assert_eq!(allocation.target, id(4));
assert_eq!(allocation.order, 0);
assert!(matches!(
project.relations[&allocation.id].body,
RelationBody::InterfaceItemAllocation {
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
..
}
));
}
let now = serde_json::to_value(project).unwrap();
for port in [id(11), id(12)] {
assert_eq!(
now["elements"][port.to_string()],
old["elements"][port.to_string()]
);
}
assert!(matches!(
project.elements[&id(4)].shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Event,
supertype: None
})
));
assert_eq!(report.unresolved_message_values.len(), 1);
assert!(
validation::validate(project)
.iter()
.any(|issue| issue.code == "message_value_ambiguous_path")
);
let canonical = language::format(project);
assert!(canonical.starts_with("arcadia 5;"));
let reopened = language::parse_with_report(&canonical).unwrap();
assert_eq!(&reopened.project, project);
assert!(reopened.migration.is_none());
assert_eq!(canonical, language::format(&reopened.project));
}
#[test]
fn v3_changed_payloads_reject_schema4_fields_and_new_constructor_names() {
let mut old = original3();
old["elements"][id(4).to_string()]["body"]["Sa"]["Shared"]["ExchangeItem"]["mechanism"] =
json!("Flow");
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
let mut old = original3();
old["elements"][id(5).to_string()]["body"]["Sa"]["Shared"]["DataType"]["definition"] =
json!({"Record":{"supertype":null}});
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
let mut old = original3();
old["elements"][id(17).to_string()]["body"]["Sa"]["Shared"]["Occurrence"]["body"]["Exchange"]
["values"][0]["path"] = json!([]);
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
let source = SOURCE3.replace("kind Event;", "kind Information; mechanism Flow;");
assert!(language::parse(&source).is_err());
let source = SOURCE3.replace(
"functional_exchange position_exchange",
"interface_item_allocation position_exchange",
);
assert!(language::parse(&source).is_err());
}
#[test]
fn deterministic_allocation_id_collision_is_located_without_overwriting_any_object() {
let mut old = original3();
let collision = Id::new_v5(
&id(1),
format!("v2-interface-item:{}:{}", id(9), id(4)).as_bytes(),
);
old["artifact_links"][collision.to_string()] = json!({"id":collision,"alias":"retained_artifact","name":"Retain","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
let error = persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap_err();
match error {
persistence::FileError::Model { issues, .. } => {
assert_eq!(issues[0].code, "identity_collision");
assert_eq!(issues[0].element, Some(id(9)));
assert_eq!(issues[0].field.as_deref(), Some("items"));
}
other => panic!("unexpected error {other}"),
}
}
#[test]
fn unique_legacy_nested_value_is_qualified_and_physical_nature_is_not_guessed() {
let source = SOURCE3.replace("kind Event;", "kind Material;").replace(
"type_ref { Named @coordinate; }; }\n }",
"type_ref String; }\n }",
);
let decoded = language::parse_with_report(&source).unwrap();
assert!(matches!(
decoded.project.elements[&id(4)].shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material,
mechanism: ExchangeMechanism::Unspecified,
..
})
));
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = decoded.project.elements[&id(17)].shared()
else {
panic!()
};
assert_eq!(values[0].path, vec![id(7)]);
assert_eq!(values[0].expression, "navigation.latitude");
}
#[test]
fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
let v2 =
persistence::decode_with_report(include_bytes!("fixtures/v2-coastal-rescue.json")).unwrap();
let text =
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
assert_eq!(v2.project, text.project);
assert_eq!(v2.migration, text.migration);
assert_eq!(v2.migration.unwrap().to_schema, 5);
let v1 = language::parse_with_report(
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
)
.unwrap();
assert_eq!(v1.project.schema_version, 5);
assert_eq!(v1.migration.unwrap().from_schema, 1);
}
#[test]
fn allocation_alias_collisions_are_stable_and_duplicate_legacy_payload_fields_reject() {
let mut old = original3();
let first = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
let alias = first.migration.unwrap().generated_interface_allocations[0]
.alias
.clone();
old["artifact_links"][id(100).to_string()] = json!({"id":id(100),"alias":alias,"name":"Existing alias","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
let bytes = serde_json::to_vec(&old).unwrap();
let migrated = persistence::decode_with_report(&bytes).unwrap();
let generated = &migrated
.migration
.as_ref()
.unwrap()
.generated_interface_allocations[0];
assert_ne!(generated.alias, alias);
assert!(generated.alias.starts_with(&alias));
assert_eq!(
migrated.project,
persistence::decode_with_report(&bytes).unwrap().project
);
let duplicate = include_str!("fixtures/v3-data-contract.json").replace(
"\"kind\": \"Event\"",
"\"kind\": \"Event\", \"kind\": \"Material\"",
);
assert!(
persistence::decode(duplicate.as_bytes())
.unwrap_err()
.to_string()
.contains("Duplicate field 'kind'")
);
}
#[test]
fn recursive_legacy_record_paths_remain_ambiguous_instead_of_becoming_one_argument() {
let mut old = original3();
old["elements"][id(8).to_string()]["body"]["Sa"]["Shared"]["DataField"]["type_ref"] =
json!("String");
old["elements"][id(100).to_string()] = json!({"id":id(100),"alias":"next_coordinate","name":"Next coordinate","description":"","parent":id(5),"body":{"Sa":{"Shared":{"DataField":{"type_ref":{"Named":id(5)},"minimum":0,"maximum":1,"unit":""}}}}});
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
assert_eq!(
decoded
.migration
.as_ref()
.unwrap()
.unresolved_message_values
.len(),
1
);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = decoded.project.elements[&id(17)].shared()
else {
panic!()
};
assert!(values[0].path.is_empty());
assert!(
validation::validate(&decoded.project)
.iter()
.any(|issue| issue.code == "message_value_ambiguous_path")
);
}
-215
View File
@@ -1,215 +0,0 @@
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
fn id(n: u128) -> Id {
Id::from_u128(n)
}
#[test]
fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_identity() {
let original = include_str!("fixtures/v3-data-contract.arcadia");
let header = original.find(" sa_function").unwrap();
let close = original.rfind('}').unwrap();
let entry = format!(
"{} // Contracts stay in their original module.\n include \"contracts.arcadia\";\n}}\n",
&original[..header]
);
let fragment = original[header..close].replace(
"items [@packet];",
"items [@ // interface contract decision\npacket];",
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), entry),
("contracts.arcadia".into(), fragment),
]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle.files.len(), 2);
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 5;"));
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
for text in [
"// Contracts stay in their original module.",
"// Reused record parameters retain their own identities.",
"// interface contract decision",
] {
assert!(
compiled
.bundle
.files
.values()
.any(|source| source.contains(text)),
"Lost {text}"
);
}
assert_eq!(compiled.index.lexical_owners[&id(6)], id(5));
assert_eq!(compiled.index.lexical_owners[&id(7)], id(4));
assert_eq!(compiled.index.lexical_owners[&id(8)], id(4));
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
let report = compiled.decoded.migration.as_ref().unwrap();
assert_eq!(report.from_schema, 3);
for allocation in &report.generated_interface_allocations {
assert_eq!(
compiled.index.declarations[&allocation.id].path,
"contracts.arcadia"
);
}
assert!(
compiled
.diagnostics
.iter()
.any(|d| d.diagnostic.code == "message_value_ambiguous_path")
);
let second = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(second.bundle, compiled.bundle);
assert_eq!(second.decoded.project, compiled.decoded.project);
assert!(second.decoded.migration.is_none());
let mut renamed = compiled.decoded.project.clone();
renamed.elements.get_mut(&id(6)).unwrap().name = "Latitude degrees".into();
let updated = update_bundle(&compiled, &renamed, ProjectLimits::default()).unwrap();
assert_eq!(updated.index.lexical_owners, compiled.index.lexical_owners);
assert!(updated.bundle.files["contracts.arcadia"].contains("Latitude degrees"));
assert!(
bundle.files["project.arcadia"].contains("arcadia 3;"),
"Migration does not mutate the caller's draft"
);
}
#[test]
fn schema4_typed_values_supertype_paths_and_protocols_roundtrip() {
let source = r#"arcadia 4; project data_contract "Data contract" {
sa_data_type bounded "Bounded" { definition { Integer { minimum -9223372036854775808; maximum 9223372036854775807; }; }; default { Integer -9223372036854775808; }; unit "m"; }
sa_data_type label_type "Label" { definition { String { minimum_length 1; maximum_length 4; }; }; default { String "航海"; }; }
sa_data_type base "Base" { definition { Record { supertype none; }; }; }
sa_data_type derived "Derived" { definition { Record { supertype @base; }; }; }
sa_exchange_item operation "Operation" { mechanism Operation;
sa_data_field value "Value" { type_ref { Named @bounded; }; direction In; storage Value; default [{ Integer 42; }]; }
}
sa_interface contract "Contract" { supertype none; }
interface_item_allocation member "Operation member" { source @contract; target @operation; transmission Synchronous; acquisition Accept; order 3; }
data_association association "Related records" { source @base; target @derived; source_role "parent"; target_role "children"; source_minimum 0; source_maximum 1; target_minimum 0; target_maximum none; source_navigable true; target_navigable true; }
}"#;
let parsed = language::parse(source).unwrap();
let formatted = language::format(&parsed);
assert!(formatted.contains("supertype @base"));
assert!(formatted.contains("transmission Synchronous"));
assert_eq!(language::parse(&formatted).unwrap(), parsed);
let compiled = compile_bundle(&format_bundle(&parsed), ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, parsed);
assert!(compiled.decoded.migration.is_none());
}
#[test]
fn omitted_ids_are_stabilized_before_allocation_identity_generation() {
let bundle=SourceBundle{entry:"project.arcadia".into(),files:BTreeMap::from([("project.arcadia".into(),"arcadia 3; project old_model \"Old\" { sa_exchange_item event_item \"Event\" { kind Event; } sa_interface contract \"Contract\" { items [@event_item]; } }".into())])};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let report = compiled.decoded.migration.as_ref().unwrap();
let allocation = &report.generated_interface_allocations[0];
assert_eq!(
allocation.id,
Id::new_v5(
&compiled.decoded.project.id,
format!(
"v2-interface-item:{}:{}",
allocation.source, allocation.target
)
.as_bytes()
)
);
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
assert_eq!(reopened.decoded.project, compiled.decoded.project);
assert_eq!(reopened.bundle, compiled.bundle);
}
#[test]
fn inferring_a_legacy_path_keeps_rationale_inside_its_argument() {
let source = include_str!("fixtures/v3-data-contract.arcadia")
.replace(
"type_ref { Named @coordinate; }; }\n }",
"type_ref String; }\n }",
)
.replace(
"field @latitude; expression",
"field @latitude; // original argument rationale\nexpression",
);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let source = &compiled.bundle.files["project.arcadia"];
let argument = &source[source.find("values [{").unwrap()..];
let argument = &argument[..argument.find('}').unwrap()];
assert!(
argument.contains("// original argument rationale"),
"{source}"
);
assert!(argument.contains("path [@origin]"), "{source}");
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
}
#[test]
fn qualified_value_reordering_and_path_reidentification_keep_each_arguments_comments() {
let mut project = language::parse(include_str!("fixtures/v3-data-contract.arcadia")).unwrap();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
else {
panic!()
};
*values = vec![
MessageValue {
path: vec![id(7)],
field: id(6),
expression: "origin value".into(),
},
MessageValue {
path: vec![id(8)],
field: id(6),
expression: "destination value".into(),
},
];
let mut bundle = format_bundle(&project);
for source in bundle.files.values_mut() {
*source = source
.replace(
"expression \"origin value\";",
"expression \"origin value\"; // origin argument rationale",
)
.replace(
"expression \"destination value\";",
"expression \"destination value\"; // destination argument rationale",
);
}
let current = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
else {
panic!()
};
values.reverse();
let renamed = Id::from_u128(100);
let mut identities: BTreeMap<_, _> = project
.identities()
.into_iter()
.map(|(id, _)| (id, id))
.collect();
identities.insert(id(7), renamed);
reidentify(&mut project, id(7), renamed).unwrap();
project.elements.get_mut(&renamed).unwrap().alias = "revised_origin".into();
let result =
update_bundle_with_identity_map(&current, &project, &identities, ProjectLimits::default())
.unwrap();
let source = &result.bundle.files[&result.index.declarations[&id(17)].path];
let destination = source.find("// destination argument rationale").unwrap();
let origin = source.find("// origin argument rationale").unwrap();
assert!(
destination < origin,
"Distinct uses of the same terminal field must reorder as complete argument annotations: {source}"
);
assert!(source[origin..].contains("path [@revised_origin]"));
assert_eq!(result.decoded.project, project);
}
+15 -18
View File
@@ -447,12 +447,11 @@ fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_r
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
let (source, target) = p.relations[&boundary].body.endpoints();
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service");
p.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
let requirement = p.relate(
RelationKind::InterfaceRequirement,
"Caller requires service",
@@ -500,12 +499,11 @@ fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_
let (mut p, a, b, item, forward) = fixture();
let (old, _) = endpoints(&p, forward);
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface");
p.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into();
*p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
@@ -661,12 +659,11 @@ fn directed_legacy_resolution_retains_unclassified_interface_and_role_identity()
let (mut p, _, _, item, fe) = fixture();
let output = endpoints(&p, fe).0;
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service");
p.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
+1 -4
View File
@@ -94,9 +94,7 @@ fn fixture(layer: Layer) -> Fixture {
.add_element(Kind::DataType, layer, "Request record", None)
.unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
definition: DataTypeDefinition::Record,
};
let field = p
.add_element(Kind::DataField, layer, "Requested level", Some(record))
@@ -162,7 +160,6 @@ fn fixture(layer: Layer) -> Fixture {
}) = p.elements.get_mut(&call).unwrap().shared_mut()
{
values.push(MessageValue {
path: vec![payload],
field,
expression: "requested_level".into(),
});
+6 -6
View File
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; }\n",
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; }\n",
)
.unwrap();
fs::create_dir(directory.path().join("parts")).unwrap();
@@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
let directory = setup();
let entry = directory.path().join("project.arcadia");
fs::write(&entry, "arcadia 5; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write(&entry, "arcadia 3; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
fs::write(
directory.path().join("parts/a.arcadia"),
"oa_activity a \"A\" { invalid",
@@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
let overlays = BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 5; project p \"P\" { include \"new.arcadia\"; }".into(),
"arcadia 3; project p \"P\" { include \"new.arcadia\"; }".into(),
),
(
"parts/a.arcadia".into(),
@@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions(
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }".into(),
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }".into(),
),
("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
]),
@@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() {
.unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 5; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
)
.unwrap();
assert!(matches!(
@@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() {
std::os::unix::fs::symlink(&destination, &link).unwrap();
fs::write(
directory.path().join("project.arcadia"),
"arcadia 5; project p \"P\" { include \"alias.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"alias.arcadia\"; }",
)
.unwrap();
let loaded = load_project(
@@ -6,10 +6,10 @@ fn source() -> CompiledSourceProject {
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 5; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
("project.arcadia".into(), "arcadia 3; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
("contracts.arcadia".into(), "sa_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
("contracts.arcadia".into(), "sa_interface contract \"Contract\" { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }\n".into()),
]),
},
ProjectLimits::default(),
@@ -91,9 +91,8 @@ fn identity_swap_and_reorder_keep_both_declarations_and_item_comments_with_origi
reidentify(&mut candidate, second, first).unwrap();
reidentify(&mut candidate, temporary, second).unwrap();
let contract = find(&candidate, "contract");
let Some(SharedBody::Event {
body: EventBody::Signal { items },
}) = candidate.elements.get_mut(&contract).unwrap().shared_mut()
let Some(SharedBody::Interface { items }) =
candidate.elements.get_mut(&contract).unwrap().shared_mut()
else {
panic!("interface")
};
@@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject {
compile_bundle(&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "arcadia 5; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
("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()
+33 -35
View File
@@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id {
#[test]
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
let input = bundle(
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[
(
"work.arcadia",
@@ -95,7 +95,7 @@ diagram review "Review" {
#[test]
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
let mut input = bundle(
"arcadia 5; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
&[
(
"work.arcadia",
@@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
fn typed() -> SourceBundle {
bundle(
"arcadia 5; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
"arcadia 3; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
&[
(
"exchanges.arcadia",
@@ -194,7 +194,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
#[test]
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
@@ -228,7 +228,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
#[test]
fn source_errors_keep_exact_fragment_unicode_positions() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }",
&[(
"child.arcadia",
"// Пример\nsa_function child \"航海\" { parent @missing; }",
@@ -274,7 +274,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
),
] {
let input = bundle(
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[("a.arcadia", left), ("b.arcadia", right)],
);
let error = compile_bundle(&input, ProjectLimits::default())
@@ -285,7 +285,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
assert_eq!(error.related[0].path, "a.arcadia");
}
let input = bundle(
"arcadia 5; project p \"P\" { include \"child.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }",
&[("child.arcadia", "oa_activity p \"Collision\" {}")],
);
let error = compile_bundle(&input, ProjectLimits::default())
@@ -297,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
#[test]
fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() {
let mut input = bundle(
"arcadia 5; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
&[
("sub/a.arcadia", "include \"../shared.arcadia\";"),
("sub/b.arcadia", "include \"../shared.arcadia\";"),
@@ -356,7 +356,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
);
assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err());
let input = bundle(
"arcadia 5; project p \"P\" {}",
"arcadia 3; project p \"P\" {}",
&[("A.arcadia", ""), ("a.arcadia", "")],
);
assert_eq!(
@@ -370,7 +370,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
#[test]
fn project_admission_is_aggregate_and_precedes_global_resolution() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[
("a.arcadia", "oa_activity one \"One\" { parent @missing; }"),
("b.arcadia", "oa_activity two \"Two\" {}"),
@@ -591,7 +591,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() {
#[test]
fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() {
let source = "arcadia 5; project p \"P\" { include \"missing.arcadia\"; }";
let source = "arcadia 3; project p \"P\" { include \"missing.arcadia\"; }";
assert!(
language::parse(source).unwrap_err()[0]
.message
@@ -681,7 +681,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
#[test]
fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() {
let input = bundle(
"arcadia 5;project p\"P\"{include \"child.arcadia\";}",
"arcadia 3;project p\"P\"{include \"child.arcadia\";}",
&[(
"child.arcadia",
"oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}",
@@ -703,7 +703,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind
#[test]
fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
&[("a.arcadia", "")],
);
assert_eq!(
@@ -720,7 +720,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg
"project_limit"
);
let input = bundle(
"arcadia 5; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
&[
(
"a.arcadia",
@@ -763,7 +763,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() {
&source[span.start..span.end],
"include \"../shared.arcadia\";"
);
let entry = "arcadia 5; project p \"P\" { include \"sub/work.arcadia\"; }";
let entry = "arcadia 3; project p \"P\" { include \"sub/work.arcadia\"; }";
assert_eq!(
inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0]
.target,
@@ -829,7 +829,7 @@ fn comments(source: &str) -> Vec<String> {
#[test]
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
let entry = "arcadia 5;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
let entry = "arcadia 3;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
let mut project = current.decoded.project.clone();
@@ -864,7 +864,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s
#[test]
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
@@ -887,7 +887,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after
#[test]
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"work.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
&[(
"work.arcadia",
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
@@ -913,7 +913,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields
#[test]
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"item.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"item.arcadia\"; }",
&[(
"item.arcadia",
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
@@ -953,7 +953,7 @@ fn significant_words(source: &str) -> Vec<String> {
#[test]
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"event.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"event.arcadia\"; }",
&[(
"event.arcadia",
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
@@ -986,7 +986,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment
#[test]
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"ports.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"ports.arcadia\"; }",
&[(
"ports.arcadia",
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
@@ -1017,10 +1017,10 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
#[test]
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n",
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }\n",
)],
);
let current = compile(&input);
@@ -1029,9 +1029,8 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
let b = id(&project, "b");
let service = id(&project, "service");
project.elements.get_mut(&a).unwrap().alias = "renamed_a".into();
if let Some(SharedBody::Event {
body: EventBody::Signal { items },
}) = project.elements.get_mut(&service).unwrap().shared_mut()
if let Some(SharedBody::Interface { items }) =
project.elements.get_mut(&service).unwrap().shared_mut()
{
*items = vec![b, a];
} else {
@@ -1052,10 +1051,10 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
#[test]
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n",
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }\n",
)],
);
let current = compile(&input);
@@ -1064,9 +1063,8 @@ fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations()
let b = id(&project, "b");
let service = id(&project, "service");
project.elements.remove(&a);
if let Some(SharedBody::Event {
body: EventBody::Signal { items },
}) = project.elements.get_mut(&service).unwrap().shared_mut()
if let Some(SharedBody::Interface { items }) =
project.elements.get_mut(&service).unwrap().shared_mut()
{
*items = vec![b];
} else {
@@ -1104,7 +1102,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
}; }; }
"#;
let current = compile(&bundle(
"arcadia 5; project p \"P\" { include \"conversation.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"conversation.arcadia\"; }",
&[("conversation.arcadia", source)],
));
let mut project = current.decoded.project.clone();
@@ -1153,7 +1151,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
#[test]
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
let input = bundle(
"arcadia 5; project p \"P\" { include \"types.arcadia\"; }",
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
&[(
"types.arcadia",
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
@@ -1162,7 +1160,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
let empty = compile(&input);
let mut project = empty.decoded.project.clone();
let values = id(&project, "values");
if let Some(SharedBody::DataType { definition, .. }) =
if let Some(SharedBody::DataType { definition }) =
project.elements.get_mut(&values).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Enumeration {
@@ -1187,7 +1185,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
.replace("\"Beta\"", "\"Beta\" // beta explanation\n"),
);
let annotated = compile(&annotated);
if let Some(SharedBody::DataType { definition, .. }) =
if let Some(SharedBody::DataType { definition }) =
project.elements.get_mut(&values).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Enumeration {
+9 -48
View File
@@ -42,7 +42,6 @@ fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id {
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
semantic: None,
}),
};
p.traces.insert(id, t);
@@ -329,12 +328,11 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None);
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None);
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None);
p.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
let port = |p: &mut Project, name: &str, parent, direction| {
let id = Id::new_v4();
let contract = if p.elements[&parent].kind() == Kind::Function {
@@ -742,9 +740,6 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
minimum: 0,
maximum: None,
unit: "items".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
@@ -809,40 +804,8 @@ fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references()
"Reidentification must preserve all values and order"
);
if id != p.id {
let deleted = if p
.relations
.get(&id)
.is_some_and(|r| r.kind() == RelationKind::InterfaceItemAllocation)
{
// An explicitly empty interface cannot silently accept the
// still-carried payload. Removing its definition needs a
// reviewed edit of that dependent contract in the same batch.
let mut session = Session::new(p.clone());
assert!(
session
.execute(Command::Delete(id))
.unwrap_err()
.0
.contains("port_interface_items")
);
assert_eq!(session.project(), &p);
assert_eq!(session.revision(), 0);
let item = p.relations[&id].target();
let mut commands = vec![Command::Delete(id)];
commands.extend(
p.relations
.values()
.filter(|r| {
r.kind() == RelationKind::BoundaryExchange
&& r.body.items().contains(&item)
})
.map(|r| Command::Delete(r.id)),
);
execute_batch(&p, &commands).unwrap()
} else {
execute_batch(&p, &[Command::Delete(id)])
.unwrap_or_else(|err| panic!("Delete {id}: {err}"))
};
let deleted = execute_batch(&p, &[Command::Delete(id)])
.unwrap_or_else(|err| panic!("Delete {id}: {err}"));
accepted(&deleted);
}
}
@@ -927,15 +890,13 @@ fn demo_has_both_layers_complete_contracts_and_no_checks() {
assert!(p.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material,
..
kind: ExchangeItemKind::Material
})
)));
assert!(p.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy,
..
kind: ExchangeItemKind::Energy
})
)));
}
-9
View File
@@ -13,14 +13,5 @@ path = "src/main.rs"
oa-core = { path = "../oa-core" }
iced = { version = "0.14", default-features = false, features = ["wgpu", "canvas", "tokio", "x11", "wayland", "advanced"] }
rfd = "0.15"
serde.workspace = true
serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
[dev-dependencies]
iced_runtime = "0.14"
oa-server = { path = "../oa-server" }
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio"] }
tempfile = "3"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
+8 -635
View File
@@ -1,4 +1,3 @@
mod hosted;
use crate::forms::Draft;
use crate::{
document::Document,
@@ -13,219 +12,8 @@ use oa_core::{
use serde_json::{Value, json};
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
#[cfg(test)]
mod direct_scenario_tests {
use super::*;
use crate::behavior::{
self,
scenario::{self, CanvasEvent, Event, MessageStyle, Slot},
};
fn setup() -> (App, scenario::direct_tests::Fixture) {
let f = scenario::direct_tests::fixture();
let mut app = App::with_project(f.project.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.scenario_context().unwrap().scenario, f.owner);
(app, f)
}
fn form(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(behavior::Event::Scenario(event)));
}
fn begin(app: &App, f: &scenario::direct_tests::Fixture) -> CanvasEvent {
CanvasEvent::Begin {
context: app.scenario_context().unwrap(),
slot: Slot {
parent: f.owner,
before: None,
},
sender: f.sender,
receiver: f.receiver,
style: MessageStyle::Asynchronous,
}
}
#[test]
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
let (mut app, f) = setup();
let original = app.document.bundle().clone();
let event = begin(&app, &f);
let context = app.scenario_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
assert_eq!(app.scenario_context(), Some(context));
let _ = app.update(Message::WindowResized(iced::Size::new(
size.width + 20.,
size.height,
)));
assert_ne!(app.scenario_context(), Some(context));
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
let context = app.scenario_context().unwrap();
for (area, offset) in [
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
] {
let before = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
assert_ne!(app.scenario_context(), Some(before));
let current = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
assert_eq!(
app.scenario_context(),
Some(current),
"unchanged redraw notification retains context"
);
}
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(&mut app, Event::Name("Keep my draft".into()));
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context,
scenario::ScrollArea::Page,
iced::Vector::new(0., 200.),
)));
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &original);
let current = app.scenario_context().unwrap();
for dimension in 0..3 {
let mut stale = current;
match dimension {
0 => stale.document = Id::new_v4(),
1 => stale.scenario = Id::new_v4(),
_ => stale.revision += 1,
}
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
stale,
scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(app.scenario_context(), Some(current));
}
assert!(!app.document.can_undo());
}
#[test]
fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() {
let (mut app, f) = setup();
let before = app.document.snapshot();
for dimension in 0..4 {
let mut event = begin(&app, &f);
let CanvasEvent::Begin { context, .. } = &mut event else {
unreachable!()
};
match dimension {
0 => context.document = Id::new_v4(),
1 => context.scenario = Id::new_v4(),
2 => context.revision += 1,
_ => context.epoch += 1,
}
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
let _ = app.update(Message::Field(
"/name".into(),
"Pending scenario name".into(),
));
assert!(app.draft_dirty());
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[test]
fn direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once() {
let (mut app, f) = setup();
let original = app.document.bundle().clone();
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(
&mut app,
Event::Exchange(Choice {
id: f.exchange,
label: "Contract".into(),
}),
);
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
let created = app.selected;
assert_ne!(created, f.owner);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[f.call, f.reply, created]
);
let before_reorder = app.document.bundle().clone();
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
context, f.sender,
)));
assert_eq!(
app.scenario_context(),
Some(context),
"same-owner selection retains gesture context"
);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
context,
occurrence: created,
slot: Slot {
parent: f.owner,
before: Some(f.call),
},
}));
assert_eq!(app.selected, created);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[created, f.call, f.reply]
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before_reorder);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &original);
assert_eq!(app.project(), &f.project);
assert!(!app.document.can_undo());
}
#[test]
fn direct_scenario_source_failure_and_navigation_keep_form_until_success() {
let (mut app, f) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 4000;
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
app.sync();
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(
&mut app,
Event::Exchange(Choice {
id: f.exchange,
label: "Contract".into(),
}),
);
form(&mut app, Event::Name("Long message".repeat(1024)));
let _ = app.update(Message::Page(Page::Catalog));
assert_eq!(app.page, Page::Behavior);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
form(&mut app, Event::Name("Recovered message".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
assert_eq!(app.project().named(app.selected), "Recovered message");
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
}
}
#[cfg(test)]
mod behavior_lifecycle_tests;
mod process_canvas;
mod state_diagram;
#[cfg(test)]
mod state_diagram_tests;
@@ -281,7 +69,6 @@ impl std::fmt::Display for Choice {
}
#[derive(Debug, Clone, Copy)]
pub enum Pending {
HostedOpen,
New,
Demo,
Open,
@@ -301,7 +88,6 @@ pub struct FilePrompt {
#[derive(Debug, Clone)]
pub enum Message {
Hosted(crate::hosted::Event),
PortAnchor(crate::port_edit::Event),
Connection(crate::connection_edit::Event),
Menu(crate::model_menu::Event),
@@ -320,7 +106,6 @@ pub enum Message {
SelectProject,
Search(String),
StartTransition,
StartSemanticReview(Id),
Workflow(crate::workflow::Event),
CollapseExplorer(Id),
ExplorerGroup(crate::explorer::Group),
@@ -330,7 +115,6 @@ pub enum Message {
BrowseGroup(crate::explorer::Group),
ExplorerPage(usize),
CreateKind(Kind),
Data(crate::data_view::Event),
Add(bool),
AddRelation,
PortAction(crate::port_workflow::Action),
@@ -343,9 +127,6 @@ pub enum Message {
Field(String, String),
FieldText(String, text_editor::Action),
FieldShape(String, Value),
FieldAppend(String, Value),
FieldValuePath(String, crate::data_values::FieldPathKey),
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
FieldVariant(String, Value),
FieldArray(String, Option<usize>, i32),
Apply,
@@ -363,8 +144,6 @@ pub enum Message {
BehaviorOwner(Choice),
BehaviorDiagram(Choice),
StateDiagram(crate::behavior::state_edit::Event),
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
TopologyCanvas(crate::behavior::topology::CanvasEvent),
PlaceSelected,
HideSelected,
ToggleCollapse,
@@ -413,7 +192,6 @@ pub enum Message {
}
pub struct App {
pub hosted: crate::hosted::State,
pub connection: Option<crate::connection_edit::Session>,
pub model_menu: Option<crate::model_menu::Opened>,
pub source_tree: crate::source_tree::State,
@@ -426,7 +204,6 @@ pub struct App {
pub page: Page,
pub guide: crate::guide::State,
pub behavior: crate::behavior::State,
pub data_state: crate::data_view::State,
pub inspector_tab: crate::navigation::InspectorTab,
pub layer: Layer,
pub selected: Id,
@@ -515,7 +292,6 @@ impl App {
page: Page::Diagram,
guide: crate::guide::State::default(),
behavior: Default::default(),
data_state: Default::default(),
inspector_tab: Default::default(),
layer,
selected,
@@ -529,7 +305,6 @@ impl App {
Kind::Activity
},
status: "Coastal rescue example · explore both engineering perspectives".into(),
hosted: Default::default(),
busy: false,
pending: None,
delete_pending: false,
@@ -799,14 +574,9 @@ impl App {
}
fn execute(&mut self, command: Command) -> bool {
let command = crate::port_edit::repair_owner_change(self.project(), command);
let scenario_selection = self
.behavior
.scenario
.created_selection(&command)
.or_else(|| self.behavior.topology.created_selection(&command));
match self.document.execute(command) {
Ok(selected) => {
if let Some(id) = scenario_selection.or(selected) {
if let Some(id) = selected {
self.selected = id;
}
self.sync();
@@ -849,10 +619,7 @@ impl App {
let source_version = self.source_version + 1;
let lint_running = self.lint_running;
let guide = std::mem::take(&mut self.guide);
let mut hosted = std::mem::take(&mut self.hosted);
hosted.leave_document();
*self = Self::with_project(project);
self.hosted = hosted;
self.window_size = window_size;
self.canvas_size = canvas_size;
self.source_version = source_version;
@@ -914,12 +681,8 @@ impl App {
}
}
pub fn view(&self) -> Element<'_, Message> {
let base = crate::context_menu::Popup::new(
crate::views::view(self),
crate::model_menu::popup(self),
)
.into();
crate::hosted::view::overlay(self, base)
crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
.into()
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
@@ -969,19 +732,6 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
// Scrollable geometry can change while async work or an overlay blocks
// commands. Keep the observed translation current; its passive handler
// still validates document, scenario and revision before accepting it.
if matches!(
&message,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..))
| Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::Scrolled(..)
| crate::behavior::topology::CanvasEvent::Viewport(..)
)
) {
return self.handle(message);
}
// Global subscriptions also receive keys consumed by an overlay.
if self.model_menu.is_some() && matches!(message, Message::Escape) {
self.model_menu = None;
@@ -1014,8 +764,6 @@ impl App {
Message::Field(..)
| Message::FieldText(..)
| Message::FieldShape(..)
| Message::FieldAppend(..)
| Message::FieldValuePath(..)
| Message::FieldVariant(..)
| Message::FieldArray(..)
| Message::ElementKind(_)
@@ -1031,7 +779,6 @@ impl App {
&& !matches!(
message,
Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(_, _)
| Message::SavePath(_)
@@ -1051,10 +798,6 @@ impl App {
message,
Message::Escape | Message::Request(Pending::Close(_))
) {
if self.hosted.visible {
self.hosted.visible = false;
return Task::none();
}
if self.workflow.take().is_some() {
return Task::none();
}
@@ -1079,7 +822,6 @@ impl App {
| Message::Lint(_)
| Message::Linted(_, _)
| Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(_, _)
| Message::SavePath(_)
@@ -1087,8 +829,7 @@ impl App {
| Message::Exported(_)
);
if !modal_event
&& ((self.hosted.visible && !matches!(message, Message::Hosted(_)))
|| (self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
&& ((self.workflow.is_some() && !matches!(message, Message::Workflow(_)))
|| (self.file_prompt.is_some()
&& !matches!(
message,
@@ -1146,11 +887,6 @@ impl App {
let entering_catalog =
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
let mut task = self.handle(message);
if let Some(context) = self.topology_context() {
self.behavior
.topology
.enter(context.document, context.owner);
}
if self.connection.as_ref().is_some_and(|session| {
Some(session.context) != self.route_context()
|| self.page != Page::Diagram
@@ -1160,7 +896,6 @@ impl App {
self.connection = None;
}
if previous_layer != self.layer {
self.data_state = Default::default();
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.explorer_focus = None;
self.explorer_group = None;
@@ -1193,117 +928,8 @@ impl App {
task
}
}
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
let scenario = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
.then_some(crate::behavior::scenario::Context {
document: self.canvas_document,
scenario,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::Hosted(event) => return self.hosted_event(event),
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
if let CanvasEvent::Scrolled(_, area, offset) = event {
// Inner and outer scroll notifications may come from the
// same frame. Accept both across the first epoch bump,
// while still rejecting another owner/document/revision.
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.revision == context.revision
}) && self.behavior.scenario.scrolled(area, offset)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if self.scenario_context() != Some(context) {
return Task::none();
}
match event {
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id)
== Some(context.scenario)
{
self.select(id);
self.selected_node = None;
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.scenario.choose_tool(context.scenario, tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status =
"Apply or cancel pending edits before changing the scenario."
.into();
return Task::none();
}
match mutation {
CanvasEvent::Begin {
slot,
sender,
receiver,
style,
..
} => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_message(
self.project(),
context,
slot,
sender,
receiver,
style,
);
self.behavior.scenario = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the message exchange and apply at the highlighted sequence position.".into();
}
Err(error) => self.status = error,
}
}
CanvasEvent::Reorder {
occurrence, slot, ..
} => {
match scenario::reorder(
self.project(),
context.scenario,
occurrence,
slot,
) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.selected_node = None;
self.refresh_draft();
self.status = "Occurrence reordered within its sequence · Undo restores the previous order".into();
}
}
Err(error) => self.status = error,
}
}
_ => unreachable!(),
}
}
}
}
Message::TopologyCanvas(event) => return self.edit_process_canvas(event),
Message::StateDiagram(event) => return self.edit_state_diagram(event),
Message::BehaviorOwner(choice) => {
if self.apply()
@@ -1498,32 +1124,6 @@ impl App {
self.status = e;
}
}
Message::FieldAppend(path, value) => {
if let Some(draft) = &mut self.draft
&& let Err(error) = draft.append(&path, value)
{
self.status = error;
}
}
Message::FieldValueBrowse(path, scope, page) => {
if let Some(draft) = &mut self.draft
&& draft
.value
.pointer(&path)
.is_some_and(|row| row.get("expression").is_some())
{
draft
.value_browsers
.insert(path, crate::forms::ValueBrowser { scope, page });
}
}
Message::FieldValuePath(path, key) => {
if let Some(draft) = &mut self.draft
&& let Err(error) = draft.value_path(&path, key)
{
self.status = error;
}
}
Message::FieldVariant(path, value) => {
if let Some(d) = &mut self.draft {
let result = d.materialize().and_then(|current| {
@@ -1693,57 +1293,12 @@ impl App {
}
}
}
Message::StartSemanticReview(selected) => {
if self.apply() {
match crate::semantic_review::State::new(self.project(), selected) {
Ok(state) => {
self.workflow =
Some(crate::workflow::Workflow::Semantic(Box::new(state)))
}
Err(error) => self.status = error,
}
}
}
Message::Workflow(event) => {
if matches!(event, crate::workflow::Event::Cancel) {
self.workflow = None;
return Task::none();
}
if let Some(mut workflow) = self.workflow.take() {
if let crate::workflow::Workflow::Semantic(state) = &mut workflow {
match event {
crate::workflow::Event::Semantic(event) => {
state.update(self.project(), event)
}
crate::workflow::Event::Apply => {
let focus = state.target(self.project());
match state.command(self.project()) {
Ok(command) => {
if self.execute(command) {
if let Some(target) = focus {
self.selected = target;
if let Some(layer) = self.project().layer_of(target)
{
self.layer = layer;
}
self.refresh_draft();
}
self.status = "Semantic review applied · model, source and baseline form one undoable change".into();
return Task::none();
}
state.error = Some(self.status.clone());
}
Err(error) => {
self.status = error.clone();
state.error = Some(error);
}
}
}
_ => {}
}
self.workflow = Some(workflow);
return Task::none();
}
let stay_in_catalog = self.page == Page::Catalog
&& matches!(&workflow, crate::workflow::Workflow::Ports(_));
let transition_focus = match &workflow {
@@ -1829,19 +1384,6 @@ impl App {
self.fit();
}
}
Message::Data(event) => {
if self.data_state.update(&event) {
return Task::none();
}
if self.apply() {
match crate::data_view::command(self.project(), self.layer, event) {
Ok(command) => {
self.execute(command);
}
Err(error) => self.status = error,
}
}
}
Message::CreateKind(kind) => self.create_kind = kind,
Message::Add(child) => {
if self.apply() {
@@ -1936,9 +1478,6 @@ impl App {
Ok(Some(command)) => {
if self.execute(command) {
self.behavior.acknowledge_commit();
if self.topology_context().is_some() {
return self.reveal_process_selection();
}
}
}
Ok(None) => {}
@@ -2510,9 +2049,6 @@ impl App {
return self.handle(Message::Save(false));
}
Message::Save(save_as) => {
if !save_as && self.hosted.session.is_some() {
return self.save_hosted();
}
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
self.continue_after_save = false;
self.pending = None;
@@ -2564,9 +2100,6 @@ impl App {
}
self.disk_project = Some(disk);
self.path = Some(receipt.path);
self.hosted.session = None;
self.hosted.review = None;
self.hosted.uncertain = None;
self.document.mark_saved(receipt.snapshot);
self.prune_closed_files(!self.document.dirty());
self.status = "All source files saved".into();
@@ -2670,16 +2203,7 @@ impl App {
Err(e) => e,
};
}
Message::WindowResized(size) => {
// The canvas can retain its capped size during a real window
// resize. Invalidate any sequence pointer/slot gesture anyway.
if self.window_size != size
&& (self.scenario_context().is_some() || self.topology_context().is_some())
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.window_size = size;
}
Message::WindowResized(size) => self.window_size = size,
Message::CanvasResized(size) => {
let first_measurement = self.canvas_size.is_none();
let size_changed = self.canvas_size != Some(size);
@@ -2905,7 +2429,6 @@ impl App {
fn proceed(&mut self, pending: Pending) -> Task<Message> {
self.pending = None;
match pending {
Pending::HostedOpen => self.open_hosted(),
Pending::New => {
self.reset(Project::empty(), None);
Task::none()
@@ -3054,20 +2577,10 @@ impl App {
.discovered(&disk.bundle, disk.reachable.clone());
self.show_source_file(false);
self.source_diagnostics = self.document.compiled().diagnostics.clone();
self.migration = self.document.compiled().decoded.migration.clone();
if self.migration.is_some() {
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
self.page = Page::Validation;
} else {
self.status =
"Source project opened. Included files and comments are preserved.".into();
}
self.status =
"Source project opened. Included files and comments are preserved.".into();
}
Err(error) => {
// Installing a local repair workspace changes its destination,
// even while the last valid graphical model stays visible. A
// failed/cancelled Open that installs nothing retains its binding.
self.hosted.leave_document();
self.canvas_document = Id::new_v4();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.path = Some(path);
@@ -3695,9 +3208,7 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
&& let Some(source_files::DiskExpectation::Bytes(bytes)) =
disk.expectations.get(&disk.bundle.entry)
&& let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model)
// Source1/2 require the historical single-document adapters.
// Source3+ must retain its authored bundle and token migration.
&& decoded.migration.as_ref().is_some_and(|report| report.from_schema <= 2)
&& decoded.migration.is_some()
{
return Ok(LoadedSnapshot::Legacy(
path,
@@ -3782,97 +3293,6 @@ mod source_project_tests {
Ok(())
}
#[test]
fn source3_native_open_preserves_report_comments_nesting_and_save_reopen() {
let original = include_str!("../../oa-core/tests/fixtures/v3-data-contract.arcadia");
for split in [false, true] {
let directory = Directory::new();
let path = directory.0.join("project.arcadia");
let mut authored = std::collections::BTreeMap::new();
if split {
let start = original.find("{\n").unwrap() + 2;
let end = original.rfind('}').unwrap();
authored.insert(
"project.arcadia",
format!(
"{} include \"dictionary.arcadia\";\n}}\n",
&original[..start]
),
);
authored.insert("dictionary.arcadia", original[start..end].to_owned());
} else {
authored.insert("project.arcadia", original.to_owned());
}
for (file, source) in &authored {
std::fs::write(directory.0.join(file), source).unwrap();
}
let mut app = App::with_project(Project::empty());
send(
&mut app,
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
);
assert_eq!(app.migration.as_ref().unwrap().from_schema, 3);
assert_eq!(app.page, Page::Validation);
assert_eq!(app.path, Some(path.clone()));
assert_eq!(app.document.bundle().files.len(), authored.len());
assert!(
app.dirty(),
"accepted source migration needs explicit saving"
);
for (file, source) in &authored {
assert_eq!(
std::fs::read_to_string(directory.0.join(file)).unwrap(),
*source
);
}
let text = app
.document
.bundle()
.files
.values()
.cloned()
.collect::<String>();
assert!(text.contains("// Reused record parameters retain their own identities."));
assert!(text.contains("// Frozen authored schema3 compatibility input."));
let packet = app
.project()
.elements
.values()
.find(|e| e.alias == "packet")
.unwrap()
.id;
let origin = app
.project()
.elements
.values()
.find(|e| e.alias == "origin")
.unwrap()
.id;
assert_eq!(
app.document.compiled().index.lexical_owners.get(&origin),
Some(&packet)
);
let before = app.document.snapshot();
save(&mut app, path.clone()).unwrap();
assert!(!app.dirty());
for (file, source) in &before.bundle.files {
assert_eq!(
std::fs::read_to_string(directory.0.join(file)).unwrap(),
*source
);
}
let mut reopened = App::with_project(Project::empty());
send(
&mut reopened,
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
);
assert!(reopened.migration.is_none());
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.document.bundle(), app.document.bundle());
assert!(!reopened.dirty());
}
}
fn pending_journal(directory: &Directory, before: Option<&str>) -> (PathBuf, PathBuf) {
let path = directory.0.join("project.arcadia");
let journal = directory.0.join(".arcadia-save-project.arcadia.journal");
@@ -4427,50 +3847,3 @@ mod lint_tests {
assert!(app.project().elements.is_empty());
}
}
#[cfg(test)]
mod data_paging_lifecycle_tests {
use super::*;
#[test]
fn paging_preserves_same_context_and_resets_on_layer_or_document_replacement() {
let mut p = Project::empty();
let mut oa = None;
for layer in [Layer::Oa, Layer::Sa] {
for index in 0..70 {
let id = p
.add_element(
Kind::Interface,
layer,
&format!("Interface {layer} {index}"),
None,
)
.unwrap();
if layer == Layer::Oa {
oa = Some(id);
}
}
}
let mut app = App::with_project(p.clone());
let page = Message::Data(crate::data_view::Event::Page {
section: crate::data_view::Section::Catalog,
owner: None,
page: 2,
});
let _ = app.update(Message::Page(Page::Data));
let _ = app.update(page.clone());
let retained = app.data_state.clone();
let _ = app.update(Message::Page(Page::Catalog));
let _ = app.update(Message::Page(Page::Data));
let _ = app.update(Message::Layer(Layer::Oa));
assert_eq!(app.data_state, retained);
let _ = app.update(Message::Layer(Layer::Sa));
assert_eq!(app.data_state, crate::data_view::State::default());
let _ = app.update(page.clone());
let _ = app.update(Message::Select(oa.unwrap()));
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.data_state, crate::data_view::State::default());
let _ = app.update(page);
app.reset(p, None);
assert_eq!(app.data_state, crate::data_view::State::default());
}
}
@@ -322,18 +322,14 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
Message::Field("/name".into(), "Unapplied state name".into()),
);
assert!(app.draft_dirty());
let context = behavior::topology::Context {
document: app.canvas_document,
owner: fixture.machine,
revision: app.document.revision(),
epoch: app.canvas_epoch,
};
for event in [
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
behavior::topology::CanvasEvent::Zoom(context, None),
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
900., 600.,
))),
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
behavior::Event::Topology(behavior::topology::Event::Fit),
] {
send(app, Message::TopologyCanvas(event));
send(app, Message::Behavior(event));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(app.draft_dirty());
assert!(!app.behavior.has_pending());
-568
View File
@@ -1,568 +0,0 @@
//! Desktop destination lifecycle; HTTP never becomes a second editing history.
use super::*;
use crate::hosted::{
Event, Operation, Outcome, Request, Review, Session,
client::{Connection, Failure, Remote},
};
impl App {
pub fn hosted_label(&self) -> String {
self.hosted.session.as_ref().map_or_else(
|| {
if self.dirty() {
"Unsaved changes".into()
} else if self.path.is_some() {
"All changes saved".into()
} else {
"Local project".into()
}
},
|session| {
format!(
"Hosted · revision {} · {}",
session.revision,
if self.dirty() {
"local changes"
} else {
"saved"
}
)
},
)
}
pub fn save_label(&self) -> &'static str {
if self.hosted.session.is_some() {
"Save to server"
} else {
"Save all"
}
}
fn hosted_problem(&mut self, detail: impl Into<String>) {
let detail = detail.into();
self.status = detail.clone();
self.hosted.error = Some(Failure {
detail,
code: "desktop_hosted".into(),
status: None,
uncertain: false,
published: None,
});
}
fn hosted_ready(&mut self) -> bool {
if self.pending_recovery.is_some() {
self.continue_after_save = false;
self.pending = None;
self.hosted.visible = false;
self.page = Page::Text;
self.status =
"Recover the interrupted source save before changing storage destinations.".into();
return false;
}
if !self.apply_behavior() || !self.apply_source() || !self.apply() {
self.hosted.visible = false;
self.continue_after_save = false;
self.pending = None;
return false;
}
if self.source_workspace.detached_dirty() {
self.hosted.visible = false;
self.page = Page::Text;
self.status =
"Reattach or revert detached source drafts before saving to a server.".into();
self.continue_after_save = false;
self.pending = None;
return false;
}
true
}
fn hosted_start(
&mut self,
operation: Operation,
connection: Connection,
resource_id: Option<Id>,
) -> Task<Message> {
let request = Request {
id: Id::new_v4(),
document: self.canvas_document,
revision: self.document.revision(),
operation,
connection,
resource_id,
etag: self.hosted.session.as_ref().map(|s| s.etag.clone()),
snapshot: self.document.snapshot(),
};
self.hosted.request = Some(request.clone());
if operation != Operation::Check {
self.hosted.error = None;
}
self.busy = true;
self.status = match operation {
Operation::Catalog(_) => "Reading hosted models…",
Operation::Open(_) => "Opening hosted model…",
Operation::Publish => "Publishing source project…",
Operation::Save => "Saving to server…",
Operation::Check => "Checking current server revision…",
}
.into();
let query = self.hosted.query.clone();
let after = if operation == Operation::Catalog(true) {
self.hosted.next
} else {
None
};
Task::perform(
async move {
let result = match operation {
Operation::Catalog(_) => request
.connection
.catalog(&query, after)
.await
.map(Outcome::Catalog),
Operation::Open(id) => request.connection.open(id).await.map(Outcome::Remote),
Operation::Publish => request
.connection
.publish(request.snapshot.bundle.clone())
.await
.map(Outcome::Remote),
Operation::Save => request
.connection
.save(
request.resource_id.expect("save has a resource"),
request.etag.as_deref().expect("save has a precondition"),
request.snapshot.bundle.clone(),
)
.await
.map(Outcome::Remote),
Operation::Check => request
.connection
.open(request.resource_id.expect("check has a resource"))
.await
.map(Outcome::Remote),
};
Message::Hosted(Event::Completed(request.id, request.document, result))
},
|message| message,
)
}
pub(super) fn open_hosted(&mut self) -> Task<Message> {
if self.pending_recovery.is_some() {
self.hosted_problem(
"Recover the interrupted source save before opening another model.",
);
return Task::none();
}
if let Some((connection, id)) = self.hosted.open_target.take() {
return self.hosted_start(Operation::Open(id), connection, Some(id));
}
Task::none()
}
pub(super) fn save_hosted(&mut self) -> Task<Message> {
if !self.hosted_ready() {
return Task::none();
}
if self.hosted.review.is_some() {
self.hosted.visible = true;
self.hosted_problem("The server has a different version. Review it before saving; your local work is retained.");
self.continue_after_save = false;
self.pending = None;
return Task::none();
}
if let Some(session) = &self.hosted.session {
return self.hosted_start(
Operation::Save,
session.connection.clone(),
Some(session.resource_id),
);
}
Task::none()
}
fn check_hosted(&mut self) -> Task<Message> {
let destination = self
.hosted
.uncertain
.as_ref()
.and_then(|r| r.resource_id.map(|id| (r.connection.clone(), id)))
.or_else(|| {
self.hosted
.session
.as_ref()
.map(|s| (s.connection.clone(), s.resource_id))
});
if let Some((connection, id)) = destination {
return self.hosted_start(Operation::Check, connection, Some(id));
}
self.hosted_problem("The publication result is unknown. Refresh the catalog and inspect the hosted models before choosing another action.");
Task::none()
}
fn acknowledge_hosted(&mut self, request: &Request, remote: Remote) -> Task<Message> {
if remote.compiled.bundle != request.snapshot.bundle {
self.hosted.review = Some(Review {
connection: request.connection.clone(),
remote,
});
self.hosted.review_path = None;
self.hosted.visible = true;
self.hosted_problem("The server returned a different source version. Review it; the local history has been preserved.");
self.continue_after_save = false;
self.pending = None;
return Task::none();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.path = None;
self.disk_project = None;
self.document.mark_saved(request.snapshot.clone());
self.hosted.uncertain = None;
self.hosted.review = None;
self.hosted.review_path = None;
self.hosted.error = None;
self.status = format!(
"Hosted revision {} saved. Undo remains local until your next Save.",
remote.revision
);
// A late receipt may save an older snapshot; it must not close newer work.
if self.continue_after_save {
self.continue_after_save = false;
if request.revision == self.document.revision()
&& !self.dirty()
&& let Some(pending) = self.pending.take()
{
return self.proceed(pending);
}
self.pending = None;
}
Task::none()
}
pub(super) fn hosted_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::Show => {
self.hosted.visible = true;
self.model_menu = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Event::Close => self.hosted.visible = false,
Event::Base(base) => {
self.hosted.base = base;
self.hosted.connection = None;
self.hosted.catalog.clear();
self.hosted.invalidate_catalog();
self.hosted.selected = None;
}
Event::Token(token) => {
self.hosted.token = token;
self.hosted.connection = None;
self.hosted.catalog.clear();
self.hosted.selected = None;
self.hosted.invalidate_catalog();
}
Event::Query(query) => {
if self.hosted.query != query {
self.hosted.query = query;
self.hosted.selected = None;
self.hosted.invalidate_catalog();
}
}
Event::Connect => match Connection::new(&self.hosted.base, self.hosted.token.clone()) {
Ok(connection) => {
self.hosted.invalidate_catalog();
return self.hosted_start(Operation::Catalog(false), connection, None);
}
Err(error) => {
self.status = error.detail.clone();
self.hosted.error = Some(error);
}
},
Event::More => {
if !self.hosted.catalog_stale
&& self.hosted.next.is_some()
&& let Some(connection) = &self.hosted.connection
{
return self.hosted_start(Operation::Catalog(true), connection.clone(), None);
}
}
Event::Select(id) => self.hosted.selected = Some(id),
Event::OpenSelected => {
if let (Some(connection), Some(id)) =
(&self.hosted.connection, self.hosted.selected)
{
self.hosted.open_target = Some((connection.clone(), id));
self.hosted.visible = false;
return self.handle(Message::Request(Pending::HostedOpen));
}
}
Event::Publish => {
if !self.hosted_ready() {
return Task::none();
}
let connection = match Connection::new(&self.hosted.base, self.hosted.token.clone())
{
Ok(connection) => connection,
Err(error) => {
self.status = error.detail.clone();
self.hosted.error = Some(error);
return Task::none();
}
};
return self.hosted_start(Operation::Publish, connection, None);
}
Event::Save => return self.save_hosted(),
Event::Check => return self.check_hosted(),
Event::ReviewFile(path) => {
if let Some(source) = self
.hosted
.review
.as_ref()
.and_then(|remote| remote.remote.compiled.bundle.files.get(&path))
{
self.hosted.review_editor = text_editor::Content::with_text(source);
self.hosted.review_path = Some(path);
}
}
Event::ReviewAction(action) => {
if !action.is_edit() {
self.hosted.review_editor.perform(action);
}
}
Event::ReviewBack => self.hosted.review_path = None,
Event::Reload => {
if !self.hosted_ready() {
return Task::none();
}
if let Some(review) = self.hosted.review.clone() {
let remote = review.remote;
let connection = review.connection;
{
match self.document.apply_compiled((*remote.compiled).clone()) {
Ok(_) => {
self.document.mark_saved(remote.compiled.clone());
self.hosted.session = Some(Session {
connection,
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.path = None;
self.disk_project = None;
self.hosted.review = None;
self.hosted.review_path = None;
self.hosted.uncertain = None;
self.hosted.error = None;
self.sync();
self.hosted.visible = false;
self.status = "Server version loaded. Undo restores the previous accepted local version.".into();
}
Err(error) => self.hosted_problem(error.to_string()),
}
}
}
}
Event::Local => {
self.hosted.visible = false;
return self.handle(Message::Save(true));
}
Event::Forget => {
if self.hosted.session.is_some() {
self.hosted_problem("Save as local before disconnecting this hosted model.");
} else {
self.hosted = Default::default();
self.hosted.visible = true;
}
}
Event::Completed(id, document, result) => {
let Some(request) = self.hosted.request.as_ref() else {
return Task::none();
};
if request.id != id
|| request.document != document
|| document != self.canvas_document
{
return Task::none();
}
let request = self.hosted.request.take().expect("matching request");
self.busy = false;
// A catalog is a read snapshot, never a live mirror. Known or
// uncertain writes and observed newer revisions invalidate only
// the service whose results are currently displayed.
let catalog_changed = match &result {
Ok(Outcome::Remote(remote)) => {
matches!(request.operation, Operation::Publish | Operation::Save)
// A changed bound head can enter/leave the active
// filter or page even when no row was displayed.
|| self.hosted.session.as_ref().is_some_and(|session| {
session.connection.base() == request.connection.base()
&& session.resource_id == remote.resource_id
&& session.etag != remote.etag
})
|| self.hosted.uncertain.as_ref().is_some_and(|failed| {
failed.operation == Operation::Save
&& failed.connection.base() == request.connection.base()
&& failed.resource_id == Some(remote.resource_id)
&& failed.etag.as_ref().is_some_and(|etag| etag != &remote.etag)
})
|| self.hosted.catalog.iter().any(|item| {
item.resource_id == remote.resource_id
&& item.revision != remote.revision
})
}
Err(error) => {
matches!(request.operation, Operation::Publish | Operation::Save)
&& (error.uncertain || error.status == Some(412))
}
Ok(Outcome::Catalog(_)) => false,
};
if catalog_changed
&& self
.hosted
.connection
.as_ref()
.is_some_and(|connection| connection.base() == request.connection.base())
{
self.hosted.invalidate_catalog();
}
match result {
Err(error) => {
let conflict =
error.status == Some(412) && request.operation == Operation::Save;
if error.uncertain || conflict {
let mut failed = request.clone();
if let Some(id) = error.published {
failed.resource_id = Some(id);
}
if matches!(request.operation, Operation::Save | Operation::Publish) {
self.hosted.uncertain = Some(failed);
}
}
self.status = error.detail.clone();
self.hosted.error = Some(error);
self.hosted.visible = true;
self.continue_after_save = false;
self.pending = None;
if conflict {
return self.check_hosted();
}
}
Ok(Outcome::Catalog(catalog)) => {
if let Some(session) = &mut self.hosted.session
&& session.connection.base() == request.connection.base()
{
session.connection = request.connection.clone();
}
if let Some(failed) = &mut self.hosted.uncertain
&& failed.connection.base() == request.connection.base()
{
failed.connection = request.connection.clone();
}
if let Some(review) = &mut self.hosted.review
&& review.connection.base() == request.connection.base()
{
review.connection = request.connection.clone();
}
self.hosted.connection = Some(request.connection);
if request.operation != Operation::Catalog(true) {
self.hosted.catalog.clear();
}
for item in catalog.items {
if !self
.hosted
.catalog
.iter()
.any(|old| old.resource_id == item.resource_id)
{
self.hosted.catalog.push(item);
}
}
self.hosted.next = catalog.next_after;
self.hosted.catalog_stale = false;
if !self
.hosted
.catalog
.iter()
.any(|item| Some(item.resource_id) == self.hosted.selected)
{
self.hosted.selected = None;
}
self.status = format!("{} hosted models shown", self.hosted.catalog.len());
}
Ok(Outcome::Remote(remote)) => match request.operation {
Operation::Open(_) => {
match Document::from_bundle(remote.compiled.bundle.clone(), LIMITS) {
Ok(document) => {
self.reset(document.project().clone(), None);
self.document = document;
self.source_workspace =
Workspace::new(self.document.bundle(), true);
self.sync();
self.hosted.session = Some(Session {
connection: request.connection,
resource_id: remote.resource_id,
revision: remote.revision,
etag: remote.etag,
});
self.status = "Hosted source project opened. Edits and Undo stay local until Save to server.".into();
}
Err(error) => self.hosted_problem(error.to_string()),
}
}
Operation::Publish | Operation::Save => {
return self.acknowledge_hosted(&request, remote);
}
Operation::Check => {
let acknowledged = self
.hosted
.uncertain
.as_ref()
.filter(|failed| {
failed.connection.base() == request.connection.base()
&& failed.resource_id == Some(remote.resource_id)
&& failed.snapshot.bundle == remote.compiled.bundle
})
.cloned();
if let Some(failed) = acknowledged {
return self.acknowledge_hosted(&failed, remote);
}
if self.hosted.session.as_ref().is_some_and(|session| {
session.connection.base() == request.connection.base()
&& session.resource_id == remote.resource_id
&& session.etag == remote.etag
}) {
self.hosted.review = None;
self.hosted.review_path = None;
self.status =
"The server revision is unchanged. Local edits are retained."
.into();
} else {
self.status = format!(
"Server revision {} is available. Review before replacing local work.",
remote.revision
);
self.hosted.review = Some(Review {
connection: request.connection.clone(),
remote,
});
self.hosted.review_path = None;
self.hosted.visible = true;
}
}
Operation::Catalog(_) => {
self.hosted_problem("Unexpected response to catalog request.")
}
},
}
}
}
Task::none()
}
}
#[cfg(test)]
mod tests;
-817
View File
@@ -1,817 +0,0 @@
use super::*;
use crate::hosted::client::Secret;
fn client() -> Connection {
Connection::new("http://127.0.0.1:8787", Secret::default()).unwrap()
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Hosted(event));
}
fn rename(app: &mut App, name: &str) {
app.document
.execute(Command::Rename(app.project().id, name.into()))
.unwrap();
app.sync();
}
fn bind(app: &mut App) -> Id {
let resource = Id::new_v4();
app.hosted.session = Some(Session {
connection: client(),
resource_id: resource,
revision: 1,
etag: "\"opaque-base\"".into(),
});
resource
}
fn remote(snapshot: Arc<CompiledSourceProject>, resource_id: Id, revision: u64) -> Remote {
Remote {
resource_id,
revision,
etag: format!("\"opaque-{revision}\""),
compiled: snapshot,
}
}
fn complete(app: &mut App, result: Result<Outcome, Failure>) {
let r = app.hosted.request.as_ref().unwrap();
send(app, Event::Completed(r.id, r.document, result));
}
fn failure(status: Option<u16>, uncertain: bool) -> Failure {
Failure {
detail: "Test failure".into(),
code: "test".into(),
status,
uncertain,
published: None,
}
}
#[test]
fn hosted_publish_acknowledges_only_the_sent_snapshot_and_retains_undo() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Published revision");
app.path = Some(PathBuf::from("local.arcadia"));
send(&mut app, Event::Publish);
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
assert!(app.busy);
rename(&mut app, "Later local edit");
let resource = Id::new_v4();
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 1))));
assert!(!app.busy);
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
assert!(app.path.is_none());
assert_eq!(app.project().name, "Later local edit");
assert!(app.document.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.project().name, "Published revision");
assert!(!app.document.dirty());
}
#[test]
fn hosted_conflict_fetches_review_without_adoption_and_reload_is_undoable() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let mut other = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
other
.execute(Command::Rename(other.project().id, "Other engineer".into()))
.unwrap();
rename(&mut app, "Local engineering work");
let local = app.document.snapshot();
send(&mut app, Event::Save);
complete(&mut app, Err(failure(Some(412), false)));
assert_eq!(
app.hosted.request.as_ref().unwrap().operation,
Operation::Check
);
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
complete(
&mut app,
Ok(Outcome::Remote(remote(other.snapshot(), resource, 2))),
);
assert!(Arc::ptr_eq(&local, &app.document.snapshot()));
assert_eq!(app.hosted.session.as_ref().unwrap().etag, "\"opaque-base\"");
send(&mut app, Event::Save);
assert!(!app.busy);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Reload);
assert_eq!(app.project().name, "Other engineer");
assert!(!app.document.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.project().name, "Local engineering work");
assert!(app.document.dirty());
}
#[test]
fn hosted_unknown_save_is_acknowledged_by_exact_readback_without_retry() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
rename(&mut app, "Saved despite lost response");
send(&mut app, Event::Save);
let captured = app.hosted.request.as_ref().unwrap().snapshot.clone();
complete(&mut app, Err(failure(None, true)));
assert!(app.document.dirty());
assert!(!app.busy);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Check);
complete(&mut app, Ok(Outcome::Remote(remote(captured, resource, 2))));
assert!(!app.document.dirty());
assert!(app.hosted.uncertain.is_none());
assert_eq!(app.hosted.session.as_ref().unwrap().revision, 2);
}
#[test]
fn hosted_stale_completions_do_not_clear_current_busy_or_adopt_another_document() {
let mut app = App::with_project(Project::empty());
bind(&mut app);
send(&mut app, Event::Save);
let request = app.hosted.request.as_ref().unwrap().clone();
send(
&mut app,
Event::Completed(Id::new_v4(), request.document, Err(failure(None, true))),
);
assert!(app.busy);
assert_eq!(app.hosted.request.as_ref().unwrap().id, request.id);
app.reset(Project::empty(), None);
let current = app.document.snapshot();
send(
&mut app,
Event::Completed(
request.id,
request.document,
Ok(Outcome::Remote(remote(request.snapshot, Id::new_v4(), 3))),
),
);
assert!(Arc::ptr_eq(&current, &app.document.snapshot()));
assert!(app.hosted.session.is_none());
}
#[test]
fn hosted_invalid_and_detached_source_drafts_never_start_network_writes() {
let mut app = App::with_project(Project::empty());
bind(&mut app);
app.source_workspace
.edit("arcadia 5; ?".into(), app.source_editor.cursor());
let draft = app.source_workspace.bundle();
let accepted = app.document.snapshot();
send(&mut app, Event::Save);
assert!(app.hosted.request.is_none());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), draft);
assert!(!app.busy);
}
#[test]
fn hosted_open_waits_for_unsaved_guard_and_failed_open_preserves_current_document() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Keep this work");
app.hosted.connection = Some(client());
app.hosted.selected = Some(Id::new_v4());
send(&mut app, Event::OpenSelected);
assert!(matches!(app.pending, Some(Pending::HostedOpen)));
assert!(app.hosted.request.is_none());
let _ = app.update(Message::Discard);
let previous = app.document.snapshot();
complete(&mut app, Err(failure(Some(401), false)));
assert!(Arc::ptr_eq(&previous, &app.document.snapshot()));
assert_eq!(app.project().name, "Keep this work");
assert!(app.document.dirty());
}
#[test]
fn hosted_only_successful_source_save_as_changes_the_active_destination() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
rename(&mut app, "Local fallback");
let failure = SaveFailure {
document: app.canvas_document,
message: "Disk failed".into(),
recovery: None,
};
let _ = app.update(Message::Saved(Err(failure)));
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join(&app.document.bundle().entry);
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
target.clone(),
None,
)
.unwrap();
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(app.hosted.session.is_none());
assert_eq!(app.path, Some(target));
assert!(!app.document.dirty());
assert!(app.document.can_undo());
}
#[test]
fn hosted_modal_blocks_underlying_edits_and_credentials_are_redacted() {
let mut app = App::with_project(Project::empty());
send(&mut app, Event::Show);
let original = app.document.snapshot();
let _ = app.update(Message::Command(Command::Rename(
app.project().id,
"Underlying edit".into(),
)));
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
let token = "a-secret-never-in-a-diagnostic";
let event = Message::Hosted(Event::Token(Secret(token.into())));
assert!(!format!("{event:?}").contains(token));
let _ = app.update(event);
send(&mut app, Event::Forget);
assert!(app.hosted.token.0.is_empty());
}
#[test]
fn hosted_detached_drafts_and_recovery_remain_available_after_rejected_publish() {
let mut app = App::with_project(Project::empty());
let mut bundle = app.document.bundle().clone();
bundle
.files
.insert("detached.arcadia".into(), "// kept draft".into());
let reachable = app.document.bundle().files.keys().cloned().collect();
app.source_workspace.discovered(&bundle, reachable);
app.source_workspace
.switch("detached.arcadia", app.source_editor.cursor());
app.source_workspace.edit(
"// unsaved detached rationale".into(),
app.source_editor.cursor(),
);
send(&mut app, Event::Publish);
assert!(app.hosted.request.is_none());
assert!(app.source_workspace.detached_dirty());
assert_eq!(
app.source_workspace.files["detached.arcadia"].text,
"// unsaved detached rationale"
);
app.pending_recovery = Some(PathBuf::from("interrupted.arcadia"));
send(&mut app, Event::Publish);
assert!(app.hosted.request.is_none());
assert_eq!(
app.pending_recovery,
Some(PathBuf::from("interrupted.arcadia"))
);
}
#[test]
fn hosted_failed_cross_service_publication_checks_and_reloads_its_own_resource() {
let mut app = App::with_project(Project::empty());
let old_id = bind(&mut app);
app.hosted.base = "http://127.0.0.1:9876".into();
rename(&mut app, "New publication");
send(&mut app, Event::Publish);
let new_id = Id::new_v4();
let mut error = failure(None, true);
error.published = Some(new_id);
complete(&mut app, Err(error));
send(&mut app, Event::Check);
let request = app.hosted.request.as_ref().unwrap();
assert_eq!(request.resource_id, Some(new_id));
assert_eq!(request.connection.base(), "http://127.0.0.1:9876/");
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
changed
.execute(Command::Rename(
changed.project().id,
"Changed after publication".into(),
))
.unwrap();
complete(
&mut app,
Ok(Outcome::Remote(remote(changed.snapshot(), new_id, 2))),
);
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, old_id);
send(&mut app, Event::Reload);
let session = app.hosted.session.as_ref().unwrap();
assert_eq!(session.resource_id, new_id);
assert_eq!(session.connection.base(), "http://127.0.0.1:9876/");
assert_eq!(app.project().name, "Changed after publication");
}
async fn dispatch(app: &mut App, message: Message) {
use iced::futures::StreamExt;
let mut tasks = vec![app.update(message)];
while let Some(task) = tasks.pop() {
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
while let Some(action) = stream.next().await {
if let iced_runtime::Action::Output(message) = action {
tasks.push(app.update(message));
}
}
}
}
}
#[tokio::test]
async fn hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reload_and_open() {
let directory = tempfile::tempdir().unwrap();
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
let token = "current-secret-for-hosted-regression-0123456789";
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, oa_server::app(store, Some(token.into())))
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
let mut app = App::with_project(Project::empty());
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Hosted rationale\n");
app.document.apply_bundle(bundle).unwrap();
app.sync();
app.hosted.base = base.clone();
app.hosted.token = Secret(token.into());
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert!(app.hosted.catalog.is_empty());
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
assert!(app.hosted.catalog_stale);
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
dispatch(&mut app, Message::Hosted(Event::Close)).await;
dispatch(&mut app, Message::Hosted(Event::Show)).await;
assert!(app.hosted.catalog_stale);
assert!(
app.hosted.request.is_none(),
"Showing the dialog does not poll"
);
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog.len(), 1);
assert_eq!(app.hosted.catalog[0].revision, 1);
dispatch(&mut app, Message::Hosted(Event::Close)).await;
let session = app
.hosted
.session
.clone()
.expect("publish binds a resource");
assert!(!app.busy);
assert!(!app.document.dirty());
assert!(
app.document.bundle().files[&app.document.bundle().entry].contains("// Hosted rationale")
);
let project_id = app.project().id;
dispatch(
&mut app,
Message::Command(Command::Rename(project_id, "Graphical update".into())),
)
.await;
dispatch(&mut app, Message::Save(false)).await;
assert!(app.hosted.catalog_stale);
assert_eq!(
app.hosted.catalog[0].revision, 1,
"Old results stay identified as a snapshot"
);
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog[0].revision, 2);
assert_eq!(app.hosted.catalog[0].name, "Graphical update");
let external = Connection::new(&base, Secret(token.into())).unwrap();
let remote = external.open(session.resource_id).await.unwrap();
assert_eq!(remote.compiled.decoded.project.name, "Graphical update");
let mut other = Document::from_bundle(remote.compiled.bundle.clone(), LIMITS).unwrap();
other
.execute(Command::Rename(project_id, "API engineering change".into()))
.unwrap();
external
.save(session.resource_id, &remote.etag, other.bundle().clone())
.await
.unwrap();
dispatch(
&mut app,
Message::Command(Command::Rename(project_id, "Local pending change".into())),
)
.await;
dispatch(&mut app, Message::Save(false)).await;
assert!(app.hosted.review.is_some());
assert_eq!(app.project().name, "Local pending change");
assert!(app.document.dirty());
// Simulate a retired credential in every preexisting reconciliation handle.
let old =
Connection::new(&base, Secret("retired-secret-012345678901234567890".into())).unwrap();
app.hosted.session.as_mut().unwrap().connection = old.clone();
app.hosted.uncertain.as_mut().unwrap().connection = old.clone();
app.hosted.review.as_mut().unwrap().connection = old;
dispatch(&mut app, Message::Hosted(Event::Connect)).await;
let reconciled_write = app.hosted.uncertain.clone().unwrap();
// Reload the captured review directly. A fresh Check must not hide a stale
// credential in that review by replacing it with a newer response.
dispatch(&mut app, Message::Hosted(Event::Reload)).await;
assert_eq!(app.project().name, "API engineering change");
dispatch(&mut app, Message::Undo).await;
assert_eq!(app.project().name, "Local pending change");
dispatch(&mut app, Message::Save(false)).await;
assert!(!app.document.dirty());
// Independently exercise the corrected uncertain-write connection.
app.hosted.uncertain = Some(reconciled_write);
dispatch(&mut app, Message::Hosted(Event::Check)).await;
assert!(app.hosted.error.is_none());
assert!(app.hosted.uncertain.is_none());
let accepted = external.open(session.resource_id).await.unwrap();
assert_eq!(accepted.compiled.bundle, *app.document.bundle());
assert_eq!(
accepted.compiled.decoded.project.name,
"Local pending change"
);
let mut second = App::with_project(Project::empty());
second.hosted.base = base;
second.hosted.token = Secret(token.into());
dispatch(&mut second, Message::Hosted(Event::Connect)).await;
assert!(
second
.hosted
.catalog
.iter()
.any(|item| item.resource_id == session.resource_id)
);
send(&mut second, Event::Select(session.resource_id));
dispatch(&mut second, Message::Hosted(Event::OpenSelected)).await;
assert_eq!(second.document.bundle(), app.document.bundle());
assert_eq!(
second.hosted.session.as_ref().unwrap().resource_id,
session.resource_id
);
dispatch(
&mut second,
Message::Command(Command::Rename(project_id, "Saved before New".into())),
)
.await;
dispatch(&mut second, Message::Request(Pending::New)).await;
assert!(second.pending.is_some());
dispatch(&mut second, Message::SaveAndContinue).await;
assert!(second.hosted.session.is_none());
assert!(second.pending.is_none());
assert_ne!(second.project().id, project_id);
assert_eq!(
external
.open(session.resource_id)
.await
.unwrap()
.compiled
.decoded
.project
.name,
"Saved before New"
);
drop(external);
drop(app);
drop(second);
let _ = stop.send(());
tokio::time::timeout(std::time::Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn hosted_invalid_local_open_repair_saves_locally_and_never_replaces_old_resource() {
let directory = tempfile::tempdir().unwrap();
let store = Arc::new(oa_server::store::Store::open(directory.path()).unwrap());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
axum::serve(listener, oa_server::app(store, None))
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
let mut app = App::with_project(Project::empty());
app.hosted.base = base.clone();
dispatch(&mut app, Message::Hosted(Event::Publish)).await;
let session = app.hosted.session.clone().unwrap();
let accepted = app.document.snapshot();
let original_identity = app.canvas_document;
let external = Connection::new(&base, Secret::default()).unwrap();
let old = external.open(session.resource_id).await.unwrap();
let local = tempfile::tempdir().unwrap();
let path = local.path().join("repair.arcadia");
let invalid = "arcadia 5; ? // unfinished local work";
std::fs::write(&path, invalid).unwrap();
let path = path.canonicalize().unwrap();
dispatch(
&mut app,
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
)
.await;
assert_ne!(app.canvas_document, original_identity);
assert_eq!(app.path.as_ref(), Some(&path));
assert!(!app.source_workspace.applied);
assert_eq!(app.source_editor.text(), invalid);
assert!(
Arc::ptr_eq(&accepted, &app.document.snapshot()),
"A repair workspace must preserve the last valid graphical model"
);
assert!(!app.source_diagnostics.is_empty());
assert!(
app.hosted.session.is_none(),
"The installed local repair workspace must own its save destination"
);
let mut repaired = Project::empty();
repaired.name = "Independent local repair".into();
let repaired_source = language::format(&repaired);
dispatch(
&mut app,
Message::SourceEdit(text_editor::Action::SelectAll),
)
.await;
dispatch(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new(repaired_source),
))),
)
.await;
dispatch(&mut app, Message::ApplySource).await;
assert_eq!(app.project(), &repaired);
assert!(!app.source_dirty());
dispatch(&mut app, Message::Save(false)).await;
let remote_after = external.open(session.resource_id).await.unwrap();
assert_eq!(
remote_after.etag, old.etag,
"Saving a repaired local workspace must never target the old hosted resource"
);
assert_eq!(remote_after.compiled.bundle, old.compiled.bundle);
assert!(app.hosted.session.is_none());
assert!(app.hosted.review.is_none());
assert!(app.hosted.uncertain.is_none());
assert!(app.hosted.request.is_none());
assert_eq!(app.path.as_ref(), Some(&path));
assert!(!app.dirty());
let saved = source_files::load_project(&path, LIMITS).unwrap();
assert_eq!(saved.reachable_bundle(), *app.document.bundle());
assert_eq!(
app.hosted.base, base,
"Connection settings remain available in memory"
);
drop(external);
drop(app);
let _ = stop.send(());
tokio::time::timeout(std::time::Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
#[test]
fn hosted_failed_or_cancelled_local_open_keeps_the_existing_document_and_destination() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let accepted = app.document.snapshot();
let identity = app.canvas_document;
let source = app.source_workspace.bundle();
let directory = tempfile::tempdir().unwrap();
let unreadable = directory.path().join("missing.json");
let error = read_snapshot(unreadable).unwrap_err();
for result in [Ok(None), Err(error)] {
let _ = app.update(Message::Loaded(result));
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.canvas_document, identity);
assert_eq!(app.source_workspace.bundle(), source);
assert!(app.path.is_none());
assert_eq!(app.hosted.session.as_ref().unwrap().resource_id, resource);
}
}
fn catalog_result(app: &App, resource: Id, revision: u64) -> Outcome {
Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![crate::hosted::client::Summary {
resource_id: resource,
revision,
model_id: app.project().id,
name: format!("Catalog snapshot {revision}"),
elements: app.project().elements.len(),
relations: app.project().relations.len(),
}],
next_after: Some(resource),
})
}
#[test]
fn hosted_catalog_search_credentials_and_failed_refresh_never_mix_result_scopes() {
let mut app = App::with_project(Project::empty());
let first = Id::new_v4();
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, first, 1);
complete(&mut app, Ok(catalog));
assert!(!app.hosted.catalog_stale);
send(&mut app, Event::More);
assert_eq!(
app.hosted.request.as_ref().unwrap().operation,
Operation::Catalog(true)
);
let next_page = catalog_result(&app, Id::new_v4(), 1);
complete(&mut app, Ok(next_page));
assert_eq!(app.hosted.catalog.len(), 2);
send(&mut app, Event::Select(first));
send(&mut app, Event::Query("different search".into()));
assert!(app.hosted.catalog_stale);
assert!(app.hosted.next.is_none());
assert!(app.hosted.selected.is_none());
assert_eq!(app.hosted.catalog.len(), 2);
assert!(
app.hosted
.catalog_message()
.unwrap()
.contains("Connect / refresh")
);
send(&mut app, Event::More);
assert!(app.hosted.request.is_none());
send(&mut app, Event::Connect);
complete(&mut app, Err(failure(None, false)));
assert!(app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog[0].resource_id, first);
send(&mut app, Event::Connect);
let second = Id::new_v4();
let catalog = catalog_result(&app, second, 1);
complete(&mut app, Ok(catalog));
assert!(!app.hosted.catalog_stale);
assert_eq!(app.hosted.catalog.len(), 1);
assert_eq!(app.hosted.catalog[0].resource_id, second);
send(&mut app, Event::Query("different search".into()));
assert!(
!app.hosted.catalog_stale,
"Unchanged query preserves the snapshot"
);
send(
&mut app,
Event::Token(Secret("different-credential".into())),
);
assert!(app.hosted.catalog_stale);
assert!(app.hosted.catalog.is_empty());
assert!(app.hosted.connection.is_none());
assert!(app.hosted.next.is_none());
assert!(app.hosted.selected.is_none());
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, second, 2);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
assert!(app.hosted.catalog.is_empty());
assert!(app.hosted.connection.is_none());
assert!(app.hosted.catalog_stale);
assert!(app.hosted.request.is_none());
}
#[test]
fn hosted_catalog_invalidation_follows_service_writes_and_observed_revision_changes() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
let other = Id::new_v4();
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, other, 7);
complete(&mut app, Ok(catalog));
rename(&mut app, "Saved to original service");
send(&mut app, Event::Save);
let saved = remote(app.document.snapshot(), resource, 2);
complete(&mut app, Ok(Outcome::Remote(saved)));
assert!(
!app.hosted.catalog_stale,
"A write to A cannot invalidate B's catalog"
);
assert_eq!(app.hosted.next, Some(other));
assert_eq!(app.hosted.catalog[0].resource_id, other);
send(&mut app, Event::Base("http://127.0.0.1:8787".into()));
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, resource, 2);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Check);
let changed = remote(app.document.snapshot(), resource, 3);
complete(&mut app, Ok(Outcome::Remote(changed)));
assert!(
app.hosted.catalog_stale,
"A known newer server revision invalidates old metadata"
);
assert!(app.hosted.next.is_none());
send(&mut app, Event::Connect);
let catalog = catalog_result(&app, resource, 3);
complete(&mut app, Ok(catalog));
send(&mut app, Event::Reload);
rename(&mut app, "Write with uncertain outcome");
send(&mut app, Event::Save);
complete(&mut app, Err(failure(None, true)));
assert!(app.hosted.catalog_stale);
assert!(app.hosted.next.is_none());
assert_eq!(app.hosted.catalog[0].revision, 3);
app.hosted.leave_document();
assert!(
app.hosted.catalog_stale,
"Leaving the hosted destination cannot make old results fresh"
);
}
#[test]
fn hosted_new_revision_invalidates_filtered_catalog_even_without_a_displayed_row() {
let mut app = App::with_project(Project::empty());
rename(&mut app, "Original");
let resource = bind(&mut app);
send(&mut app, Event::Query("Renamed".into()));
send(&mut app, Event::Connect);
complete(
&mut app,
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![],
next_after: None,
})),
);
assert!(!app.hosted.catalog_stale);
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
let mut unchanged = remote(app.document.snapshot(), resource, 1);
unchanged.etag = app.hosted.session.as_ref().unwrap().etag.clone();
send(&mut app, Event::Check);
complete(&mut app, Ok(Outcome::Remote(unchanged)));
assert!(
!app.hosted.catalog_stale,
"An unchanged bound head keeps the filtered snapshot fresh"
);
// A different engineer changes the hosted model so it now matches the filter.
let mut changed = Document::from_bundle(app.document.bundle().clone(), LIMITS).unwrap();
changed
.execute(Command::Rename(changed.project().id, "Renamed".into()))
.unwrap();
send(&mut app, Event::Check);
complete(
&mut app,
Ok(Outcome::Remote(remote(changed.snapshot(), resource, 2))),
);
assert_eq!(app.hosted.review.as_ref().unwrap().remote.revision, 2);
assert!(
app.hosted.request.is_none(),
"No polling or automatic catalog request"
);
assert!(
app.hosted.catalog_stale,
"Known newer revision can change membership in an empty filtered catalog"
);
assert!(
!app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
}
#[test]
fn hosted_changed_bound_head_does_not_invalidate_another_services_empty_filter() {
let mut app = App::with_project(Project::empty());
let resource = bind(&mut app);
send(&mut app, Event::Base("http://127.0.0.1:9876".into()));
send(&mut app, Event::Query("Renamed".into()));
send(&mut app, Event::Connect);
complete(
&mut app,
Ok(Outcome::Catalog(crate::hosted::client::Catalog {
items: vec![],
next_after: None,
})),
);
send(&mut app, Event::Check);
let changed = remote(app.document.snapshot(), resource, 2);
complete(&mut app, Ok(Outcome::Remote(changed)));
assert!(app.hosted.review.is_some());
assert!(!app.hosted.catalog_stale);
assert!(
app.hosted
.catalog_message()
.unwrap()
.starts_with("No models found")
);
assert!(app.hosted.request.is_none());
}
-591
View File
@@ -1,591 +0,0 @@
use super::*;
use crate::behavior::topology::{self, CanvasEvent};
impl App {
pub(crate) fn topology_context(&self) -> Option<topology::Context> {
let owner = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& matches!(
self.project().elements.get(&owner)?.kind(),
Kind::Process | Kind::FunctionalChain
))
.then_some(topology::Context {
document: self.canvas_document,
owner,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
let Some(context) = self.topology_context() else {
return Task::none();
};
let Some(offset) =
self.behavior
.topology
.selection_offset(self.project(), context.owner, self.selected)
else {
return Task::none();
};
iced::widget::operation::scroll_to("process-canvas", offset)
}
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
if let CanvasEvent::Scrolled(_, area, offset) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.scrolled(area, offset)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if let CanvasEvent::Viewport(_, size) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.viewport(size)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if self.topology_context() != Some(context) {
return Task::none();
}
self.behavior
.topology
.enter(context.document, context.owner);
match event {
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
{
self.select(id);
self.selected_node = None;
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
self.behavior.topology.zoom(zoom);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let changed = state.expand(self.project(), context.owner, id);
self.behavior.topology = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::Open(_, occurrence) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
if let Some(target) =
topology::referenced(self.project(), context.owner, occurrence)
{
if self
.project()
.elements
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior.topology.remember(context.owner, occurrence);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(target);
}
}
CanvasEvent::Back(_) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior.topology);
let back = state.back(self.project());
self.behavior.topology = state;
if let Some((owner, occurrence)) = back {
self.behavior.active_owner = Some(owner);
self.select(occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before changing the process.".into();
return Task::none();
}
match mutation {
CanvasEvent::Begin {
tool,
control,
insertion,
..
} => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.begin(self.project(), context, tool, control, insertion);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status="Choose the contextual contribution and apply at the captured position.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::AppendExchange(_) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.append_exchange(self.project(), context.owner);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Connect { source, target, .. } => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.connect(self.project(), context.owner, source, target);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Describe the path condition, then Create.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reconnect {
link, end, target, ..
} => {
match topology::reconnect(self.project(), context.owner, link, end, target)
{
Ok(command) => {
if self.execute(command) {
self.selected = link;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reorder {
occurrence, before, ..
} => {
match topology::reorder(self.project(), context.owner, occurrence, before) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
_ => unreachable!(),
}
}
}
Task::none()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::behavior::{
self,
topology::{
CanvasTool, Event, Insertion, Tool,
direct_tests::{self, Fixture},
},
};
fn setup() -> (App, Fixture) {
let f = direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.topology_context().unwrap().owner, f.owner);
(app, f)
}
fn form(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(behavior::Event::Topology(event)));
}
fn begin(app: &App) -> CanvasEvent {
CanvasEvent::Begin {
context: app.topology_context().unwrap(),
tool: Tool::Work,
control: ControlKind::Choice,
insertion: Insertion::default(),
}
}
#[test]
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = direct_tests::empty_exchange_fixture(layer);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let empty = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: Tool::Exchange,
control: ControlKind::Choice,
insertion: Insertion::default(),
}));
assert!(app.behavior.has_pending());
form(
&mut app,
Event::Target(Choice {
id: f.exchange,
label: "Assessment".into(),
}),
);
form(&mut app, Event::Name("First contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let first_id = app.selected;
let first = app.document.snapshot();
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id]
);
assert_eq!(app.project().relations, empty.decoded.project.relations);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
context,
)));
assert!(
app.behavior.has_pending(),
"reachable single-exchange continuation: {}",
app.status
);
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
form(
&mut app,
Event::Target(Choice {
id: next,
label: "Response".into(),
}),
);
form(&mut app, Event::Name("Second contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let second_id = app.selected;
let second = app.document.snapshot();
assert_ne!(first_id, second_id);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id, second_id]
);
let links = app
.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::SequenceLink)
.collect::<Vec<_>>();
assert_eq!(links.len(), 1);
assert_eq!(
(links[0].source(), links[0].target()),
(first_id, second_id)
);
assert_eq!(
app.project().elements.len(),
empty.decoded.project.elements.len() + 2
);
for (id, relation) in &empty.decoded.project.relations {
assert_eq!(app.project().relations[id], *relation);
}
for work in f.work {
assert_eq!(
app.project().elements[&work],
empty.decoded.project.elements[&work]
);
}
let reopened = Document::from_bundle(
second.bundle.clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), &second.bundle);
for expected in [&first, &empty] {
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(
!app.document.can_undo(),
"each Create must add exactly one history entry"
);
for expected in [&first, &second] {
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(!app.document.can_redo());
}
}
#[test]
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
let (mut app, _) = setup();
let snapshot = app.document.snapshot();
for field in 0..4 {
let mut event = begin(&app);
let CanvasEvent::Begin { context, .. } = &mut event else {
unreachable!()
};
match field {
0 => context.document = Id::new_v4(),
1 => context.owner = Id::new_v4(),
2 => context.revision += 1,
_ => context.epoch += 1,
};
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
let _ = app.update(Message::Field("/name".into(), "Pending owner name".into()));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(app.draft_dirty());
let context = app.topology_context().unwrap();
for event in [
CanvasEvent::Zoom(context, Some(0.7)),
CanvasEvent::Viewport(context, iced::Size::new(510., 360.)),
] {
let _ = app.update(Message::TopologyCanvas(event));
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[test]
fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() {
let (mut app, f) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 3000;
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
app.sync();
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(
&mut app,
Event::Target(Choice {
id: f.work[0],
label: "Assess".into(),
}),
);
form(&mut app, Event::Name("Retain this long input".repeat(1000)));
let _ = app.update(Message::Page(Page::Catalog));
assert_eq!(app.page, Page::Behavior);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
form(&mut app, Event::Name("Recovered contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
assert_ne!(app.selected, f.owner);
assert_eq!(app.project().named(app.selected), "Recovered contribution");
let created = app.selected;
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
assert!(app.project().contains(created));
}
#[test]
fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Connect,
)));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
context, f.nodes[0],
)));
assert_eq!(app.topology_context(), Some(context));
let other = app
.project()
.make_element(Kind::Process, Layer::Oa, "Other", None)
.unwrap();
let other_id = other.id;
assert!(app.execute(Command::CreateElement(other)));
let _ = app.update(Message::BehaviorOwner(Choice {
id: other_id,
label: "Other".into(),
}));
assert_eq!(app.topology_context().unwrap().owner, other_id);
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect {
context,
source: f.nodes[0],
target: f.nodes[2],
}));
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
for (area, offset) in [
(
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(25., 100.),
),
(
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
),
] {
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(500., 300.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
let mut stale = context;
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
document: context.document,
revision: context.revision,
snapshot: original.clone(),
path: root.join(&original.bundle.entry),
disk: Some(source_files::DiskProject {
root,
bundle: original.bundle.clone(),
expectations: Default::default(),
reachable: original.bundle.files.keys().cloned().collect(),
diagnostics: vec![],
}),
};
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(!app.busy);
assert!(!app.document.can_undo());
assert_eq!(app.project(), &f.p);
let context = app.topology_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
assert_eq!(app.topology_context(), Some(context));
let _ = app.update(Message::WindowResized(iced::Size::new(
size.width + 20.,
size.height,
)));
assert_ne!(app.topology_context(), Some(context));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(&mut app, Event::Name("Keep this form".into()));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 80.),
)));
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &original.bundle);
}
#[test]
fn direct_process_app_open_source_and_back_preserve_local_reference_identity() {
let (mut app, f) = setup();
let source = app
.project()
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
.unwrap();
let source_id = source.id;
assert!(app.execute(Command::CreateElement(source)));
let mut occurrence = app
.project()
.make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner))
.unwrap();
let occurrence_id = occurrence.id;
if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() {
*body = OccurrenceBody::SequenceReference {
sequence: Some(source_id),
};
}
assert!(app.execute(Command::CreateElement(occurrence)));
let snapshot = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open(
context,
occurrence_id,
)));
assert_eq!(app.selected, source_id);
assert_eq!(app.topology_context().unwrap().owner, source_id);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
assert_eq!(app.selected, occurrence_id);
assert_eq!(app.topology_context().unwrap().owner, f.owner);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[cfg(test)]
#[test]
fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() {
let f = topology::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let original = app.document.snapshot();
let edited = format!(
"{}\n// Retained source draft\n",
app.source_workspace.text()
);
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(edited.clone().into()),
)));
assert!(app.source_dirty());
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: topology::Tool::Work,
control: ControlKind::Choice,
insertion: topology::Insertion::default(),
}));
assert!(!app.behavior.has_pending());
assert!(app.source_dirty());
assert_eq!(app.source_workspace.text(), edited);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+413 -197
View File
@@ -1,16 +1,8 @@
//! Process/chain authoring and projection use contextual occurrences and explicit
//! SequenceLinks. Presentation order never invents control-flow relationships.
use super::*;
use iced::widget::{Column, text_input};
use iced::widget::{Column, column, text_input};
use std::collections::BTreeSet;
mod direct;
mod view;
use direct::Input;
pub(crate) use direct::{
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
};
#[cfg(test)]
pub(crate) mod direct_tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
@@ -48,8 +40,9 @@ pub enum Event {
Guard(String),
AlternateGuard(String),
Control(ControlChoice),
Search(usize, String),
Page(usize, usize),
Viewport(Size),
Zoom(f32),
Fit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ControlChoice(pub ControlKind);
@@ -66,25 +59,11 @@ impl std::fmt::Display for ControlChoice {
}
#[derive(Default)]
pub struct State {
identity: Option<(Id, Id)>,
tool: CanvasTool,
expanded: BTreeSet<Id>,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<(Id, Id)>,
draft: Option<Draft>,
viewport: Option<Size>,
zoom: Option<f32>,
fit: bool,
}
#[derive(Default)]
struct Browser {
search: String,
page: usize,
}
struct Draft {
insertion: Insertion,
fixed: bool,
browsers: [Browser; 4],
owner: Id,
tool: Tool,
name: String,
@@ -120,6 +99,9 @@ impl State {
return Err(PENDING_DRAFT.into());
}
match event {
Event::Viewport(size) => self.viewport = Some(size),
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
Event::Fit => self.zoom = None,
Event::Start(owner, tool, selected) => {
let e = p
.elements
@@ -145,9 +127,6 @@ impl State {
}
});
self.draft = Some(Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool,
name: String::new(),
@@ -175,11 +154,6 @@ impl State {
Event::Guard(value) => d.guard = value,
Event::AlternateGuard(value) => d.alternate_guard = value,
Event::Control(value) => d.control = value.0,
Event::Search(index, value) if index < 4 => {
d.browsers[index].search = value;
d.browsers[index].page = 0;
}
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
_ => {}
}
}
@@ -189,13 +163,12 @@ impl State {
}
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
if !self.fit {
return self.zoom.unwrap_or(1.);
}
let width = self.viewport.map_or(fallback_width, |s| s.width);
((width - 24.).max(1.) / graph.width.max(1.))
.min(450. / graph.height.max(1.))
.clamp(0.05, 1.)
self.zoom.unwrap_or_else(|| {
let width = self.viewport.map_or(fallback_width, |s| s.width);
((width - 24.).max(1.) / graph.width.max(1.))
.min(450. / graph.height.max(1.))
.clamp(0.05, 1.)
})
}
}
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
@@ -274,24 +247,13 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
})
.map(|e| Choice {
id: e.id,
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
label: format!("{} · {}", e.name, e.alias),
})
.collect()
};
choices.sort_by(|a, b| a.label.cmp(&b.label));
choices
}
fn semantic_path(p: &Project, id: Id) -> String {
let mut path = Vec::new();
let mut current = Some(id);
while let Some(id) = current {
let Some(e) = p.elements.get(&id) else { break };
path.push(e.alias.as_str());
current = e.parent;
}
path.reverse();
path.join(" / ")
}
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
p.elements
.get(&owner)
@@ -432,40 +394,6 @@ impl Draft {
if self.before.is_some() && self.before == self.after {
return Err("Choose different preceding and following contributions.".into());
}
let captured = self
.insertion
.link
.map(|id| {
p.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, self.owner, r.source())
&& is_contribution(p, self.owner, r.target())
})
.ok_or("The captured path no longer exists in this process.")
})
.transpose()?;
if captured
.is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after)
{
return Err("The captured path changed. Choose its position again.".into());
}
let matches: Vec<_> = sequence_links(p, self.owner)
.into_iter()
.filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after)
.collect();
let split_link = if self.tool == Tool::Connect {
None
} else if captured.is_some() {
captured
} else if !self.fixed && matches.len() == 1 {
Some(matches[0])
} else if !self.fixed && matches.len() > 1 {
return Err("Select the specific sequence link to insert on.".into());
} else {
None
};
let mut commands = preserved_order(
p,
self.owner,
@@ -490,6 +418,7 @@ impl Draft {
{
return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into());
}
require_guard(p, source, &self.guard)?;
commands.push(link(p, source, target, &self.guard));
return Ok(unique_batch(p, commands));
}
@@ -530,6 +459,12 @@ impl Draft {
return Err("Choose same-layer work for the second path.".into());
}
let parallel = self.control == ControlKind::Fork;
if !parallel && (self.guard.trim().is_empty() || self.alternate_guard.trim().is_empty())
{
return Err(
"Describe both alternative guards; use 'else' for the fallback path.".into(),
);
}
let split = occurrence(
p,
self.owner,
@@ -617,47 +552,30 @@ impl Draft {
commands.push(Command::CreateElement(e));
(id, id)
};
if let Some(existing) = split_link {
let mut updated = existing.clone();
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
*target = first;
}
commands.push(Command::EditRelation(existing.id, updated));
} else if let Some(before) = self.before {
commands.push(link(
p,
before,
first,
if self.tool == Tool::Branch {
""
} else {
&self.guard
},
));
if let Some(before) = self.before {
// Branch guards belong to the split's two outgoing paths. A branch
// inserted after another choice must first use an explicit link.
let guard = if self.tool == Tool::Branch {
""
} else {
&self.guard
};
require_guard(p, before, guard)?;
commands.push(link(p, before, first, guard));
}
if let Some(after) = self.after {
commands.push(link(p, last, after, ""));
}
let new_ids: Vec<_> = commands
.iter()
.filter_map(|c| match c {
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
_ => None,
})
.collect();
let mut order = owner.steps().expect("process steps").clone();
let slot = self.insertion.before.or(self.after);
let index = slot
.map(|id| {
order
.iter()
.position(|v| *v == id)
.ok_or("The presentation slot changed.")
})
.transpose()?
.unwrap_or(order.len());
order.splice(index..index, new_ids);
commands.push(Command::SetSteps(self.owner, order));
// Inserting between two selected contributions replaces their direct
// precedence while preserving other links and their semantic identities.
if let (Some(before), Some(after)) = (self.before, self.after) {
commands.extend(
sequence_links(p, self.owner)
.into_iter()
.filter(|r| r.source() == before && r.target() == after)
.map(|r| Command::Delete(r.id)),
);
}
Ok(unique_batch(p, commands))
}
}
@@ -680,6 +598,23 @@ pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
}
Command::Batch(commands)
}
fn require_guard(p: &Project, source: Id, guard: &str) -> Result<(), String> {
if guard.trim().is_empty()
&& matches!(
p.elements.get(&source).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Control {
kind: ControlKind::Choice | ControlKind::Loop
},
..
})
)
{
Err("Describe this choice / repeat condition, or enter 'else'.".into())
} else {
Ok(())
}
}
fn event(e: Event) -> Message {
Message::Behavior(if matches!(e, Event::Submit) {
super::Event::Submit
@@ -687,9 +622,307 @@ fn event(e: Event) -> Message {
super::Event::Topology(e)
})
}
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
view::view(app, owner, out)
fn picker<'a>(
choices: Vec<Choice>,
value: Option<Id>,
placeholder: &'a str,
change: fn(Choice) -> Event,
) -> Element<'a, Message> {
let selected = value
.and_then(|id| choices.iter().find(|c| c.id == id))
.cloned();
pick_list(choices, selected, move |v| event(change(v)))
.placeholder(placeholder)
.padding(9)
.text_size(12)
.width(Fill)
.into()
}
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
let p = app.project();
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
if !p.elements[&owner].description.is_empty() {
out = out.push(
text(&p.elements[&owner].description)
.size(12)
.color(style::MUTED),
);
}
out = out
.push(
row![
action("+ Work", event(Event::Start(owner, Tool::Work, selected))),
action(
"+ Exchange",
event(Event::Start(owner, Tool::Exchange, selected))
),
action("Reuse…", event(Event::Start(owner, Tool::Reuse, selected))),
]
.spacing(7),
)
.push(
row![
action(
"+ Control",
event(Event::Start(owner, Tool::Control, selected))
),
action(
"Branch / parallel…",
event(Event::Start(owner, Tool::Branch, selected))
),
action(
"Connect…",
event(Event::Start(owner, Tool::Connect, selected))
),
action("Properties", Message::EditProperties(owner)),
]
.spacing(7),
);
if let Some(d) = app
.behavior
.topology
.draft
.as_ref()
.filter(|d| d.owner == owner)
{
let mut form = column![text(d.tool.title()).size(16)].spacing(9);
let legacy = legacy_order(p, owner);
if legacy.len() > 1 {
form = form.push(label(format!("This edit will preserve the {} ordered contributions as explicit sequence links in the same undoable operation. Inserting between two contributions replaces their direct link.", legacy.len())));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices: Vec<_> = targets(p, owner, d.tool)
.into_iter()
.filter(|choice| compatible_source(p, d, choice.id))
.collect();
if choices.is_empty() {
form = form.push(label("No compatible sources for these connections. Change the insertion points, or create the matching work, exchange or reusable process first."));
}
form = form.push(picker(
choices,
d.target,
"Choose an existing source…",
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source name)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12),
);
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
} else {
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
}
if d.tool == Tool::Branch {
form = form.push(picker(
targets(p, owner, Tool::Work),
d.alternate,
"Work on the second path…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form.push(
row![
text_input("First path condition", &d.guard)
.on_input(|s| event(Event::Guard(s)))
.padding(9)
.size(12),
text_input("Second path condition", &d.alternate_guard)
.on_input(|s| event(Event::AlternateGuard(s)))
.padding(9)
.size(12),
]
.spacing(7),
);
}
form = form.push(label("Creates split, two work contributions, merge and their links in one undoable operation."));
}
form = form
.push(
row![
picker(
contributions(p, owner),
d.before,
if d.tool == Tool::Connect {
"From contribution…"
} else {
"After contribution (optional)…"
},
Event::Before
),
action("Clear", event(Event::ClearBefore)),
]
.spacing(7),
)
.push(
row![
picker(
contributions(p, owner),
d.after,
if d.tool == Tool::Connect {
"To contribution…"
} else {
"Before contribution (optional)…"
},
Event::After
),
action("Clear", event(Event::ClearAfter)),
]
.spacing(7),
);
if d.tool != Tool::Branch {
form = form.push(
text_input("Sequence guard (for a choice or loop)", &d.guard)
.on_input(|s| event(Event::Guard(s)))
.padding(9)
.size(12),
);
}
if let Err(problem) = d.command(p) {
form = form.push(label(problem));
}
form = form.push(
row![
button("Create")
.padding(9)
.on_press_maybe(d.command(p).is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7),
);
out = out.push(container(form).padding(14).style(style::card));
}
let (canvas, size): (Element<'a, Message>, Size) = if uses_explicit_topology(p, owner)
|| p.elements[&owner].steps().is_none_or(Vec::is_empty)
{
let mut graph = Graph::new(p, owner, app.selected);
if graph.nodes.is_empty() {
out = out.push(container(column![
text("Start this process").size(18),
label("Use + Work to involve an existing activity, or + Exchange for an interaction. Then connect contributions or insert alternative and parallel paths."),
].spacing(12)).padding(24).width(Fill).style(style::card));
return scrollable(out.padding(24)).into();
} else if graph.edges.is_empty() {
out = out.push(label(
"No explicit sequence links. Connect contributions to describe topology.",
));
}
graph.scale = app
.behavior
.topology
.scale(graph.size, (app.window_size.width - 600.).max(200.));
let scale = graph.scale;
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
out = out.push(
row![
action("Fit", event(Event::Fit)),
action("−", event(Event::Zoom(scale / 1.25))),
label(format!("{:.0}%", scale * 100.)),
action("+", event(Event::Zoom(scale * 1.25))),
action("100%", event(Event::Zoom(1.))),
]
.spacing(8)
.align_y(iced::Center),
);
(
canvas::Canvas::new(graph)
.width(size.width)
.height(size.height)
.into(),
size,
)
} else {
let scene = Scene::sequence(p, owner, app.selected);
let size = scene.size;
let conversion = preserved_order(p, owner, None);
out = out.push(label(
"Ordered exchange path · the stored contribution order defines this sequence.",
));
if !conversion.is_empty() {
out = out.push(action(
"Convert ordered path to explicit links",
Message::Command(unique_batch(p, conversion)),
));
}
(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height)
.into(),
size,
)
};
out = out.push(
iced::widget::sensor(
container(
scrollable(canvas)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(size.height.clamp(200., 470.)),
)
.padding(8)
.width(Fill)
.style(style::card),
)
.on_show(|size| event(Event::Viewport(size)))
.on_resize(|size| event(Event::Viewport(size))),
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference {
sequence: Some(target),
},
..
}) = p.elements.get(&app.selected).and_then(|e| e.shared())
{
out = out.push(action(
&format!("Open source: {} →", p.named(*target)),
Message::Select(*target),
));
}
for r in sequence_links(p, owner) {
let guard = match &r.body {
RelationBody::SequenceLink { guard, .. } if !guard.is_empty() => format!(" [{guard}]"),
_ => String::new(),
};
out = out.push(
action(
&format!("{} → {}{}", p.named(r.source()), p.named(r.target()), guard),
Message::Select(r.id),
)
.width(Fill),
);
}
scrollable(out.padding(24)).into()
}
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
p.relations
.values()
@@ -709,7 +942,6 @@ struct GraphNode {
source: Option<Id>,
preview: Vec<(Id, String)>,
preview_links: Vec<(Id, Id)>,
preview_total: usize,
}
struct GraphEdge {
id: Id,
@@ -719,21 +951,15 @@ struct GraphEdge {
points: Vec<Point>,
label: Rectangle,
}
struct Graph<'a> {
project: &'a Project,
interaction: Option<(Context, CanvasTool, bool)>,
scroll_offset: iced::Vector,
struct Graph {
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
size: Size,
selected: Id,
scale: f32,
}
impl<'a> Graph<'a> {
fn new(p: &'a Project, owner: Id, selected: Id) -> Self {
Self::expanded(p, owner, selected, &BTreeSet::new())
}
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
impl Graph {
fn new(p: &Project, owner: Id, selected: Id) -> Self {
let choices = contributions(p, owner);
let links = sequence_links(p, owner);
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
@@ -774,17 +1000,17 @@ impl<'a> Graph<'a> {
fallback += 1;
}
}
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
let mut rows: BTreeMap<usize, usize> = BTreeMap::new();
let mut nodes = vec![];
let mut size: Size = Size::new(640., 220.);
for c in choices {
let e = &p.elements[&c.id];
let rank = ranks[&c.id];
let slot = rows.entry(rank).or_insert(24.);
let slot = rows.entry(rank).or_default();
let mut node = GraphNode {
id: e.id,
rect: Rectangle::new(
Point::new(24. + rank as f32 * 420., *slot),
Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.),
Size::new(270., 104.),
),
title: e.name.clone(),
@@ -793,8 +1019,8 @@ impl<'a> Graph<'a> {
source: None,
preview: vec![],
preview_links: vec![],
preview_total: 0,
};
*slot += 1;
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
match body {
OccurrenceBody::Action { action, .. } => {
@@ -825,19 +1051,11 @@ impl<'a> Graph<'a> {
.map(|id| format!("ref · {}", p.named(id)))
.unwrap_or("Choose source in Properties".into());
if let Some(source) = sequence {
let contributions = contributions(p, *source);
node.preview_total = contributions.len();
node.preview = contributions
node.preview = contributions(p, *source)
.into_iter()
.take(if expanded.contains(&e.id) {
usize::MAX
} else {
4
})
.take(4)
.map(|c| (c.id, p.named(c.id).to_owned()))
.collect();
node.rect.height =
(132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.);
let preview_ids: BTreeSet<_> =
node.preview.iter().map(|(id, _)| *id).collect();
node.preview_links = sequence_links(p, *source)
@@ -853,13 +1071,11 @@ impl<'a> Graph<'a> {
_ => {}
}
}
*slot += node.rect.height + 36.;
size.width = size.width.max(node.rect.x + node.rect.width + 28.);
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
nodes.push(node);
}
let mut edges = vec![];
let mut routed_pairs = BTreeSet::new();
for (i, r) in links.into_iter().enumerate() {
let a = nodes
.iter()
@@ -873,14 +1089,11 @@ impl<'a> Graph<'a> {
.rect;
let start = Point::new(a.x + a.width, a.center_y());
let end = Point::new(b.x, b.center_y());
// Parallel records keep distinct visible paths and guard hit targets.
// Their semantic endpoints and IDs remain unchanged.
let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target()));
let points = if forward {
let points = if end.x > start.x {
let mid = (start.x + end.x) / 2.;
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
} else {
let below = size.height + 24. + i as f32 * 42.;
let below = size.height + 24. + i as f32 * 30.;
vec![
start,
Point::new(start.x + 18., start.y),
@@ -894,7 +1107,7 @@ impl<'a> Graph<'a> {
RelationBody::SequenceLink { guard, .. } => guard.clone(),
_ => String::new(),
};
let label_at = if forward {
let label_at = if end.x > start.x {
Point::new(end.x - 138., end.y - 43.)
} else {
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
@@ -915,9 +1128,6 @@ impl<'a> Graph<'a> {
}
}
Self {
project: p,
interaction: None,
scroll_offset: iced::Vector::ZERO,
nodes,
edges,
size,
@@ -994,24 +1204,47 @@ fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) {
Stroke::default().with_color(color).with_width(width),
);
}
impl canvas::Program<Message> for Graph<'_> {
type State = Input;
impl canvas::Program<Message> for Graph {
type State = ();
fn update(
&self,
state: &mut Input,
_: &mut (),
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
self.input(state, event, bounds, cursor)
if matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) && let Some(point) = cursor.position_in(bounds)
{
let point = Point::new(point.x / self.scale, point.y / self.scale);
if let Some(n) = self.nodes.iter().rev().find(|n| n.rect.contains(point)) {
let id = if point.y > n.rect.y + 75. {
n.source.unwrap_or(n.id)
} else {
n.id
};
return Some(Action::publish(Message::Select(id)).and_capture());
}
if let Some(e) = self.edges.iter().find(|e| {
(!e.guard.is_empty() && e.label.contains(point))
|| e.points
.windows(2)
.any(|v| crate::diagram::distance_to_segment(point, v[0], v[1]) < 8.)
}) {
return Some(Action::publish(Message::Select(e.id)).and_capture());
}
}
None
}
fn draw(
&self,
state: &Input,
_: &(),
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
_: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
@@ -1094,7 +1327,7 @@ impl canvas::Program<Message> for Graph<'_> {
if n.source.is_some() {
draw_text(
&mut f,
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
"Open source →".into(),
Point::new(n.rect.x + 12., n.rect.y + 94.),
11.,
style::TEAL,
@@ -1167,12 +1400,11 @@ impl canvas::Program<Message> for Graph<'_> {
);
}
}
self.overlay(state, &mut f, cursor, bounds);
vec![f.into_geometry()]
}
fn mouse_interaction(
&self,
_: &Input,
_: &(),
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
@@ -1246,9 +1478,6 @@ mod tests {
}
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool: Tool::Branch,
name: String::new(),
@@ -1437,7 +1666,7 @@ mod tests {
let command = branch(owner, a, b, before, after).command(&p).unwrap();
session.execute(command).unwrap();
let result = session.project();
assert!(result.relations.contains_key(&old_link));
assert!(!result.relations.contains_key(&old_link));
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
assert_eq!(sequence_links(result, owner).len(), 6);
assert_eq!(
@@ -1548,26 +1777,10 @@ mod tests {
draft: Some(branch(owner, a, b, before, after)),
..State::default()
};
state
.update(
&p,
Event::Target(Choice {
id: Id::nil(),
label: "Missing".into(),
}),
)
.unwrap();
state.update(&p, Event::Guard(String::new())).unwrap();
assert!(state.update(&p, Event::Submit).is_err());
assert!(state.draft.is_some());
state
.update(
&p,
Event::Target(Choice {
id: a,
label: "Work".into(),
}),
)
.unwrap();
state.update(&p, Event::Guard("ready".into())).unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert!(state.draft.is_some());
assert!(matches!(command, Command::Batch(_)));
@@ -1623,8 +1836,9 @@ mod tests {
.unwrap();
let mut graph = Graph::new(&p, owner, owner);
let mut state = State::default();
state.viewport(Size::new(486., 460.));
state.zoom(None);
state
.update(&p, Event::Viewport(Size::new(486., 460.)))
.unwrap();
graph.scale = state.scale(graph.size, 500.);
assert!(graph.size.width * graph.scale <= 462.1);
assert!(graph.size.height * graph.scale <= 450.1);
@@ -1635,7 +1849,7 @@ mod tests {
);
let result = canvas::Program::update(
&graph,
&mut Input::default(),
&mut (),
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
mouse::Cursor::Available(click),
@@ -1644,10 +1858,12 @@ mod tests {
.into_inner()
.0;
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
state.zoom(Some(1.));
state.viewport(Size::new(800., 460.));
state.update(&p, Event::Zoom(1.)).unwrap();
state
.update(&p, Event::Viewport(Size::new(800., 460.)))
.unwrap();
assert_eq!(state.scale(graph.size, 500.), 1.);
state.zoom(None);
state.update(&p, Event::Fit).unwrap();
assert!(state.scale(graph.size, 500.) < 1.);
}
}
@@ -1,744 +0,0 @@
//! Local pointer state and ordinary command proposals for the derived process graph.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Context {
pub document: Id,
pub owner: Id,
pub revision: u64,
pub epoch: u64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Insertion {
pub link: Option<Id>,
pub before: Option<Id>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CanvasTool {
#[default]
Select,
Add(Tool, ControlKind),
Connect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum End {
Source,
Target,
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
Scrolled(Context, scenario::ScrollArea, iced::Vector),
Choose(Context, CanvasTool),
Select(Context, Id),
AppendExchange(Context),
Begin {
context: Context,
tool: Tool,
control: ControlKind,
insertion: Insertion,
},
Connect {
context: Context,
source: Id,
target: Id,
},
Reconnect {
context: Context,
link: Id,
end: End,
target: Id,
},
Reorder {
context: Context,
occurrence: Id,
before: Id,
},
Expand(Context, Id),
Open(Context, Id),
Back(Context),
Zoom(Context, Option<f32>),
Viewport(Context, Size),
}
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Scrolled(c, ..)
| Self::Choose(c, ..)
| Self::Select(c, ..)
| Self::Expand(c, ..)
| Self::Open(c, ..)
| Self::Back(c)
| Self::AppendExchange(c)
| Self::Zoom(c, ..)
| Self::Viewport(c, ..) => *c,
Self::Begin { context, .. }
| Self::Connect { context, .. }
| Self::Reconnect { context, .. }
| Self::Reorder { context, .. } => *context,
}
}
}
impl State {
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.identity != Some((document, owner)) {
self.identity = Some((document, owner));
self.tool = CanvasTool::Select;
self.zoom = Some(1.);
self.fit = false;
self.viewport = None;
self.expanded.clear();
self.scroll_offsets = [iced::Vector::ZERO; 2];
}
}
pub(crate) fn scroll_offset(&self) -> iced::Vector {
self.scroll_offsets[0] + self.scroll_offsets[1]
}
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
let index = match area {
scenario::ScrollArea::Canvas => 0,
scenario::ScrollArea::Page => 1,
};
let changed = self.scroll_offsets[index] != offset;
self.scroll_offsets[index] = offset;
changed
}
pub(crate) fn choose(&mut self, tool: CanvasTool) {
// Choosing how to edit must retain the user's current view.
self.tool = tool;
}
pub(crate) fn zoom(&mut self, value: Option<f32>) {
self.fit = value.is_none();
self.zoom = value.map(|v| v.clamp(0.5, 2.));
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
let changed = self.viewport != Some(size);
self.viewport = Some(size);
changed
}
pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool {
if !is_contribution(p, owner, id)
|| !matches!(
p.elements[&id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
})
)
{
return false;
}
if !self.expanded.remove(&id) {
self.expanded.insert(id);
}
true
}
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
self.return_to.push((owner, occurrence));
}
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
while let Some((owner, occurrence)) = self.return_to.pop() {
if is_contribution(p, owner, occurrence) {
return Some((owner, occurrence));
}
}
None
}
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
let owner = self.pending_owner()?;
let Command::Batch(commands) = command else {
return None;
};
commands.iter().find_map(|c| match c {
Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id),
_ => None,
})
}
pub(crate) fn begin(
&mut self,
p: &Project,
context: Context,
tool: Tool,
control: ControlKind,
insertion: Insertion,
) -> Result<(), String> {
if self.draft.is_some() {
return Err(PENDING_DRAFT.into());
}
if !matches!(
tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control
) {
return Err("Choose a contribution tool.".into());
}
if !legacy_order(p, context.owner).is_empty() {
return Err("Convert the ordered path to explicit links before drawing.".into());
}
let link = insertion
.link
.map(|id| {
p.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, context.owner, r.source())
&& is_contribution(p, context.owner, r.target())
})
.ok_or("Choose a sequence link in this process.")
})
.transpose()?;
if insertion
.before
.is_some_and(|id| !is_contribution(p, context.owner, id))
{
return Err("Choose a presentation slot in this process.".into());
}
self.update(p, Event::Start(context.owner, tool, None))?;
let d = self.draft.as_mut().expect("started form");
d.fixed = true;
d.insertion = insertion;
d.control = control;
if let Some(r) = link {
d.before = Some(r.source());
d.after = Some(r.target());
}
Ok(())
}
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
let last = legacy_order(p, owner)
.last()
.copied()
.ok_or("Choose an ordered exchange path to append to.")?;
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
self.draft.as_mut().expect("started form").fixed = true;
Ok(())
}
pub(crate) fn connect(
&mut self,
p: &Project,
owner: Id,
source: Id,
target: Id,
) -> Result<(), String> {
if self.draft.is_some() {
return Err(PENDING_DRAFT.into());
}
if !is_contribution(p, owner, source)
|| !is_contribution(p, owner, target)
|| !validation::can_connect(p, RelationKind::SequenceLink, source, target)
{
return Err("Choose compatible contributions directly owned by this process.".into());
}
self.update(p, Event::Start(owner, Tool::Connect, None))?;
let d = self.draft.as_mut().expect("started form");
d.fixed = true;
d.before = Some(source);
d.after = Some(target);
Ok(())
}
}
pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
if !is_contribution(p, owner, occurrence) {
return None;
}
match p.elements[&occurrence].shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Action { action, .. },
..
} => *action,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
} => *exchange,
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence },
..
} => *sequence,
_ => None,
}
}
pub(crate) fn reconnect(
p: &Project,
owner: Id,
id: Id,
end: End,
endpoint: Id,
) -> Result<Command, String> {
let mut r = p
.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, owner, r.source())
&& is_contribution(p, owner, r.target())
})
.ok_or("Choose a sequence link in this process.")?
.clone();
if !is_contribution(p, owner, endpoint) {
return Err("Choose a local contribution endpoint.".into());
}
if let RelationBody::SequenceLink { source, target, .. } = &mut r.body {
match end {
End::Source => *source = endpoint,
End::Target => *target = endpoint,
}
}
let command = Command::EditRelation(id, r);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
return Err("Reorder contributions in this process only.".into());
}
let graph = Graph::new(p, owner, id);
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
if position(id) != position(before) {
return Err("Reorder within this column; links determine the path columns.".into());
}
let mut steps = p.elements[&owner].steps().unwrap().clone();
if id != before {
steps.retain(|v| *v != id);
let index = steps
.iter()
.position(|v| *v == before)
.ok_or("This slot no longer exists.")?;
steps.insert(index, id);
}
let command = Command::SetSteps(owner, steps);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
#[derive(Default)]
pub(super) struct Input {
context: Option<Context>,
tool: CanvasTool,
pointer: Option<Point>,
bounds: Option<Rectangle>,
source: Option<Id>,
press: Option<Press>,
}
#[derive(Clone, Copy)]
enum Press {
Connect {
source: Id,
first: bool,
start: Point,
moved: bool,
},
Reconnect {
link: Id,
end: End,
start: Point,
moved: bool,
},
Reorder {
occurrence: Id,
start: Point,
moved: bool,
},
Add {
insertion: Insertion,
},
}
impl Input {
fn cancel(&mut self) {
self.source = None;
self.press = None;
}
}
impl Graph<'_> {
fn node_at(&self, point: Point) -> Option<Id> {
self.nodes
.iter()
.rev()
.find(|n| n.rect.contains(point))
.map(|n| n.id)
}
fn edge_at(&self, point: Point) -> Option<Id> {
self.edges
.iter()
.find(|e| {
(!e.guard.is_empty() && e.label.contains(point))
|| e.points.windows(2).any(|v| {
crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale
})
})
.map(|e| e.id)
}
fn handle(&self, id: Id) -> Option<Rectangle> {
let n = self.nodes.iter().find(|n| n.id == id)?;
Some(Rectangle::new(
Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.),
Size::new(23., 24.),
))
}
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
let e = self.edges.iter().find(|e| e.id == self.selected)?;
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
.into_iter()
.find(|(p, _)| p.distance(point) < 10. / self.scale)
.map(|(_, end)| (e.id, end))
}
fn connectable(&self, source: Id, target: Id) -> bool {
validation::can_connect(self.project, RelationKind::SequenceLink, source, target)
}
pub(super) fn input(
&self,
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
(Some(c), t, e)
});
if state.context != context
|| state.tool != tool
|| state.bounds.is_some_and(|b| b != bounds)
{
state.cancel();
state.pointer = None;
state.context = context;
state.tool = tool;
}
state.bounds = Some(bounds);
if !editable {
state.cancel();
}
let visible = cursor.position().is_some();
if !visible
&& matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
)
{
state.cancel();
return Some(Action::request_redraw());
}
let cursor = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
if let Some(
Press::Connect { start, moved, .. }
| Press::Reconnect { start, moved, .. }
| Press::Reorder { start, moved, .. },
) = &mut state.press
{
*moved |= position.distance(*start) >= 3.;
}
mouse::Cursor::Available(*position + self.scroll_offset)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
| canvas::Event::Window(
iced::window::Event::Resized(_) | iced::window::Event::Unfocused,
) => {
state.cancel();
state.pointer = None;
return Some(Action::request_redraw());
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) => {
state.cancel();
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
) => state
.pointer
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
cursor
}
_ => cursor,
};
let world = cursor
.position_in(bounds)
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
let point = world?;
let Some(context) = context else {
return self
.node_at(point)
.or_else(|| self.edge_at(point))
.map(|id| Action::publish(Message::Select(id)).and_capture());
};
if !editable {
return None;
}
let start = cursor.position()? - self.scroll_offset;
match tool {
CanvasTool::Add(..) => {
state.press = Some(Press::Add {
insertion: Insertion {
link: self
.edge_at(point)
.filter(|_| self.node_at(point).is_none()),
before: self.node_at(point),
},
});
}
CanvasTool::Connect => {
let first = state.source.is_none();
let source = state.source.or_else(|| self.node_at(point))?;
state.source = Some(source);
state.press = Some(Press::Connect {
source,
first,
start,
moved: false,
});
}
CanvasTool::Select => {
if let Some((link, end)) = self.endpoint_at(point) {
state.press = Some(Press::Reconnect {
link,
end,
start,
moved: false,
});
} else if self
.handle(self.selected)
.is_some_and(|r| r.contains(point))
{
state.press = Some(Press::Reorder {
occurrence: self.selected,
start,
moved: false,
});
} else {
return self
.node_at(point)
.or_else(|| self.edge_at(point))
.and_then(|id| publish(CanvasEvent::Select(context, id)));
}
}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let press = state.press.take()?;
let context = context?;
match press {
Press::Add { insertion } if world.is_some() => {
if let CanvasTool::Add(tool, control) = tool {
state.cancel();
return publish(CanvasEvent::Begin {
context,
tool,
control,
insertion,
});
}
}
Press::Connect {
source,
first,
moved,
..
} if moved || !first => {
if let Some(target) = world
.and_then(|p| self.node_at(p))
.filter(|target| self.connectable(source, *target))
{
state.cancel();
return publish(CanvasEvent::Connect {
context,
source,
target,
});
}
}
Press::Reconnect {
link,
end,
moved: true,
..
} => {
if let Some(target) = world.and_then(|p| self.node_at(p)) {
state.cancel();
return publish(CanvasEvent::Reconnect {
context,
link,
end,
target,
});
}
}
Press::Reorder {
occurrence,
moved: true,
..
} => {
if let Some(before) = world.and_then(|p| self.node_at(p)) {
state.cancel();
return publish(CanvasEvent::Reorder {
context,
occurrence,
before,
});
}
}
_ => {}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
Some(Action::request_redraw())
}
_ => None,
}
}
pub(super) fn overlay(
&self,
state: &Input,
f: &mut Frame,
cursor: mouse::Cursor,
bounds: Rectangle,
) {
let Some((context, tool, editable)) = self.interaction else {
return;
};
if !editable {
return;
}
let point = state
.pointer
.map(|p| p + self.scroll_offset)
.or_else(|| cursor.position())
.filter(|p| bounds.contains(*p))
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
let live =
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
if live && let Some(source) = state.source {
for n in &self.nodes {
if n.id == source || self.connectable(source, n.id) {
f.stroke(
&Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(2.5),
);
}
}
if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) {
draw_arrow(
f,
&[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to],
style::TEAL,
2.,
);
}
}
if live
&& let Some(Press::Reconnect { link, end, .. }) = state.press
&& let Some(edge) = self.edges.iter().find(|e| e.id == link)
{
for node in &self.nodes {
let (source, target) = match end {
End::Source => (node.id, edge.target),
End::Target => (edge.source, node.id),
};
if self.connectable(source, target) {
f.stroke(
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(2.5),
);
}
}
if let Some(point) = point {
let (from, to) = match end {
End::Source => (point, *edge.points.last().unwrap()),
End::Target => (edge.points[0], point),
};
draw_arrow(f, &[from, to], style::TEAL, 2.5);
}
}
if live
&& let Some(Press::Reorder { occurrence, .. }) = state.press
&& let Some(point) = point
&& let Some(target) = self.node_at(point)
{
let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap();
let target = self.nodes.iter().find(|n| n.id == target).unwrap();
if source.rect.x == target.rect.x {
f.stroke(
&Path::line(
Point::new(target.rect.x, target.rect.y - 7.),
Point::new(target.rect.x + target.rect.width, target.rect.y - 7.),
),
Stroke::default().with_color(style::TEAL).with_width(3.),
);
draw_text(
f,
"Order before · paths unchanged".into(),
Point::new(target.rect.x, target.rect.y - 24.),
12.,
style::TEAL,
);
}
}
if tool == CanvasTool::Select {
if let Some(r) = self.handle(self.selected) {
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
}
if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) {
for p in [e.points[0], *e.points.last().unwrap()] {
f.fill(&Path::circle(p, 6. / self.scale), style::TEAL);
}
}
}
if let CanvasTool::Add(..) = tool
&& let Some(point) = point
{
let at = self
.edge_at(point)
.filter(|_| self.node_at(point).is_none());
let caption = if let Some(id) = at {
if let Some(e) = self.edges.iter().find(|e| e.id == id) {
draw_arrow(f, &e.points, style::TEAL, 3.);
}
"Insert on this path · preserve its guard"
} else if self.node_at(point).is_some() {
"Add before this contribution · unconnected"
} else {
"Append contribution · unconnected"
};
draw_text(
f,
caption.into(),
Point::new(point.x + 12., point.y + 16.),
12. / self.scale,
style::TEAL,
);
}
}
}
impl State {
pub(crate) fn selection_offset(
&self,
p: &Project,
owner: Id,
id: Id,
) -> Option<scrollable::AbsoluteOffset> {
let graph = Graph::expanded(p, owner, id, &self.expanded);
let scale = self.scale(graph.size, 500.);
let rect = graph
.nodes
.iter()
.find(|n| n.id == id)
.map(|n| n.rect)
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
Some(scrollable::AbsoluteOffset {
x: (rect.x * scale - 12.).max(0.),
y: (rect.y * scale - 12.).max(0.),
})
}
}
File diff suppressed because it is too large Load Diff
@@ -1,493 +0,0 @@
use super::*;
use iced::widget::column;
pub(super) fn picker<'a>(
mut choices: Vec<Choice>,
d: &'a Draft,
index: usize,
value: Option<Id>,
placeholder: &'a str,
change: fn(Choice) -> Event,
) -> Element<'a, Message> {
let selected = value
.and_then(|id| choices.iter().find(|c| c.id == id))
.cloned();
let browser = &d.browsers[index];
let query = browser.search.trim().to_lowercase();
choices.retain(|c| c.label.to_lowercase().contains(&query));
let range = choice_page(choices.len(), browser.page);
let page = range.start / 24;
let mut out = column![
text_input("Search compatible choices…", &browser.search)
.on_input(move |v| event(Event::Search(index, v)))
.padding(7)
.size(12)
]
.spacing(5);
if let Some(selected) = selected
.as_ref()
.filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id))
{
out = out.push(label(format!("Selected: {}", selected.label)));
}
out = out.push(
row![
button("Previous")
.on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))),
text(format!(
"{} choices · {}/{}",
choices.len(),
page + 1,
choices.len().div_ceil(24).max(1)
))
.size(11),
button("Next").on_press_maybe(
(range.end < choices.len()).then(|| event(Event::Page(index, page + 1)))
)
]
.spacing(6),
);
out.push(
pick_list(choices[range].to_vec(), selected, move |v| event(change(v)))
.placeholder(placeholder)
.padding(8)
.text_size(12)
.width(Fill),
)
.into()
}
pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize> {
let start = requested.min(len.saturating_sub(1) / 24) * 24;
start..(start + 24).min(len)
}
pub(super) fn view<'a>(
app: &'a App,
owner: Id,
mut out: Column<'a, Message>,
) -> Element<'a, Message> {
let p = app.project();
let context = app.topology_context().expect("visible process context");
let state = &app.behavior.topology;
let pending = state.draft.is_some();
let explicit =
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
// Keep the canvas at the same sibling position when its palette becomes a
// pending-form notice, so Iced retains the scrollable's local offsets.
let mut controls = Column::new().spacing(14);
if !pending && explicit {
controls = controls
.push(
row![
button("Select / reorder")
.style(if state.tool == CanvasTool::Select {
button::primary
} else {
style::nav
})
.on_press(choose(CanvasTool::Select)),
button("Work")
.style(
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
button("Exchange")
.style(
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
button("Reuse")
.style(
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
]
.spacing(6),
)
.push(
row![
pick_list(
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop
]
.into_iter()
.map(ControlChoice)
.collect::<Vec<_>>(),
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
Some(ControlChoice(kind))
} else {
None
},
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Add(Tool::Control, c.0)
))
)
.placeholder("Draw control…")
.text_size(12)
.padding(8),
button("Sequence link")
.style(if state.tool == CanvasTool::Connect {
button::primary
} else {
style::nav
})
.on_press(choose(CanvasTool::Connect)),
button("Two paths…").on_press(event(Event::Start(
owner,
Tool::Branch,
selected
)))
]
.spacing(6),
);
}
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
out = out.push(controls);
if explicit {
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
graph.interaction = Some((
context,
state.tool,
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
));
graph.scroll_offset = state.scroll_offset();
let scale = graph.scale;
out = out.push(
row![
button("Fit overview")
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale / 1.25)
))),
label(format!("{:.0}%", scale * 100.)),
button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale * 1.25)
))),
button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(1.)
)))
]
.spacing(6),
);
if graph.nodes.is_empty() {
out = out.push(label(
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
));
}
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
let canvas = scrollable(
canvas::Canvas::new(graph)
.width(size.width)
.height(size.height),
)
.id("process-canvas")
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(if pending {
(app.window_size.height - 510.).clamp(160., 240.)
} else {
(app.window_size.height - 420.).clamp(190., 420.)
});
out = out.push(
iced::widget::sensor(
container(super::super::scenario_scroll::observe_topology(
context,
scenario::ScrollArea::Canvas,
canvas,
))
.padding(8)
.width(Fill)
.style(style::card),
)
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
);
} else {
out = out.push(label(
"Ordered exchange path · append the next exchange or convert to explicit links.",
));
if !pending {
out = out.push(
button("Append exchange…")
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
}
let commands = preserved_order(p, owner, None);
if !commands.is_empty() {
out = out.push(
button("Convert ordered path to explicit links")
.on_press(Message::Command(unique_batch(p, commands))),
);
} else {
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
}
let scene = Scene::sequence(p, owner, app.selected);
let size = scene.size;
out = out.push(
scrollable(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height),
)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(240.),
);
}
if let Some(id) = selected {
let mut actions =
row![button("Contribution properties").on_press(Message::EditProperties(id))]
.spacing(6);
if let Some(target) = direct::referenced(p, owner, id) {
actions = actions.push(
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
);
if matches!(
p.elements[&id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
})
) {
actions = actions.push(
button(if state.expanded.contains(&id) {
"Collapse"
} else {
"Expand"
})
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
);
}
}
out = out.push(actions);
} else if sequence_links(p, owner)
.iter()
.any(|r| r.id == app.selected)
{
out = out.push(
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
);
}
if !state.return_to.is_empty() {
out = out.push(
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
);
}
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
guard.as_str()
} else {
""
};
form = form.push(label(format!(
"Insert on {} → {} · preserve path identity and condition [{}]",
p.named(link.source()),
p.named(link.target()),
guard
)));
} else if d.fixed
&& d.tool == Tool::Exchange
&& let Some(before) = d.before
{
form = form.push(label(format!(
"Append after {} → new exchange · Create adds an explicit sequence link",
p.named(before)
)));
} else if d.fixed && d.tool != Tool::Connect {
form = form.push(label(
d.insertion
.before
.map_or("Append an unconnected contribution".into(), |id| {
format!("Unconnected contribution before {}", p.named(id))
}),
));
}
if !legacy_order(p, owner).is_empty() {
form = form.push(label(
"This edit preserves the ordered path as explicit links in the same undo step.",
));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices = targets(p, owner, d.tool)
.into_iter()
.filter(|c| compatible_source(p, d, c.id))
.collect();
if d.tool == Tool::Branch {
form = form.push(label("First path work"));
}
form = form.push(picker(
choices,
d,
0,
d.target,
if d.tool == Tool::Branch {
"Choose first path work…"
} else {
"Choose existing source…"
},
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
);
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
} else {
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(8)
.text_size(12),
);
}
if d.tool == Tool::Branch {
form = form.push(label("Second path work")).push(picker(
targets(p, owner, Tool::Work),
d,
1,
d.alternate,
"Second path work…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form
.push(label("First path guard"))
.push(
text_input("First path guard", &d.guard)
.on_input(|v| event(Event::Guard(v)))
.padding(8),
)
.push(label("Second path guard"))
.push(
text_input("Second path guard (e.g. else)", &d.alternate_guard)
.on_input(|v| event(Event::AlternateGuard(v)))
.padding(8),
);
}
}
if d.fixed {
if let (Some(a), Some(b)) = (d.before, d.after) {
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
}
} else {
form = form
.push(label("Preceding contribution"))
.push(picker(
contributions(p, owner),
d,
2,
d.before,
"After contribution…",
Event::Before,
))
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
.push(label("Following contribution"))
.push(picker(
contributions(p, owner),
d,
3,
d.after,
"Before contribution…",
Event::After,
))
.push(button("Clear following").on_press(event(Event::ClearAfter)));
}
if d.tool != Tool::Branch
&& d.insertion.link.is_none()
&& (d.before.is_some() || d.tool == Tool::Connect)
{
form = form.push(
text_input(
"Path condition (empty remains a Choice / Loop warning)",
&d.guard,
)
.on_input(|v| event(Event::Guard(v)))
.padding(8)
.size(12),
);
}
let valid = match d.command(p) {
Ok(_) => true,
Err(problem) => {
form = form.push(label(problem));
false
}
};
let actions = row![
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
button("Cancel").on_press(event(Event::Cancel))
]
.spacing(7);
out = out.push(
container(
column![
actions,
scrollable(form).height((app.window_size.height - 520.).clamp(150., 260.))
]
.spacing(8),
)
.padding(12)
.style(style::card),
);
}
super::super::scenario_scroll::observe_topology(
context,
scenario::ScrollArea::Page,
scrollable(out.padding(18)),
)
}
+5 -6
View File
@@ -432,12 +432,11 @@ mod tests {
let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
for (interface, item) in [(provided, report), (required, result)] {
p.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
}
p.relate(
RelationKind::InterfaceProvision,
-559
View File
@@ -1,559 +0,0 @@
//! One address for a carried value, shared by scenario creation and property editing.
use oa_core::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FieldPathKey {
pub path: Vec<Id>,
pub field: Id,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldPathChoice {
pub key: FieldPathKey,
pub label: String,
pub picker_label: String,
}
impl std::fmt::Display for FieldPathChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.picker_label)
}
}
fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &MessageKind) -> bool {
data::item_mechanism(project, entry.item) != Some(ExchangeMechanism::Operation)
|| match phase {
MessageKind::Call => matches!(
entry.direction,
ParameterDirection::Unspecified
| ParameterDirection::In
| ParameterDirection::InOut
),
MessageKind::Reply => matches!(
entry.direction,
ParameterDirection::Unspecified
| ParameterDirection::Out
| ParameterDirection::InOut
| ParameterDirection::Return
),
_ => true,
}
}
fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice {
let qualified = entry
.path
.iter()
.chain(std::iter::once(&entry.field))
.map(|id| {
project
.elements
.get(id)
.map(|e| format!("{} (@{})", e.name, e.alias))
.unwrap_or_else(|| id.to_string())
})
.collect::<Vec<_>>();
let direction =
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
match entry.direction {
ParameterDirection::Unspecified => " · direction unspecified — review",
ParameterDirection::In => " · In",
ParameterDirection::Out => " · Out",
ParameterDirection::InOut => " · InOut",
ParameterDirection::Return => " · Return",
}
} else {
""
};
let terminal = qualified
.last()
.expect("A field path always has a terminal");
let owner_path = qualified[..qualified.len() - 1].join(" → ");
let picker_label = format!(
"{terminal}{direction} — {}{}",
project.named(entry.item),
if owner_path.is_empty() {
String::new()
} else {
format!(" / {owner_path}")
},
);
FieldPathChoice {
key: FieldPathKey {
path: entry.path,
field: entry.field,
},
label: format!(
"{} / {}{direction}",
project.named(entry.item),
qualified.join(" → ")
),
picker_label,
}
}
// A bounded picker must expose its distinguishing field before a long path.
// Keep the full address separately for the wrapping detail text.
fn sorted_choices(mut choices: Vec<FieldPathChoice>) -> Vec<FieldPathChoice> {
choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase());
choices
}
#[cfg(test)]
pub fn choices(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
) -> Result<Vec<FieldPathChoice>, String> {
Ok(sorted_choices(
data::try_carried_field_paths(project, exchange)?
.into_iter()
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
.collect(),
))
}
/// Direct carried fields only: an unrelated deep branch must not prevent
/// browsing a root field. Preserve the core resolver's first carried owner.
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
let mut seen = std::collections::BTreeSet::new();
sorted_choices(
exchange
.body
.items()
.iter()
.flat_map(|item| {
data::effective_fields(project, *item)
.into_iter()
.filter_map(|field| {
let SharedBody::DataField { direction, .. } = field.shared()? else {
return None;
};
Some(data::CarriedFieldPath {
item: *item,
path: vec![],
field: field.id,
direction: *direction,
})
})
})
.filter(|entry| seen.insert(entry.field))
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
.collect(),
)
}
/// Browse one record at a time, including explicitly chosen recursive hops.
pub fn at_scope(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
scope: Option<&FieldPathKey>,
) -> Result<Vec<FieldPathChoice>, String> {
match scope {
Some(key) => children(project, exchange, phase, key),
None => Ok(roots(project, exchange, phase)),
}
}
/// Resolve authored finite paths as well as the finite representative picker list.
/// In particular, optional recursive Value records may be traversed repeatedly.
pub fn resolve(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
key: &FieldPathKey,
) -> Option<FieldPathChoice> {
data::carried_value_path(project, exchange, &key.path, key.field)
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
}
pub fn parent(key: &FieldPathKey) -> Option<FieldPathKey> {
let (&field, path) = key.path.split_last()?;
Some(FieldPathKey {
path: path.to_vec(),
field,
})
}
/// Expand exactly one selected record. This keeps recursive authoring explicit
/// and finite, and never traverses Reference storage or manufactures value paths.
pub fn children(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
key: &FieldPathKey,
) -> Result<Vec<FieldPathChoice>, String> {
let Some(entry) = data::carried_value_path(project, exchange, &key.path, key.field)
.filter(|entry| phase_allows(project, entry, phase))
else {
return Ok(vec![]);
};
let Some(SharedBody::DataField {
type_ref: TypeRef::Named(record),
storage: FieldStorage::Value,
..
}) = project.elements.get(&key.field).and_then(Element::shared)
else {
return Ok(vec![]);
};
if !data::is_record(project, *record) {
return Ok(vec![]);
}
if key.path.len() >= data::MAX_VALUE_PATH_DEPTH {
return Err(format!(
"A value path can contain at most {} enclosing fields.",
data::MAX_VALUE_PATH_DEPTH
));
}
let mut path = key.path.clone();
path.push(key.field);
Ok(sorted_choices(
data::effective_fields(project, *record)
.into_iter()
.map(|field| {
named_choice(
project,
data::CarriedFieldPath {
item: entry.item,
path: path.clone(),
field: field.id,
direction: entry.direction,
},
)
})
.collect(),
))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub struct Payload {
pub project: Project,
pub exchange: Id,
pub item: Id,
pub leaf: Id,
pub origin: Id,
pub destination: Id,
pub scenario: Id,
}
pub fn payload() -> Payload {
let mut p = Project::empty();
let item = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Move", None)
.unwrap();
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Operation,
supertype: None,
};
let record = p
.add_element(Kind::DataType, Layer::Oa, "Coordinate", None)
.unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
let leaf = p
.add_element(Kind::DataField, Layer::Oa, "latitude", Some(record))
.unwrap();
let origin = p
.add_element(Kind::DataField, Layer::Oa, "origin", Some(item))
.unwrap();
let destination = p
.add_element(Kind::DataField, Layer::Oa, "destination", Some(item))
.unwrap();
for (id, type_ref, direction) in [
(leaf, TypeRef::Real, ParameterDirection::Unspecified),
(origin, TypeRef::Named(record), ParameterDirection::In),
(destination, TypeRef::Named(record), ParameterDirection::Out),
] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
direction,
minimum: 1,
maximum: Some(1),
unit: String::new(),
storage: FieldStorage::Value,
default: None,
};
}
let source = p
.add_element(Kind::Activity, Layer::Oa, "Request", None)
.unwrap();
let target = p
.add_element(Kind::Activity, Layer::Oa, "Perform", None)
.unwrap();
let exchange = p.relate(RelationKind::Interaction, "Movement", source, target);
p.relations.get_mut(&exchange).unwrap().body = RelationBody::Interaction {
source,
target,
items: vec![item],
};
let scenario = p
.add_element(Kind::Scenario, Layer::Oa, "Conversation", None)
.unwrap();
validate_candidate(&p).unwrap();
Payload {
project: p,
exchange,
item,
leaf,
origin,
destination,
scenario,
}
}
#[test]
fn phase_choices_keep_reused_record_addresses_distinct() {
let mut f = payload();
assert_eq!(
data::item_mechanism(&f.project, f.item),
Some(ExchangeMechanism::Operation)
);
let call = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Call,
)
.unwrap();
assert!(
call.iter()
.any(|c| c.key.path == [f.origin] && c.key.field == f.leaf)
);
assert!(!call.iter().any(|c| c.key.path == [f.destination]));
let reply = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Reply,
)
.unwrap();
assert!(
reply
.iter()
.any(|c| c.key.path == [f.destination] && c.key.field == f.leaf)
);
assert!(!reply.iter().any(|c| c.key.path == [f.origin]));
if let Some(SharedBody::DataField { direction, .. }) = f
.project
.elements
.get_mut(&f.destination)
.unwrap()
.shared_mut()
{
*direction = ParameterDirection::InOut;
}
let both = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Call,
)
.unwrap();
let leaves = both
.iter()
.filter(|c| c.key.field == f.leaf)
.collect::<Vec<_>>();
assert_eq!(leaves.len(), 2);
assert_ne!(leaves[0].key, leaves[1].key);
assert_ne!(leaves[0].label, leaves[1].label);
let values = leaves
.into_iter()
.map(|c| (c.key.clone(), "1".to_string()))
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(values.len(), 2);
}
#[test]
fn reference_parameter_stops_descent_and_unspecified_direction_remains_visible() {
let mut f = payload();
if let Some(SharedBody::DataField {
storage, direction, ..
}) = f.project.elements.get_mut(&f.origin).unwrap().shared_mut()
{
*storage = FieldStorage::Reference;
*direction = ParameterDirection::Unspecified;
}
let call = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Call,
)
.unwrap();
let direct = call.iter().find(|c| c.key.field == f.origin).unwrap();
assert!(direct.key.path.is_empty());
assert!(direct.label.contains("unspecified"));
assert!(!call.iter().any(|c| c.key.path.contains(&f.origin)));
}
pub fn recursive_payload() -> (Payload, Id) {
let mut f = payload();
let record = f.project.elements[&f.leaf].parent.unwrap();
let next = f
.project
.add_element(Kind::DataField, Layer::Oa, "next", Some(record))
.unwrap();
*f.project
.elements
.get_mut(&next)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::DataField {
type_ref: TypeRef::Named(record),
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
validate_candidate(&f.project).unwrap();
(f, next)
}
pub fn wide_payload() -> Payload {
let mut f = payload();
let record = f.project.elements[&f.leaf].parent.unwrap();
for (owner, template, count) in [(f.item, f.origin, 95), (record, f.leaf, 127)] {
for index in 0..count {
let body = f.project.elements[&template].shared().unwrap().clone();
let id = f
.project
.add_element(
Kind::DataField,
Layer::Oa,
&format!("Field {template} {index}"),
Some(owner),
)
.unwrap();
*f.project
.elements
.get_mut(&id)
.unwrap()
.shared_mut()
.unwrap() = body;
}
}
f
}
#[test]
fn roots_and_one_hop_children_do_not_expand_unrelated_payload_branches() {
let mut f = wide_payload();
let exchange = &f.project.relations[&f.exchange];
let direct = roots(&f.project, exchange, &MessageKind::Call);
assert_eq!(direct.len(), 96);
assert!(direct.iter().all(|choice| choice.key.path.is_empty()));
let representative = choices(&f.project, exchange, &MessageKind::Call).unwrap();
assert_eq!(representative.len(), 96 * 129);
for root in &direct {
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &root.key),
Some(root.clone())
);
}
let nested = children(&f.project, exchange, &MessageKind::Call, &direct[0].key).unwrap();
assert_eq!(nested.len(), 128);
for child in &nested {
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &child.key),
Some(child.clone())
);
assert!(child.label.ends_with(" · In"));
}
// A temporarily over-limit model still exposes its unrelated root fields;
// the editor must not expand the whole graph before showing a choice.
for index in 0..32 {
let body = f.project.elements[&f.origin].shared().unwrap().clone();
let id = f
.project
.add_element(
Kind::DataField,
Layer::Oa,
&format!("Extra {index}"),
Some(f.item),
)
.unwrap();
*f.project
.elements
.get_mut(&id)
.unwrap()
.shared_mut()
.unwrap() = body;
}
let exchange = &f.project.relations[&f.exchange];
assert!(choices(&f.project, exchange, &MessageKind::Call).is_err());
assert_eq!(roots(&f.project, exchange, &MessageKind::Call).len(), 128);
assert_eq!(
children(&f.project, exchange, &MessageKind::Call, &direct[0].key)
.unwrap()
.len(),
128
);
}
#[test]
fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() {
let (f, next) = recursive_payload();
let exchange = &f.project.relations[&f.exchange];
let record = FieldPathKey {
path: vec![f.origin],
field: next,
};
let nested = children(&f.project, exchange, &MessageKind::Call, &record).unwrap();
let leaf = nested.iter().find(|c| c.key.field == f.leaf).unwrap();
assert_eq!(leaf.key.path, vec![f.origin, next]);
assert!(
!choices(&f.project, exchange, &MessageKind::Call)
.unwrap()
.iter()
.any(|c| c.key == leaf.key)
);
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
Some(leaf.clone())
);
assert_eq!(parent(&leaf.key), Some(record));
let deepest = FieldPathKey {
path: std::iter::once(f.origin)
.chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1))
.collect(),
field: next,
};
assert!(resolve(&f.project, exchange, &MessageKind::Call, &deepest).is_some());
assert!(
children(&f.project, exchange, &MessageKind::Call, &deepest)
.unwrap_err()
.contains("128")
);
}
#[test]
fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() {
let (f, next) = recursive_payload();
let exchange = &f.project.relations[&f.exchange];
let context = FieldPathKey {
path: vec![f.origin, next, next],
field: next,
};
let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap();
let leaf = visible
.iter()
.find(|choice| choice.key.field == f.leaf)
.unwrap();
assert!(leaf.to_string().starts_with("latitude (@latitude)"));
assert!(leaf.label.contains("origin (@origin) → next (@next)"));
assert!(
visible.windows(2).all(
|pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase()
)
);
assert_ne!(
visible[0].to_string().chars().take(24).collect::<String>(),
visible[1].to_string().chars().take(24).collect::<String>()
);
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
Some(leaf.clone())
);
}
}
File diff suppressed because it is too large Load Diff
+1 -181
View File
@@ -292,7 +292,6 @@ impl DiagramCanvas<'_> {
request.placement = Some(node);
} else if let Some(edge) = self
.hit_label(&scene.routed, local)
.or_else(|| self.hit_association_label(&scene.routed, local))
.or_else(|| self.hit_edge(&scene.routed, local).map(|(id, _)| id))
{
request.object = edge;
@@ -370,33 +369,6 @@ impl DiagramCanvas<'_> {
})
.map(|edge| edge.id)
}
fn hit_association_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
let p = self.world(p);
routed
.edges
.values()
.flat_map(|edge| {
self.project
.relations
.get(&edge.id)
.into_iter()
.flat_map(move |relation| routing::association_end_geometry(relation, edge))
.map(move |end| (edge.id, end.label))
})
.filter(|(_, label)| {
Rectangle::new(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
)
.contains(p)
})
.min_by(|a, b| {
p.distance(point(a.1.center))
.total_cmp(&p.distance(point(b.1.center)))
.then(a.0.cmp(&b.0))
})
.map(|(id, _)| id)
}
fn preview_diagram(&self, state: &State) -> Diagram {
let mut diagram = self.project.diagrams[&self.diagram].clone();
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
@@ -753,10 +725,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
state.drag = Some((id, p, position));
state.preview = Some(position);
Some(Action::publish(Message::SelectNode(id)).and_capture())
} else if let Some(id) = self
.hit_label(&scene.routed, p)
.or_else(|| self.hit_association_label(&scene.routed, p))
{
} else if let Some(id) = self.hit_label(&scene.routed, p) {
Some(Action::publish(Message::Select(id)).and_capture())
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
if id == self.selected
@@ -1041,37 +1010,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
..Default::default()
});
}
if let Some(relation) = self.project.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, routed) {
if end.navigable {
let [a, tip, b] = navigation_mark(end.tip, end.inward);
f.stroke(
&Path::new(|p| {
p.move_to(a);
p.line_to(tip);
p.line_to(b);
}),
Stroke::default().with_color(color).with_width(1.7),
);
}
let label = &end.label;
f.fill_rectangle(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
Color::WHITE,
);
f.fill_text(Text {
content: label.text.clone(),
position: point(label.center),
color: if active { style::TEAL } else { style::MUTED },
size: 13.into(),
align_x: iced::widget::text::Alignment::Center,
align_y: iced::alignment::Vertical::Center,
max_width: f32::INFINITY,
..Default::default()
});
}
}
}
for n in &view.nodes {
if !containers.contains(&n.id) {
@@ -1319,17 +1257,6 @@ fn endpoint_position(view: &ProjectedView, id: Id) -> Option<Position> {
})
})
}
fn navigation_mark(tip: Position, inward: Position) -> [Point; 3] {
let tip = point(tip);
let delta = point(inward) - tip;
let direction = delta / delta.x.hypot(delta.y).max(0.001);
let normal = Vector::new(-direction.y, direction.x) * 4.;
[
tip + direction * 9. + normal,
tip,
tip + direction * 9. - normal,
]
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PortGlyph {
Function,
@@ -1617,21 +1544,6 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
if !label.text.is_empty() {
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
}
if let Some(relation) = p.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, route) {
let side = if end.source_end { "source" } else { "target" };
svg.push_str(&format!(
"<g data-association=\"{}\" data-end=\"{side}\">",
edge.id
));
if end.navigable {
let [a, tip, b] = navigation_mark(end.tip, end.inward);
svg.push_str(&format!("<path data-navigation=\"{side}\" d=\"M {} {} L {} {} L {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.7\"/>",a.x,a.y,tip.x,tip.y,b.x,b.y));
}
let label = &end.label;
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text></g>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
}
}
}
for n in &view.nodes {
if !containers.contains(&n.id) {
@@ -2441,98 +2353,6 @@ mod tests {
assert!(geometry.bounds.position.y <= route.label.bounds.position.y);
}
#[test]
fn association_end_contracts_share_svg_geometry_and_select_without_moving_name_label() {
for self_loop in [false, true] {
let mut p = Project::empty();
let a = p.add(Kind::DataType, "Tree", 100., 100.);
let b = if self_loop {
a
} else {
p.add(Kind::DataType, "Branch", 650., 100.)
};
for id in BTreeSet::from([a, b]) {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
}
let edge = p.relate(RelationKind::DataAssociation, "Hierarchy", a, b);
p.relations.get_mut(&edge).unwrap().body = RelationBody::DataAssociation {
source: a,
target: b,
source_role: "parent".into(),
target_role: "children".into(),
source_minimum: 0,
source_maximum: Some(1),
target_minimum: 0,
target_maximum: None,
source_navigable: false,
target_navigable: true,
};
let diagram = p.ensure_diagram(Layer::Oa);
let projected = project_view(&p, diagram);
assert!(
!projected
.edges
.iter()
.find(|e| e.id == edge)
.unwrap()
.oriented
);
let routed = route_diagram(&p, &p.diagrams[&diagram], &projected);
let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]);
assert_eq!(ends.len(), 2);
let svg = export_svg(&p, diagram);
assert!(svg.contains("parent [0..1]</text>"));
assert!(svg.contains("children [0..*]</text>"));
assert!(!svg.contains("data-navigation=\"source\""));
assert!(svg.contains("data-navigation=\"target\""));
let mut canvas = DiagramCanvas {
project: &p,
diagram,
selected: edge,
zoom: 0.7,
pan: Vector::new(350., 350.),
context: Context {
document: p.id,
diagram,
revision: 0,
epoch: 0,
},
selected_bend: None,
editable: true,
connection: None,
};
for end in ends {
assert!(svg.contains(&format!(
"x=\"{}\" y=\"{}\" text-anchor",
end.label.center.x, end.label.center.y
)));
let at = Point::new(
end.label.center.x * canvas.zoom + canvas.pan.x,
end.label.center.y * canvas.zoom + canvas.pan.y,
);
let mut state = State::default();
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
assert!(
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
);
assert!(
state.route_drag.is_none(),
"Endpoint contract labels never drag the association name"
);
assert_eq!(
canvas
.context_request(&state, at, Rectangle::with_size(Size::new(1800., 1200.)))
.object,
edge
);
canvas.selected = p.id;
}
}
}
#[test]
fn detached_label_selects_connection_and_occluding_card_owns_its_hit() {
let (mut p, diagram, edge) = route_fixture();
p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel {
+41 -319
View File
@@ -14,7 +14,6 @@ use iced::{
use oa_core::*;
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
mod data_fields;
mod port_fields;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -32,12 +31,6 @@ pub struct Input {
pub raw: String,
pub editor: text_editor::Content,
}
#[derive(Clone, Default)]
pub struct ValueBrowser {
pub scope: Option<crate::data_values::FieldPathKey>,
pub page: usize,
}
pub struct Draft {
pub kind: RecordKind,
pub original: Id,
@@ -45,9 +38,7 @@ pub struct Draft {
baseline: Value,
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
context: Option<Value>,
port_contract_drafts: BTreeMap<String, Value>,
pub value_browsers: BTreeMap<String, ValueBrowser>,
}
impl Draft {
@@ -84,16 +75,12 @@ impl Draft {
value,
inputs: BTreeMap::new(),
exact_strings: false,
context: None,
port_contract_drafts: BTreeMap::new(),
value_browsers: BTreeMap::new(),
};
draft.rebuild();
Some(draft)
}
fn rebuild(&mut self) {
// Array/variant edits can change the meaning of a row path.
self.value_browsers.clear();
self.inputs.clear();
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
match value {
@@ -142,32 +129,8 @@ impl Draft {
}
}
pub fn materialize(&self) -> Result<Value, String> {
self.materialize_inputs(|_| true)
}
/// Read-only context for dependent controls. Workflow proposals never replace
/// the authored value or its input buffers and are not used by command().
pub fn context_value(&self) -> Value {
self.context
.clone()
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
}
pub fn set_context(&mut self, context: Option<Value>) {
self.context = context;
}
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
self.materialize_inputs(|path| {
scopes
.iter()
.any(|root| path == root || path.starts_with(&format!("{root}/")))
})
}
fn materialize_inputs(&self, include: impl Fn(&str) -> bool) -> Result<Value, String> {
let mut value = self.value.clone();
for (path, input) in &self.inputs {
if !include(path) {
continue;
}
let Some(target) = value.pointer_mut(path) else {
continue;
};
@@ -238,97 +201,6 @@ impl Draft {
self.rebuild();
Ok(())
}
/// Replace a workflow-authorized subtree without parsing unrelated inputs,
/// including an invalid value which this replacement deliberately removes.
pub fn shape_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
*self
.value
.pointer_mut(path)
.ok_or("Property no longer exists.")? = value;
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
Ok(())
}
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
let previous = std::mem::take(&mut self.inputs);
self.rebuild();
for (path, input) in previous {
if let Some(path) = new_path(&path)
&& self.inputs.contains_key(&path)
{
self.inputs.insert(path, input);
}
}
}
pub fn array_scoped(
&mut self,
path: &str,
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
let mut candidate = self.value.clone();
let count = candidate
.pointer(path)
.and_then(Value::as_array)
.ok_or("List no longer exists.")?
.len();
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild_retaining(|old| {
let Some(tail) = old.strip_prefix(&format!("{path}/")) else {
return Some(old.to_owned());
};
let (number, rest) = tail.split_once('/').unwrap_or((tail, ""));
let position = number.parse::<usize>().ok()?;
let position = match index {
Some(index) if direction == 0 && position == index => return None,
Some(index) if direction == 0 && position > index => position - 1,
Some(index) if direction != 0 => {
let target = (index as i32 + direction).clamp(0, count as i32 - 1) as usize;
if position == index {
target
} else if position == target {
index
} else {
position
}
}
_ => position,
};
Some(if rest.is_empty() {
format!("{path}/{position}")
} else {
format!("{path}/{position}/{rest}")
})
});
Ok(())
}
pub fn append_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
self.value
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?
.push(value);
self.rebuild_retaining(|path| Some(path.to_owned()));
Ok(())
}
pub fn value_path_scoped(
&mut self,
path: &str,
key: crate::data_values::FieldPathKey,
) -> Result<(), String> {
let row = self
.value
.pointer_mut(path)
.and_then(Value::as_object_mut)
.ok_or("Message value no longer exists.")?;
row.insert("path".into(), json!(key.path));
row.insert("field".into(), json!(key.field));
self.rebuild_retaining(|old| {
(!within(old, &format!("{path}/path")) && !within(old, &format!("{path}/field")))
.then(|| old.to_owned())
});
Ok(())
}
pub fn array(
&mut self,
path: &str,
@@ -336,34 +208,36 @@ impl Draft {
direction: i32,
) -> Result<(), String> {
let mut candidate = self.materialize()?;
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild();
Ok(())
}
pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> {
let mut candidate = self.materialize()?;
candidate
let items = candidate
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?
.push(value);
self.value = candidate;
self.rebuild();
Ok(())
}
pub fn value_path(
&mut self,
path: &str,
key: crate::data_values::FieldPathKey,
) -> Result<(), String> {
let mut candidate = self.materialize()?;
let row = candidate
.pointer_mut(path)
.and_then(Value::as_object_mut)
.ok_or("Message value no longer exists.")?;
row.insert("path".into(), json!(key.path));
row.insert("field".into(), json!(key.field));
.ok_or("List no longer exists.")?;
match index {
None => items.push(if path.ends_with("literals") {
json!("New value")
} else if path.ends_with("bindings") {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"field": Id::nil(), "expression": ""})
} else if path.ends_with("routes") {
json!({"edge": Id::nil(), "waypoints": [], "label": null})
} else if path.ends_with("waypoints") {
json!({"x": 0.0, "y": 0.0})
} else if path.ends_with("port_anchors") {
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
} else {
json!(Id::nil())
}),
Some(i) if i < items.len() => {
if direction == 0 {
items.remove(i);
} else {
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
items.swap(i, target);
}
}
_ => return Err("List item no longer exists.".into()),
}
self.value = candidate;
self.rebuild();
Ok(())
@@ -438,51 +312,6 @@ impl Draft {
}
}
fn within(path: &str, root: &str) -> bool {
path == root
|| path
.strip_prefix(root)
.is_some_and(|tail| tail.starts_with('/'))
}
fn edit_array(
value: &mut Value,
path: &str,
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
let items = value
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?;
match index {
None => items.push(if path.ends_with("literals") {
json!("New value")
} else if path.ends_with("bindings") {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"path": [], "field": Id::nil(), "expression": ""})
} else if path.ends_with("routes") {
json!({"edge": Id::nil(), "waypoints": [], "label": null})
} else if path.ends_with("waypoints") {
json!({"x": 0.0, "y": 0.0})
} else if path.ends_with("port_anchors") {
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
} else {
json!(Id::nil())
}),
Some(i) if i < items.len() => {
if direction == 0 {
items.remove(i);
} else {
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
items.swap(i, target);
}
}
_ => return Err("List item no longer exists.".into()),
}
Ok(())
}
fn friendly(name: &str) -> String {
match name {
"id" => "UUID identity".into(),
@@ -582,7 +411,6 @@ fn reference(path: &str) -> bool {
| "Named"
| "diagram"
| "machine"
| "supertype"
) || matches!(
parent,
"items"
@@ -612,7 +440,6 @@ fn optional_reference(path: &str) -> bool {
| "reply_to"
| "lifeline"
| "state"
| "supertype"
)
}
fn multiline(path: &str) -> bool {
@@ -635,11 +462,9 @@ fn free_string(path: &str) -> bool {
multiline(path)
|| matches!(
path.rsplit('/').next(),
Some("name" | "uri" | "revision" | "fragment" | "unit" | "source_role" | "target_role")
Some("name" | "uri" | "revision" | "fragment" | "unit")
)
|| path.split('/').rev().nth(1) == Some("literals")
|| (path.contains("/default/")
&& matches!(path.rsplit('/').next(), Some("String" | "Enumeration")))
}
// These are choices for the closed model, not a second validation boundary.
@@ -697,7 +522,7 @@ fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id)
}
fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
let value = draft.context_value();
let value = draft.materialize().unwrap_or_else(|_| draft.value.clone());
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
let owner = value
.get("element")
@@ -837,42 +662,21 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
.collect();
}
if field == "field" {
let address_path = path
.strip_suffix("/field")
.and_then(|row| value.pointer(&format!("{row}/path")))
.and_then(|v| serde_json::from_value::<Vec<Id>>(v.clone()).ok())
.unwrap_or_default();
return element
.as_ref()
.and_then(|e| match e.shared()? {
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(id),
message_kind,
..
exchange: Some(id), ..
},
..
} => project.relations.get(id).and_then(|r| {
let scope = address_path.split_last().map(|(&field, path)| {
crate::data_values::FieldPathKey {
path: path.to_vec(),
field,
}
});
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
}),
} => project.relations.get(id),
_ => None,
})
.unwrap_or_default()
.into_iter()
.filter(|choice| choice.key.path == address_path)
.map(|choice| choice.key.field)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
.map(|r| validation::carried_fields(project, r).into_iter().collect())
.unwrap_or_default();
}
let scenario = element
.as_ref()
.and_then(|e| e.parent)
@@ -903,40 +707,6 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
return false;
}
match field {
"supertype" => element.as_ref().is_some_and(|owner| {
if candidate.id == owner.id
|| candidate.kind() != owner.kind()
|| (candidate.kind() == Kind::DataType
&& !data::is_record(project, candidate.id))
{
return false;
}
if let (
Some(SharedBody::ExchangeItem {
kind, mechanism, ..
}),
Some(SharedBody::ExchangeItem {
kind: base_kind,
mechanism: base_mechanism,
..
}),
) = (owner.shared(), candidate.shared())
&& (kind != base_kind
|| (*base_mechanism != ExchangeMechanism::Unspecified
&& mechanism != base_mechanism))
{
return false;
}
let mut next = Some(candidate.id);
let mut seen = BTreeSet::new();
while let Some(id) = next {
if id == owner.id || !seen.insert(id) {
return false;
}
next = project.elements.get(&id).and_then(data::supertype);
}
true
}),
"machine" => candidate.kind() == Kind::StateMachine,
"parent" => element.as_ref().is_some_and(|child| {
if candidate.id == draft.original
@@ -1288,20 +1058,6 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
let words: &[&str] = match field {
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
"layer" => &["Oa", "Sa"],
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
"storage" => &["Value", "Reference"],
"direction" if path.contains("DataField") => {
&["Unspecified", "In", "Out", "InOut", "Return"]
}
"transmission" => &[
"Unspecified",
"Synchronous",
"Asynchronous",
"Unicast",
"Multicast",
"Broadcast",
],
"acquisition" => &["Unspecified", "Read", "Accept"],
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
"viewpoint" if path.contains("/Oa/") => &[
"OperationalActivity",
@@ -1310,7 +1066,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
],
"viewpoint" => &["Functional", "BoundaryInterface", "MixedLegacy"],
"phase" => &["Entry", "Do", "Exit"],
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy"],
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"],
"kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"],
"kind" if path.contains("State") => &[
"State",
@@ -1376,20 +1132,11 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
if field == "definition" {
return vec![
("Boolean".into(), json!("Boolean")),
(
"Integer".into(),
json!({"Integer":{"minimum":null,"maximum":null}}),
),
(
"Real".into(),
json!({"Real":{"minimum":null,"maximum":null}}),
),
(
"Text".into(),
json!({"String":{"minimum_length":0,"maximum_length":null}}),
),
("Integer".into(), json!("Integer")),
("Real".into(), json!("Real")),
("Text".into(), json!("String")),
("Enumeration".into(), json!({"Enumeration":{"literals":[]}})),
("Record".into(), json!({"Record":{"supertype":null}})),
("Record".into(), json!("Record")),
];
}
if field == "type_ref" {
@@ -1518,22 +1265,6 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
content.into()
}
/// Render one closed property subtree for a workflow-owned typed draft.
/// The caller routes messages to that draft and authorizes the selected scope.
pub fn scoped<'a>(
draft: &'a Draft,
project: &'a Project,
path: &str,
label: &str,
) -> View<'a, Message> {
match draft.value.pointer(path) {
Some(value) => property(draft, project, value, path, label, false),
None => text("This property is not applicable to the current target.")
.size(12)
.into(),
}
}
fn property<'a>(
draft: &'a Draft,
project: &'a Project,
@@ -1542,9 +1273,6 @@ fn property<'a>(
label: &str,
skip_variant: bool,
) -> View<'a, Message> {
if let Some(view) = data_fields::property(draft, project, value, path) {
return view;
}
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
if path.ends_with("/Port/legacy") {
let mut content = column![].spacing(4);
@@ -2347,8 +2075,6 @@ mod tests {
Layer::Oa,
SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy,
mechanism: ExchangeMechanism::Flow,
supertype: None,
},
);
let mut draft = Draft::from_project(&p, item).unwrap();
@@ -2365,12 +2091,8 @@ mod tests {
let interface = p
.add_element(Kind::Interface, Layer::Oa, "Power contract", None)
.unwrap();
p.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
p.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] });
assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err());
assert!(draft.dirty());
assert_eq!(p.elements[&item].layer(), Layer::Oa);
File diff suppressed because it is too large Load Diff
+11 -9
View File
@@ -42,7 +42,7 @@ pub(super) fn variants(
_value: &Value,
) -> Option<Vec<(String, Value)>> {
if path.ends_with("/Port/contract") {
let current = draft.context_value();
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let sa = current.pointer("/body/Sa").is_some();
if !sa {
return Some(vec![]);
@@ -80,7 +80,7 @@ pub(super) fn variants(
);
}
if path.ends_with("/Port/contract/Component/orientation") {
let current = draft.context_value();
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind";
let orientations: &[&str] =
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
@@ -144,7 +144,13 @@ fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool {
}
let declared: Vec<_> = interfaces
.iter()
.flat_map(|id| data::interface_items(project, *id))
.filter_map(|id| project.elements.get(id))
.filter_map(|e| match e.shared() {
Some(SharedBody::Interface { items }) => Some(items),
_ => None,
})
.flatten()
.copied()
.collect();
items_satisfy(project, items, &declared)
}
@@ -708,12 +714,8 @@ mod tests {
interface,
);
assert!(reference_ids(&draft, &project, target).is_empty());
project.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
project.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![item] });
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
}
+16 -55
View File
@@ -102,7 +102,7 @@ impl Topic {
Self::Source => "Textual editing & Git",
Self::Recovery => "Checks & error recovery",
Self::Files => "Files & keyboard shortcuts",
Self::Server => "Hosted models & REST API",
Self::Server => "REST API & automation",
}
}
@@ -369,7 +369,7 @@ fn article(topic: Topic) -> Article {
&[
"Create the source and target activities/functions in one perspective. Give each an engineering name and allocate leaf work to its responsible participant.",
"Choose Interaction or Functional exchange in the diagram palette. Click the source, then a highlighted compatible target, or drag between them. Functional exchanges connect output ports to input ports; function cards create or reuse real ports in the same transaction. Middle-button drag pans while keeping the source; arrow keys also pan when the drawing canvas has focus. Click empty space to restart, or Escape to cancel.",
"Create an Exchange item under Data & interfaces. Choose its nature (Information, Material or Energy) and its mechanism (Flow, Event, Operation or Shared data). Describe the payload independently of the connection.",
"Create an Exchange item under Data & interfaces. Choose its kind (Information, Material, Energy or Event) and describe its payload.",
"Select the exchange in Model explorer or the diagram. Edit its properties to reference the appropriate exchange items and available ports; Apply changes validates the complete record.",
"Choose Boundary exchange and connect compatible component ports. Flow goes from Out to In; Standard is unoriented. Select / move returns to arranging. Exchange allocation associates a boundary exchange with the functional exchange it carries.",
],
@@ -380,7 +380,7 @@ fn article(topic: Topic) -> Article {
),
explain(
"Use engineering payloads",
"Choose Information for data such as a distress report, Energy for delivered power and Material for a transferred substance. Mechanism separately describes how it is exchanged: an Event is a notification, an Operation has parameters, and a Flow carries values or a physical quantity. Data types and fields make payloads precise. Artifact links can point to interface-control documents.",
"Choose Information for data such as a distress report, Energy for delivered power, Material for a transferred substance and Event for a notification. Data types and fields can make structured payloads precise. URI artifact links can point to detailed interface-control documents.",
),
],
related: &[
@@ -510,34 +510,17 @@ fn article(topic: Topic) -> Article {
const {
Article {
summary: "Define exchanged payloads independently of the ports and participants that carry them. Reuse the same data definitions in related exchanges.",
keywords: "data interface port direction input output inout return contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum bounds default unit specialization inheritance association storage reference recursive path transmission acquisition protocol",
keywords: "data interface port direction input output inout contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum",
sections: &[
walkthrough(
"Define a reusable payload",
&[
"Open Data & interfaces and create an Exchange item. Choose Information, Material or Energy for nature; choose Flow, Event, Operation or Shared data for mechanism. Keep Unspecified when the engineering decision is still open.",
"Create a Data type for a reusable definition. Choose Boolean, Integer, Real, String, Enumeration or Record. Numeric bounds are inclusive; String bounds count Unicode characters. A typed default must satisfy those bounds or enumeration literals. Units annotate quantities without converting them.",
"Create fields within an Exchange item or Record type. Set Value type, minimum/maximum count, unit and optional default. A default can be absent, explicitly empty when minimum is zero, or a list of typed scalar values. A type default describes one element; it does not create a required collection.",
"Open Data & interfaces and create an Exchange item. Select Information, Material, Energy or Event to describe the nature of the exchange.",
"Create a Data type where the payload needs a named definition. Choose its Data type definition and edit the corresponding values in Properties.",
"For structured definitions, create Data fields under a legal Data type or Exchange item owner. Set Value type and the minimum/maximum count as required by the contract.",
"Select the exchange and reference the payload items. Apply changes and check the result in Model checks.",
],
),
explain(
"Operation parameters and message values",
"An Operation's direct fields are parameters. In and InOut supply call values; Out, InOut and Return supply reply values, from the provider's perspective. There is at most one Return parameter. Qualified paths distinguish origin.latitude from destination.latitude when both use the same Coordinate record. Nested fields inherit the parameter direction. Message expressions are annotations, not evaluated values.",
),
explain(
"Reuse, ownership and references",
"A Record or Exchange item can specialize one compatible base. Inherited fields keep their original owner and identity; edit the base to change them. Value fields compose a payload. Reference fields refer to separately identified Record objects and stop payload expansion. Optional recursive values and reference cycles are valid; a mandatory value cycle warns that no finite complete value exists. Data associations describe named reference ends, multiplicities and navigation between Record types without creating contained fields.",
),
walkthrough(
"Allocate an item to interfaces",
&[
"Create an Interface and allocate an Exchange item. Select its allocation row to edit transmission, acquisition and order; Open item navigates to the shared payload definition.",
"Two interfaces may allocate the same item with different protocols. Attach CAD, code or document evidence to the allocation when it concerns that particular contract.",
"Removing an allocation keeps the shared item and other allocations. If a live boundary depends on it, first revise that boundary contract. Undo restores an accepted removal.",
"An Interface may specialize one base and inherit its allocations. Duplicate allocation of an already inherited item is rejected. A specialized item satisfies its base contract; a base item does not satisfy a more specific contract.",
],
),
walkthrough(
"Connect participant boundaries",
&[
@@ -740,7 +723,7 @@ fn article(topic: Topic) -> Article {
),
explain(
"Open and migration",
"Open validates a complete candidate before replacing the project. Versions 1, 2 and 3 migrate explicitly to version 4; Model checks shows preserved ports, supplied data defaults, generated interface allocations and unresolved message paths. Old Event items become Information notifications; unknown physical mechanisms remain Unspecified. Source3 upgrades retain files, includes, nested ownership and comments. Opening does not overwrite the original: use Save as if it must remain in its older format. Invalid input leaves the accepted project unchanged.",
"Open reads and validates a complete candidate before replacing the current project. Supported legacy files are migrated explicitly; save the resulting current format under a new name if the legacy original must remain available. Unsupported or invalid input does not replace the accepted project.",
),
],
related: &[Topic::Source, Topic::Recovery, Topic::Artifacts],
@@ -751,42 +734,16 @@ fn article(topic: Topic) -> Article {
Topic::Server => {
const {
Article {
summary: "Use optional hosted storage from the desktop, scripts and engineering-tool plugins. All clients work with the same model, validation and authored source.",
summary: "The Rust REST service gives scripts and engineering-tool plugins access to the same model, commands, validation and source representation.",
keywords: "server rest api automation ai plugin curl http etag if-match revision resource_id deploy hosted cad scripting openapi token",
sections: &[
example(
"Start a local service",
"From the repository root, run the command below in a terminal and leave it running. The default address is loopback 127.0.0.1:8787, with platform-local storage. docs/server.md also covers installed executables, authentication and Linux deployment.",
"cargo run --locked -p oa-server --bin arcadia-server\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
"Run the separate arcadia-server binary from a terminal. Its default address is loopback 127.0.0.1:8787. docs/server.md describes storage, authentication and Linux deployment.",
"arcadia-server --data-dir ./arcadia-models\n\nGET http://127.0.0.1:8787/health\nGET http://127.0.0.1:8787/api/v1/openapi.json",
),
walkthrough(
"Open or publish a hosted model",
&[
"Choose Hosted models... in the top bar. Enter the service root, such as http://127.0.0.1:8787, without /api/v1. Enter the configured bearer token if required, then Connect / refresh.",
"Choose Publish as new to upload the current source project, including its files and comments, as a separate hosted resource. Its acknowledgement switches the current Save destination to that resource.",
"To open an existing resource, search the Hosted model catalog, select a model and choose Open selected. More models loads another page. Unsaved work uses the normal save/discard/cancel prompt.",
"Edit diagrams, properties or Textual model, then choose Save to server or press Ctrl+S. Valid drafts are applied; invalid drafts remain available to correct or revert. Edits and Undo stay local until Save.",
],
),
walkthrough(
"Review another client's changes",
&[
"Check server reads the current revision without replacing local content. A stale Save returns 412 and automatically checks the server for review; local work is retained.",
"Review the changed file list. View server text opens a read-only file; Back to review returns to the list. There is no automatic merge.",
"Reload server version adopts the reviewed source project as one undoable local change after checking unapplied drafts. Undo restores the previous accepted local version without changing the server. Reconcile your intended changes before saving again.",
"After an unknown write outcome, use Check previous write when offered. If publication may already have succeeded, inspect the refreshed catalog before publishing another copy.",
],
),
explain(
"Return to a local file",
"Choose Save as local... or Ctrl+Shift+S, then save an .arcadia entry to retain included files and comments. A successful source-project save makes that file the current destination and leaves the hosted resource unchanged. JSON exports the model only and retains the hosted destination and unsaved state. Canceling or a failed save also retains the hosted destination. Forget connection becomes available after leaving the hosted session.",
),
explain(
"Connection credentials",
"The desktop keeps credentials in memory only. Use HTTPS remotely; HTTP is allowed only for localhost or a loopback IP. Put the token in its own field, never the service URL. URLs with credentials, queries or fragments are rejected, and redirects are not followed. To update a token, enter it and Connect / refresh for the same service, then retry the check or Save.",
),
walkthrough(
"Automate model edits",
"Build a safe editing client",
&[
"Create or import a project using /api/v1/projects or /api/v1/projects/import. Keep the returned service resource_id; it is distinct from the editable model UUID.",
"Read the project's envelope, source or model. Preserve its exact ETag as the precondition for your edit.",
@@ -795,6 +752,10 @@ fn article(topic: Topic) -> Article {
"Query /artifact-links and /traces to connect domain-tool artifacts to model records. The server stores these references; the integration is responsible for resolving its external artifacts.",
],
),
explain(
"Desktop files and hosted models",
"The desktop currently edits local model files. To exchange with the service, export its /source or /model response and open that file; upload a reviewed candidate through the API with its original revision precondition. The desktop does not maintain a live hosted editing session.",
),
explain(
"Deployment and persistence",
"Non-loopback binding requires a configured bearer token. Use the documented local-disk storage and deployment configuration. The service keeps the current revision and serializes accepted updates; it does not provide retained revision history or server-side undo. Stop the service cleanly before copying its committed store for backup.",
-621
View File
@@ -1,621 +0,0 @@
//! Optional HTTP transport. It never adopts a document or persists credentials.
use oa_core::{
Id, Project,
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle},
};
use reqwest::{Client, Method, Url, header};
use serde::{Deserialize, de::DeserializeOwned};
use std::{fmt, net::IpAddr, sync::Arc, time::Duration};
// The service permits two 8 MiB representations plus envelope framing. Keep
// transport/parser budgets finite even when connecting to a misconfigured host.
const MAX_RESPONSE_BYTES: usize = 17 * 1024 * 1024;
const MAX_PROBLEM_BYTES: usize = 256 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(40);
const SOURCE_LIMITS: ProjectLimits = ProjectLimits {
max_files: 256,
max_include_depth: 32,
max_include_edges: 1_024,
model: oa_core::language::ParseLimits {
max_source_bytes: 8 * 1024 * 1024,
max_graph_objects: 5_000,
max_records: 20_000,
max_step_references: 50_000,
},
};
#[derive(Clone, Default)]
pub struct Secret(pub String);
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret([redacted])")
}
}
#[derive(Clone)]
pub struct Connection {
base: Url,
client: Client,
token: Secret,
}
impl fmt::Debug for Connection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Do not expose headers, credentials, or user-supplied URL text.
f.debug_struct("Connection").finish_non_exhaustive()
}
}
#[derive(Clone, Debug)]
pub struct Remote {
pub resource_id: Id,
pub revision: u64,
pub etag: String,
pub compiled: Arc<CompiledSourceProject>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Catalog {
pub items: Vec<Summary>,
pub next_after: Option<Id>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Summary {
pub resource_id: Id,
pub revision: u64,
pub model_id: Id,
pub name: String,
pub elements: usize,
pub relations: usize,
}
#[derive(Clone, Debug)]
pub struct Failure {
pub detail: String,
pub code: String,
pub status: Option<u16>,
/// A mutation may have committed. Read current state before another write.
pub uncertain: bool,
/// Publication was acknowledged, but its accepted source could not be read.
pub published: Option<Id>,
}
impl Failure {
fn local(code: &str, detail: &str) -> Self {
Self {
detail: detail.into(),
code: code.into(),
status: None,
uncertain: false,
published: None,
}
}
}
impl fmt::Display for Failure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.detail)
}
}
impl std::error::Error for Failure {}
#[derive(Deserialize)]
struct Envelope {
resource_id: Id,
revision: u64,
model: Project,
source_project: SourceBundle,
}
#[derive(Deserialize)]
struct AcceptedSource {
revision: u64,
source_project: SourceBundle,
}
#[derive(Deserialize)]
struct Problem {
code: String,
detail: String,
#[serde(default)]
issues: Vec<ProblemIssue>,
}
#[derive(Deserialize)]
struct ProblemIssue {
message: String,
path: Option<String>,
line: Option<usize>,
column: Option<usize>,
}
struct Reply {
status: u16,
etag: Option<String>,
body: Vec<u8>,
}
impl Connection {
/// `base` is the service root, optionally beneath a reverse-proxy path.
/// Requests append `api/v1/`; credentials belong in `token`, never the URL.
pub fn new(base: &str, token: Secret) -> Result<Self, Failure> {
Self::with_timeout(base, token, REQUEST_TIMEOUT)
}
fn with_timeout(base: &str, token: Secret, timeout: Duration) -> Result<Self, Failure> {
let invalid = || {
Failure::local(
"invalid_endpoint",
"Enter an HTTPS service URL, or HTTP on localhost/loopback, without credentials, query parameters or a fragment.",
)
};
let base = base.trim();
if base.contains('\\')
|| base.chars().any(char::is_control)
|| base.split_once("://").is_some_and(|(_, rest)| {
rest.split(['/', '?', '#'])
.next()
.is_some_and(|authority| authority.contains('@'))
})
{
return Err(invalid());
}
let mut base = Url::parse(base).map_err(|_| invalid())?;
let host = base.host_str().ok_or_else(invalid)?;
let loopback = host.eq_ignore_ascii_case("localhost")
|| host
.trim_matches(['[', ']'])
.parse::<IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
if !(base.scheme() == "https" || (base.scheme() == "http" && loopback))
|| !base.username().is_empty()
|| base.password().is_some()
|| base.query().is_some()
|| base.fragment().is_some()
{
return Err(invalid());
}
if !base.path().ends_with('/') {
// set_path accepts the existing URL's encoded path without decoding
// it; this preserves an optional reverse-proxy prefix exactly.
base.set_path(&format!("{}/", base.path()));
}
let mut headers = header::HeaderMap::new();
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/json"),
);
if !token.0.is_empty() {
if !token.0.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(Failure::local(
"invalid_token",
"The bearer token must contain printable ASCII without whitespace.",
));
}
let mut value =
header::HeaderValue::from_str(&format!("Bearer {}", token.0)).map_err(|_| {
Failure::local(
"invalid_token",
"The bearer token is not a valid HTTP credential.",
)
})?;
value.set_sensitive(true);
headers.insert(header::AUTHORIZATION, value);
}
let mut builder = Client::builder()
.default_headers(headers)
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.connect_timeout(Duration::from_secs(10).min(timeout))
.timeout(timeout)
.user_agent("Arcadia-Studio/hosted-desktop");
if loopback {
builder = builder.no_proxy();
}
let client = builder.build().map_err(|_| {
Failure::local(
"client_unavailable",
"The HTTP client could not be initialized.",
)
})?;
Ok(Self {
base,
client,
token,
})
}
pub fn base(&self) -> &str {
self.base.as_str()
}
fn endpoint(&self, path: &str) -> Url {
// All path suffixes are literals or UUIDs, never user-provided paths.
self.base
.join(&format!("api/v1/{path}"))
.expect("validated hierarchical base")
}
fn redact(&self, text: String) -> String {
let text = if self.token.0.is_empty() {
text
} else {
text.replace(&self.token.0, "[redacted]")
};
// Limit user-visible diagnostics independently of the response budget.
text.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.take(8_192)
.collect()
}
fn invalid_reply(&self, reply: &Reply, mutation: bool, detail: &str) -> Failure {
Failure {
detail: detail.into(),
code: "invalid_response".into(),
status: Some(reply.status),
uncertain: mutation,
published: None,
}
}
fn decode<T: DeserializeOwned>(&self, reply: &Reply, mutation: bool) -> Result<T, Failure> {
serde_json::from_slice(&reply.body).map_err(|_| {
self.invalid_reply(
reply,
mutation,
"The service returned an invalid JSON response. Local work is unchanged.",
)
})
}
fn etag(&self, reply: &Reply, mutation: bool) -> Result<String, Failure> {
reply
.etag
.as_deref()
.filter(|value| valid_etag(value))
.map(str::to_owned)
.ok_or_else(|| {
self.invalid_reply(
reply,
mutation,
"The service did not return a valid strong ETag. Local work is unchanged.",
)
})
}
async fn request(
&self,
method: Method,
url: Url,
bundle: Option<SourceBundle>,
etag: Option<&str>,
) -> Result<Reply, Failure> {
let mutation = method != Method::GET;
let expected_status = if method == Method::POST { 201 } else { 200 };
let mut request = self.client.request(method, url);
if let Some(bundle) = bundle {
let bytes = serde_json::to_vec(&bundle).map_err(|_| {
Failure::local(
"invalid_source_project",
"The source project could not be encoded for the service.",
)
})?;
if bytes.len() > 8 * 1024 * 1024 {
return Err(Failure::local(
"request_too_large",
"The source project exceeds the service's 8 MiB JSON request limit. Your local model remains available; save it locally or reduce the hosted project.",
));
}
request = request
.header(header::CONTENT_TYPE, "application/json")
.body(bytes);
}
if let Some(etag) = etag {
if !valid_etag(etag) {
return Err(Failure::local(
"invalid_etag",
"A captured strong server ETag is required to save. Check the server version first.",
));
}
request = request.header(header::IF_MATCH, etag);
}
let mut response = request.send().await.map_err(|error| Failure {
detail: if error.is_timeout() {
"The service request timed out. Check the server before retrying a write."
} else {
"The service could not be reached or the connection was interrupted. Check the URL, service and credentials."
}.into(),
code: if error.is_timeout() { "timeout" } else { "connection_failed" }.into(),
status: None,
uncertain: mutation && !error.is_connect() && !error.is_builder(),
published: None,
})?;
let status = response.status().as_u16();
let limit = if response.status().is_success() {
MAX_RESPONSE_BYTES
} else {
MAX_PROBLEM_BYTES
};
let uncertain =
mutation && (response.status().is_success() || response.status().is_server_error());
let oversized = || Failure {
detail:
"The service response exceeds the desktop transport limit. Local work is unchanged."
.into(),
code: "response_too_large".into(),
status: Some(status),
uncertain,
published: None,
};
if response
.content_length()
.is_some_and(|size| size > limit as u64)
{
return Err(oversized());
}
let etag = response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| Failure {
detail:
"The service response was interrupted. Check the server before retrying a write."
.into(),
code: "response_interrupted".into(),
status: Some(status),
uncertain,
published: None,
})? {
if chunk.len() > limit.saturating_sub(body.len()) {
return Err(oversized());
}
body.extend_from_slice(&chunk);
}
if status != expected_status {
let mut failure = Failure {
detail: format!("The service returned HTTP {status}. Local work is unchanged."),
code: if (300..400).contains(&status) {
"redirect_refused"
} else {
"http_error"
}
.into(),
status: Some(status),
uncertain,
published: None,
};
if let Ok(problem) = serde_json::from_slice::<Problem>(&body) {
failure.uncertain |= mutation && problem.code == "outcome_unknown";
// These service failures explicitly precede admission/commit.
// Other 5xx responses remain uncertain, including storage I/O.
if matches!(
(status, problem.code.as_str()),
(503, "overloaded") | (507, "store_capacity")
) {
failure.uncertain = false;
}
if !problem.code.is_empty()
&& problem.code.len() <= 80
&& problem
.code
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
{
failure.code = self.redact(problem.code);
}
let mut detail = problem.detail;
for issue in problem.issues.into_iter().take(20) {
detail.push('\n');
if let Some(path) = issue.path {
detail.push_str(&path);
if let Some(line) = issue.line {
detail.push_str(&format!(":{line}"));
if let Some(column) = issue.column {
detail.push_str(&format!(":{column}"));
}
}
detail.push_str(": ");
}
detail.push_str(&issue.message);
}
failure.detail = self.redact(detail);
}
return Err(failure);
}
Ok(Reply { status, etag, body })
}
pub async fn catalog(&self, query: &str, after: Option<Id>) -> Result<Catalog, Failure> {
let mut url = self.endpoint("projects");
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("limit", "100");
if !query.is_empty() {
pairs.append_pair("q", query);
}
if let Some(after) = after {
pairs.append_pair("after", &after.to_string());
}
}
let reply = self.request(Method::GET, url, None, None).await?;
let catalog: Catalog = self.decode(&reply, false)?;
let mut previous = after;
for item in &catalog.items {
if item.resource_id.is_nil()
|| item.model_id.is_nil()
|| item.revision == 0
|| previous.is_some_and(|id| item.resource_id <= id)
{
return Err(self.invalid_reply(
&reply,
false,
"The service returned an invalid project catalog.",
));
}
previous = Some(item.resource_id);
}
if catalog.items.len() > 100
|| catalog
.next_after
.is_some_and(|id| Some(id) != previous || catalog.items.is_empty())
{
return Err(self.invalid_reply(
&reply,
false,
"The service returned invalid catalog pagination.",
));
}
Ok(catalog)
}
pub async fn open(&self, id: Id) -> Result<Remote, Failure> {
let reply = self
.request(
Method::GET,
self.endpoint(&format!("projects/{id}")),
None,
None,
)
.await?;
let envelope: Envelope = self.decode(&reply, false)?;
let etag = self.etag(&reply, false)?;
if envelope.resource_id != id
|| id.is_nil()
|| envelope.revision == 0
|| envelope.model.id.is_nil()
{
return Err(self.invalid_reply(
&reply,
false,
"The service returned a different project identity or invalid revision.",
));
}
let compiled = self.compile(envelope.source_project, &reply, false).await?;
if compiled.decoded.project != envelope.model {
return Err(self.invalid_reply(
&reply,
false,
"The hosted source and model disagree. The document was not opened.",
));
}
Ok(Remote {
resource_id: id,
revision: envelope.revision,
etag,
compiled,
})
}
pub async fn publish(&self, bundle: SourceBundle) -> Result<Remote, Failure> {
let reply = self
.request(
Method::POST,
self.endpoint("projects/import-source-project"),
Some(bundle),
None,
)
.await?;
let receipt: Summary = self.decode(&reply, true)?;
if receipt.resource_id.is_nil() || receipt.model_id.is_nil() || receipt.revision == 0 {
return Err(self.invalid_reply(&reply, true, "The service returned an invalid publication receipt. Inspect the catalog before publishing again."));
}
let published = Some(receipt.resource_id);
let etag = self.etag(&reply, true).map_err(|mut failure| {
failure.published = published;
failure
})?;
let remote = self.open(receipt.resource_id).await.map_err(|mut failure| {
failure.uncertain = true;
failure.published = published;
failure.detail = format!("Publication was acknowledged, but its source could not be read. Open the published resource before trying another publication.\n{}", failure.detail);
failure
})?;
if remote.revision != receipt.revision
|| remote.etag != etag
|| remote.compiled.decoded.project.id != receipt.model_id
{
let mut failure = self.invalid_reply(&reply, true, "The published resource changed before its source was downloaded. Review the published resource before publishing again.");
failure.published = published;
return Err(failure);
}
Ok(remote)
}
pub async fn save(&self, id: Id, etag: &str, bundle: SourceBundle) -> Result<Remote, Failure> {
let reply = self
.request(
Method::PUT,
self.endpoint(&format!("projects/{id}/source-project")),
Some(bundle),
Some(etag),
)
.await?;
let accepted: AcceptedSource = self.decode(&reply, true)?;
let etag = self.etag(&reply, true)?;
if id.is_nil() || accepted.revision == 0 {
return Err(self.invalid_reply(
&reply,
true,
"The service returned an invalid saved revision.",
));
}
let compiled = self.compile(accepted.source_project, &reply, true).await?;
Ok(Remote {
resource_id: id,
revision: accepted.revision,
etag,
compiled,
})
}
async fn compile(
&self,
bundle: SourceBundle,
reply: &Reply,
mutation: bool,
) -> Result<Arc<CompiledSourceProject>, Failure> {
let result = tokio::task::spawn_blocking(move || {
let compiled = source_project::compile_bundle(&bundle, SOURCE_LIMITS)?;
Ok::<_, Vec<source_project::SourceDiagnostic>>((compiled, bundle))
})
.await
.map_err(|_| {
self.invalid_reply(
reply,
mutation,
"The hosted source could not be checked. Local work is unchanged.",
)
})?;
match result {
Ok((compiled, bundle)) if compiled.bundle == bundle && !compiled.decoded.project.id.is_nil() => Ok(Arc::new(compiled)),
Ok(_) => Err(self.invalid_reply(reply, mutation, "The service returned source without stable current-format identities. Local work is unchanged.")),
Err(issues) => {
let mut failure = self.invalid_reply(reply, mutation, "The hosted source could not be compiled. Local work is unchanged.");
for issue in issues.into_iter().take(10) {
failure.detail.push_str(&format!("\n{}:{}:{}: {}", issue.path, issue.diagnostic.line, issue.diagnostic.column, issue.diagnostic.message));
}
failure.detail = self.redact(failure.detail);
Err(failure)
}
}
}
}
fn valid_etag(value: &str) -> bool {
(2..=1_024).contains(&value.len())
&& value.starts_with('"')
&& value.ends_with('"')
&& value.as_bytes()[1..value.len() - 1]
.iter()
.all(|byte| matches!(*byte, 0x21 | 0x23..=0x7e))
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;
@@ -1,609 +0,0 @@
use super::*;
use axum::{
Json, Router,
http::StatusCode,
response::IntoResponse,
routing::{get, post, put},
};
use serde_json::json;
use std::{
collections::BTreeMap,
sync::atomic::{AtomicUsize, Ordering},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::oneshot,
task::JoinHandle,
};
struct TestServer {
base: String,
stop: Option<oneshot::Sender<()>>,
task: JoinHandle<()>,
_directory: Option<tempfile::TempDir>,
}
impl TestServer {
async fn fixture(router: Router) -> Self {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (stop, done) = oneshot::channel();
let task = tokio::spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(async {
let _ = done.await;
})
.await
.unwrap();
});
Self {
base,
stop: Some(stop),
task,
_directory: None,
}
}
async fn production(token: Option<String>, prefix: bool) -> Self {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().to_owned();
let store = Arc::new(
tokio::task::spawn_blocking(move || oa_server::store::Store::open(path))
.await
.unwrap()
.unwrap(),
);
let router = oa_server::app(store, token);
let router = if prefix {
Router::new().nest("/teams/engineering", router)
} else {
router
};
let mut server = Self::fixture(router).await;
server._directory = Some(directory);
if prefix {
server.base.push_str("/teams/engineering");
}
server
}
fn connection(&self) -> Connection {
Connection::new(&self.base, Secret::default()).unwrap()
}
async fn finish(mut self) {
self.stop.take().unwrap().send(()).unwrap();
(&mut self.task).await.unwrap();
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.task.abort();
}
}
fn authored() -> SourceBundle {
let bundle = SourceBundle {
entry: "harbor.arcadia".into(),
files: BTreeMap::from([
("harbor.arcadia".into(), "// Harbor rationale 航海\r\narcadia 4; project harbor \"Harbor & rescue\" {\r\n include \"work/operations.arcadia\";\r\n}\r\n".into()),
("work/operations.arcadia".into(), "// Keep this authored organization\r\noa_activity assess \"Assess assistance\" {}\r\nsa_function assist \"Assist operator\" {}\r\n".into()),
]),
};
source_project::compile_bundle(&bundle, SOURCE_LIMITS)
.unwrap()
.bundle
}
fn envelope(bundle: SourceBundle, resource_id: Id, revision: u64) -> serde_json::Value {
let model = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
.unwrap()
.decoded
.project;
json!({"resource_id":resource_id,"revision":revision,"model":model,"source_project":bundle})
}
#[test]
fn hosted_endpoint_validation_redacts_secrets_and_preserves_proxy_paths() {
for base in [
"http://example.com",
"http://192.168.1.4",
"ftp://localhost",
"not a url",
"https://user:secret@example.com",
"https://@example.com",
"https://user@example.com",
"https://example.com/?token=secret",
"https://example.com/#secret",
"https://example.com\\hidden",
"https://example.com/\nsecret",
] {
let failure = Connection::new(base, Secret("secret".into())).unwrap_err();
assert_eq!(failure.code, "invalid_endpoint");
assert!(!failure.detail.contains("secret"));
assert!(!failure.uncertain);
}
for base in [
"http://localhost:8787",
"http://127.0.0.2:8787",
"http://[::1]:8787",
"https://example.com/prefix",
] {
assert!(Connection::new(base, Secret::default()).is_ok(), "{base}");
}
let client = Connection::new(
"https://example.com/teams/space%20name",
Secret("private-token".into()),
)
.unwrap();
assert_eq!(client.base(), "https://example.com/teams/space%20name/");
assert_eq!(
client.endpoint("projects").as_str(),
"https://example.com/teams/space%20name/api/v1/projects"
);
assert!(!format!("{client:?}").contains("private-token"));
assert_eq!(
format!("{:?}", Secret("private-token".into())),
"Secret([redacted])"
);
assert_eq!(
Connection::new("http://localhost", Secret("bad\nheader".into()))
.unwrap_err()
.code,
"invalid_token"
);
for etag in [
"*",
"W/\"weak\"",
"unquoted",
"\"one\",\"two\"",
"\"new\nline\"",
] {
assert!(!valid_etag(etag), "{etag}");
}
assert!(valid_etag("\"opaque:tag/+,=?\""));
}
#[tokio::test]
async fn hosted_production_roundtrip_preserves_comments_noops_and_conflicts() {
let server = TestServer::production(None, false).await;
let client = server.connection();
assert!(client.catalog("", None).await.unwrap().items.is_empty());
let bundle = authored();
let original = client.publish(bundle.clone()).await.unwrap();
assert_ne!(original.resource_id, original.compiled.decoded.project.id);
assert_eq!(original.compiled.bundle, bundle);
assert_eq!(original.revision, 1);
let catalog = client.catalog("Harbor &", None).await.unwrap();
assert_eq!(catalog.items.len(), 1);
assert_eq!(catalog.items[0].resource_id, original.resource_id);
assert_eq!(
catalog.items[0].model_id,
original.compiled.decoded.project.id
);
assert_eq!(catalog.items[0].name, "Harbor & rescue");
assert_eq!(
(catalog.items[0].elements, catalog.items[0].relations),
(2, 0)
);
assert!(
client
.catalog("?q=missing&limit=1000", None)
.await
.unwrap()
.items
.is_empty()
);
assert!(
client
.catalog("", Some(original.resource_id))
.await
.unwrap()
.items
.is_empty()
);
let same = client
.save(original.resource_id, &original.etag, bundle.clone())
.await
.unwrap();
assert_eq!(same.etag, original.etag);
assert_eq!(same.revision, original.revision);
let mut changed = bundle.clone();
changed
.files
.get_mut("work/operations.arcadia")
.unwrap()
.push_str("// Reviewed by the team\r\n");
let saved = client
.save(original.resource_id, &original.etag, changed.clone())
.await
.unwrap();
assert_eq!(saved.resource_id, original.resource_id);
assert_eq!(saved.revision, 2);
assert_ne!(saved.etag, original.etag);
assert_eq!(saved.compiled.bundle, changed);
assert_eq!(
saved.compiled.decoded.project,
original.compiled.decoded.project
);
let conflict = client
.save(original.resource_id, &original.etag, bundle)
.await
.unwrap_err();
assert_eq!(conflict.status, Some(412));
assert_eq!(conflict.code, "revision_conflict");
assert!(!conflict.uncertain);
assert_eq!(
client
.open(original.resource_id)
.await
.unwrap()
.compiled
.bundle,
changed
);
server.finish().await;
}
#[tokio::test]
async fn hosted_production_auth_proxy_prefix_and_located_rejection() {
let token = "hosted-test-token-with-at-least-32-characters";
let server = TestServer::production(Some(token.into()), true).await;
let unauthorized = server.connection().catalog("", None).await.unwrap_err();
assert_eq!(
(unauthorized.status, unauthorized.code.as_str()),
(Some(401), "unauthorized")
);
let client = Connection::new(&server.base, Secret(token.into())).unwrap();
let original = client.publish(authored()).await.unwrap();
assert_eq!(
client.catalog("Harbor &", None).await.unwrap().items.len(),
1
);
let mut invalid = original.compiled.bundle.clone();
invalid
.files
.get_mut("work/operations.arcadia")
.unwrap()
.push_str("this is not valid source;");
let rejected = client
.save(original.resource_id, &original.etag, invalid)
.await
.unwrap_err();
assert_eq!(rejected.status, Some(422));
assert!(!rejected.uncertain);
assert!(rejected.detail.contains("work/operations.arcadia:"));
assert_eq!(
client.open(original.resource_id).await.unwrap().etag,
original.etag
);
let missing = client.open(Id::new_v4()).await.unwrap_err();
assert_eq!(missing.status, Some(404));
assert!(!missing.uncertain);
server.finish().await;
}
#[tokio::test]
async fn hosted_redirect_is_not_followed_or_retried_and_problem_tokens_are_redacted() {
let redirected = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&redirected);
let destination = TestServer::fixture(Router::new().fallback(move || {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
StatusCode::OK
}
}))
.await;
let location = destination.base.clone();
let attempts = Arc::new(AtomicUsize::new(0));
let seen = Arc::clone(&attempts);
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
let location = location.clone();
let seen = Arc::clone(&seen);
async move { seen.fetch_add(1, Ordering::SeqCst); (StatusCode::TEMPORARY_REDIRECT, [(header::LOCATION, location)]) }
})).route("/api/v1/projects", get(|| async {
(StatusCode::FORBIDDEN, Json(json!({"code":"denied","detail":"Do not print private-test-token", "issues":[{"message":"private-test-token","path":"secret/private-test-token.arcadia","line":4,"column":2}]})))
}))).await;
let client = Connection::new(&server.base, Secret("private-test-token".into())).unwrap();
let failure = client.publish(authored()).await.unwrap_err();
assert_eq!(failure.code, "redirect_refused");
assert!(!failure.uncertain);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
assert_eq!(redirected.load(Ordering::SeqCst), 0);
let failure = client.catalog("", None).await.unwrap_err();
assert!(failure.detail.contains("[redacted]"));
assert!(!format!("{failure:?}").contains("private-test-token"));
server.finish().await;
destination.finish().await;
}
#[tokio::test]
async fn hosted_open_rejects_incoherent_envelopes_missing_etags_and_unstable_ids() {
let id = Id::new_v4();
let stable = authored();
let mut mismatched = envelope(stable.clone(), id, 1);
mismatched["model"]["name"] = json!("different model");
let mut wrong_id = envelope(stable.clone(), Id::new_v4(), 1);
// Keep this independently useful even if the model's schema changes.
wrong_id["revision"] = json!(1);
let mut unstable = envelope(stable.clone(), id, 1);
unstable["source_project"] = json!({"entry":"model.arcadia","files":{"model.arcadia":"arcadia 4; project unstable \"Unstable\" {}"}});
for (body, etag) in [
(mismatched, Some("\"opaque\"")),
(wrong_id, Some("\"opaque\"")),
(envelope(stable.clone(), id, 0), Some("\"opaque\"")),
(envelope(stable, id, 1), None),
(unstable, Some("\"opaque\"")),
] {
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/{id}",
get(move || {
let body = body.clone();
async move {
let mut response = Json(body).into_response();
if let Some(etag) = etag {
response
.headers_mut()
.insert(header::ETAG, etag.parse().unwrap());
}
response
}
}),
))
.await;
let failure = server.connection().open(id).await.unwrap_err();
assert_eq!(failure.code, "invalid_response");
assert!(!failure.uncertain);
server.finish().await;
}
}
#[tokio::test]
async fn hosted_put_preserves_an_opaque_etag_and_compiles_the_accepted_bundle() {
let id = Id::new_v4();
let etag = "\"opaque:tag/+,=?\"";
let bundle = authored();
let response_bundle = bundle.clone();
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/{id}/source-project",
put(
move |headers: header::HeaderMap, Json(body): Json<SourceBundle>| {
let expected_bundle = response_bundle.clone();
async move {
assert_eq!(headers.get(header::IF_MATCH).unwrap(), etag);
assert_eq!(body, expected_bundle);
(
[(header::ETAG, "\"accepted:new-tag\"")],
Json(json!({"revision":8,"changed":false,"source_project":body})),
)
}
},
),
))
.await;
let client = server.connection();
let saved = client.save(id, etag, bundle.clone()).await.unwrap();
assert_eq!(saved.etag, "\"accepted:new-tag\"");
assert_eq!(saved.resource_id, id);
assert_eq!(saved.revision, 8);
assert_eq!(saved.compiled.bundle, bundle.clone());
assert_eq!(
client.save(id, "*", bundle).await.unwrap_err().code,
"invalid_etag"
);
server.finish().await;
}
#[tokio::test]
async fn hosted_publication_retains_known_resource_after_failed_followup() {
let id = Id::new_v4();
let attempts = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&attempts);
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
(StatusCode::CREATED, [(header::ETAG, "\"receipt\"")], Json(json!({"resource_id":id,"revision":1,"model_id":Id::new_v4(),"name":"Published","elements":0,"relations":0})))
}
})).route("/api/v1/projects/{id}", get(|| async { StatusCode::SERVICE_UNAVAILABLE }))).await;
let failure = server.connection().publish(authored()).await.unwrap_err();
assert_eq!(failure.published, Some(id));
assert!(failure.uncertain);
assert_eq!(failure.status, Some(503));
assert_eq!(attempts.load(Ordering::SeqCst), 1);
server.finish().await;
}
/// Reads the entire request before simulating a lost/malformed response. The
/// counter proves the production client never repeats an admitted publication.
async fn raw_fixture(reply: Vec<u8>) -> (String, Arc<AtomicUsize>, JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let attempts = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&attempts);
let task = tokio::spawn(async move {
loop {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buffer = [0; 4096];
loop {
let read = stream.read(&mut buffer).await.unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase();
let length = headers
.lines()
.find_map(|line| {
line.strip_prefix("content-length:")
.and_then(|value| value.trim().parse::<usize>().ok())
})
.unwrap_or(0);
if request.len() >= end + 4 + length {
break;
}
}
}
calls.fetch_add(1, Ordering::SeqCst);
let _ = stream.write_all(&reply).await;
let _ = stream.shutdown().await;
}
});
(base, attempts, task)
}
#[tokio::test]
async fn hosted_lost_publication_response_is_uncertain_without_retry() {
let (base, attempts, task) = raw_fixture(Vec::new()).await;
let client = Connection::new(&base, Secret::default()).unwrap();
let failure = client.publish(authored()).await.unwrap_err();
assert!(failure.uncertain);
assert_eq!(failure.published, None);
assert_eq!(attempts.load(Ordering::SeqCst), 1);
task.abort();
}
#[tokio::test]
async fn hosted_response_limits_cover_declared_and_chunked_bodies() {
let declared = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
MAX_RESPONSE_BYTES + 1
)
.into_bytes();
let mut chunked =
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_vec();
let chunk = vec![b' '; 64 * 1024];
for _ in 0..=(MAX_RESPONSE_BYTES / chunk.len()) {
chunked.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes());
chunked.extend_from_slice(&chunk);
chunked.extend_from_slice(b"\r\n");
}
chunked.extend_from_slice(b"0\r\n\r\n");
for reply in [declared, chunked] {
let (base, _, task) = raw_fixture(reply).await;
let failure = Connection::new(&base, Secret::default())
.unwrap()
.catalog("", None)
.await
.unwrap_err();
assert_eq!(failure.code, "response_too_large");
assert!(!failure.uncertain);
task.abort();
}
}
#[tokio::test]
async fn hosted_timeout_and_disconnect_leave_mutation_outcomes_explicit() {
let received = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&received);
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/import-source-project",
post(move || {
let calls = Arc::clone(&calls);
async move {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(100)).await;
StatusCode::CREATED
}
}),
))
.await;
let client =
Connection::with_timeout(&server.base, Secret::default(), Duration::from_millis(30))
.unwrap();
let failure = client.publish(authored()).await.unwrap_err();
assert_eq!(failure.code, "timeout");
assert!(failure.uncertain);
assert_eq!(received.load(Ordering::SeqCst), 1);
let base = server.base.clone();
server.finish().await;
let failure = Connection::with_timeout(&base, Secret::default(), Duration::from_secs(2))
.unwrap()
.catalog("", None)
.await
.unwrap_err();
assert!(matches!(
failure.code.as_str(),
"connection_failed" | "timeout"
));
assert!(!failure.uncertain);
}
#[tokio::test]
async fn hosted_publication_race_keeps_receipt_identity_for_review() {
let id = Id::new_v4();
let bundle = authored();
let published_model_id = source_project::compile_bundle(&bundle, SOURCE_LIMITS)
.unwrap()
.decoded
.project
.id;
let mut changed = bundle.clone();
changed
.files
.get_mut("work/operations.arcadia")
.unwrap()
.push_str("// Concurrent external review\r\n");
let head = envelope(changed, id, 2);
let server = TestServer::fixture(Router::new().route("/api/v1/projects/import-source-project", post(move || async move {
(StatusCode::CREATED, [(header::ETAG, "\"first-revision\"")], Json(json!({"resource_id":id,"revision":1,"model_id":published_model_id,"name":"Published","elements":2,"relations":0})))
})).route("/api/v1/projects/{id}", get(move || {
let head = head.clone();
async move { ([(header::ETAG, "\"next-revision\"")], Json(head)) }
}))).await;
let failure = server.connection().publish(bundle).await.unwrap_err();
assert!(failure.uncertain);
assert_eq!(failure.published, Some(id));
assert!(failure.detail.contains("changed before"));
server.finish().await;
}
#[tokio::test]
async fn hosted_structured_mutation_failures_distinguish_precommit_rejections() {
for (status, code, uncertain) in [
(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large", false),
(StatusCode::INSUFFICIENT_STORAGE, "store_capacity", false),
(StatusCode::SERVICE_UNAVAILABLE, "overloaded", false),
(StatusCode::SERVICE_UNAVAILABLE, "storage_unavailable", true),
(StatusCode::GATEWAY_TIMEOUT, "outcome_unknown", true),
] {
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/import-source-project",
post(move || async move {
(
status,
Json(json!({"code":code,"detail":"Service-specific actionable detail"})),
)
}),
))
.await;
let failure = server.connection().publish(authored()).await.unwrap_err();
assert_eq!(failure.status, Some(status.as_u16()));
assert_eq!(failure.code, code);
assert_eq!(failure.uncertain, uncertain, "{code}");
assert_eq!(failure.detail, "Service-specific actionable detail");
server.finish().await;
}
}
#[tokio::test]
async fn hosted_oversized_local_bundle_is_rejected_before_network_admission() {
let mut bundle = authored();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str(&" ".repeat(8 * 1024 * 1024));
let failure = Connection::new("http://127.0.0.1:1", Secret::default())
.unwrap()
.publish(bundle)
.await
.unwrap_err();
assert_eq!(failure.code, "request_too_large");
assert!(!failure.uncertain);
assert!(failure.published.is_none());
}
-148
View File
@@ -1,148 +0,0 @@
//! Optional hosted transport state. The Document remains the local editing history.
pub mod client;
pub mod view;
use client::{Catalog, Connection, Failure, Remote, Secret, Summary};
use oa_core::{Id, source_project::CompiledSourceProject};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub enum Event {
Show,
Close,
Base(String),
Token(Secret),
Query(String),
Connect,
More,
Select(Id),
OpenSelected,
Publish,
Save,
Check,
Reload,
ReviewFile(String),
ReviewAction(iced::widget::text_editor::Action),
ReviewBack,
Local,
Forget,
Completed(Id, Id, Result<Outcome, Failure>),
}
#[derive(Debug, Clone)]
pub enum Outcome {
Catalog(Catalog),
Remote(Remote),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
Catalog(bool),
Open(Id),
Publish,
Save,
Check,
}
#[derive(Debug, Clone)]
pub struct Session {
pub connection: Connection,
pub resource_id: Id,
pub revision: u64,
pub etag: String,
}
#[derive(Debug, Clone)]
pub struct Request {
pub id: Id,
pub document: Id,
pub revision: u64,
pub operation: Operation,
pub connection: Connection,
pub resource_id: Option<Id>,
pub etag: Option<String>,
pub snapshot: Arc<CompiledSourceProject>,
}
#[derive(Debug, Clone)]
pub struct Review {
pub connection: Connection,
pub remote: Remote,
}
pub struct State {
pub visible: bool,
pub base: String,
pub token: Secret,
pub query: String,
pub catalog: Vec<Summary>,
pub catalog_stale: bool,
pub next: Option<Id>,
pub selected: Option<Id>,
pub connection: Option<Connection>,
pub session: Option<Session>,
pub request: Option<Request>,
pub uncertain: Option<Request>,
pub review: Option<Review>,
pub review_path: Option<String>,
pub review_editor: iced::widget::text_editor::Content,
pub error: Option<Failure>,
pub open_target: Option<(Connection, Id)>,
}
impl Default for State {
fn default() -> Self {
Self {
visible: false,
base: "http://127.0.0.1:8787".into(),
token: Secret::default(),
query: String::new(),
catalog: vec![],
catalog_stale: true,
next: None,
selected: None,
connection: None,
session: None,
request: None,
uncertain: None,
review: None,
review_path: None,
review_editor: iced::widget::text_editor::Content::new(),
error: None,
open_target: None,
}
}
}
impl State {
pub fn invalidate_catalog(&mut self) {
self.catalog_stale = true;
self.next = None;
}
pub fn catalog_message(&self) -> Option<&'static str> {
if self.connection.is_none() {
Some("Connect / refresh to load models for this service.")
} else if self.catalog_stale {
Some("Catalog may be out of date. Connect / refresh to update these results.")
} else if self.catalog.is_empty() {
Some("No models found. Publish the current model or change the search.")
} else {
None
}
}
/// Keep explicit connection settings in memory, but never carry a resource
/// binding or in-flight result into another local document.
pub fn leave_document(&mut self) {
self.visible = false;
self.session = None;
self.request = None;
self.uncertain = None;
self.review = None;
self.review_path = None;
self.review_editor = iced::widget::text_editor::Content::new();
self.error = None;
self.open_target = None;
}
}
-238
View File
@@ -1,238 +0,0 @@
//! Hosted dialog. Main-header integration is a small caller-owned hook.
use super::{Event, Operation, client::Secret};
use crate::{
app::{App, Message},
style,
views::{action, label},
};
use iced::{
Element, Fill, Font,
widget::{self, button, column, container, row, scrollable, text, text_editor, text_input},
};
use std::collections::BTreeSet;
pub fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> {
if !app.hosted.visible
|| app.pending.is_some()
|| app.file_prompt.is_some()
|| app.workflow.is_some()
|| app.delete_pending
{
return base;
}
widget::stack![
base,
widget::opaque(
container(
container(dialog(app))
.padding(22)
.width((app.window_size.width - 64.).clamp(680., 1000.))
.height((app.window_size.height - 80.).max(400.))
.style(style::card)
)
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
)
]
.into()
}
fn control<'a>(title: &'a str, event: Event, enabled: bool) -> widget::Button<'a, Message> {
button(text(title).size(12))
.padding([9, 12])
.style(style::nav)
.on_press_maybe(enabled.then_some(Message::Hosted(event)))
}
fn dialog(app: &App) -> Element<'_, Message> {
let state = &app.hosted;
let enabled = !app.busy;
if let Some(path) = &state.review_path {
return column![
text("Server source - read only").size(22),
text(path).size(13),
text_editor(&state.review_editor)
.font(Font::MONOSPACE)
.size(13)
.height(Fill)
.on_action(|action| Message::Hosted(Event::ReviewAction(action))),
control("Back to review", Event::ReviewBack, enabled),
]
.spacing(12)
.into();
}
let mut content = column![
label("SERVICE ADDRESS"),
text_input("http://127.0.0.1:8787", &state.base)
.padding(10)
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Base(value)))),
label("BEARER TOKEN - KEPT IN MEMORY ONLY"),
text_input("Optional for a loopback service", &state.token.0)
.secure(true)
.padding(10)
.on_input_maybe(
enabled.then_some(|value| Message::Hosted(Event::Token(Secret(value))))
),
row![
control("Connect / refresh", Event::Connect, enabled),
control(
"Forget connection",
Event::Forget,
enabled && state.session.is_none()
),
]
.spacing(8),
]
.spacing(8);
if let Some(session) = &state.session {
content = content.push(container(column![
text("Current hosted model").size(16),
text(session.connection.base()).size(12),
text(format!("Resource {} - revision {}", session.resource_id, session.revision)).size(12),
label("Edits and Undo stay local. Save sends the complete authored source project."),
row![
control("Check server", Event::Check, enabled),
control("Save as local...", Event::Local, enabled),
].spacing(8),
].spacing(8)).padding(12).style(style::panel));
}
if let Some(error) = &state.error {
content = content.push(
container(
column![
text(if error.uncertain {
"The write outcome is unknown"
} else {
"Hosted action needs attention"
})
.size(16),
text(&error.detail).size(13),
label("Your local model and source history are retained."),
]
.spacing(7),
)
.padding(12)
.style(style::panel),
);
}
if let Some(failed) = &state.uncertain {
if failed.operation == Operation::Publish {
content = content.push(text("A previous publication may already exist. Inspect the catalog before publishing another copy.").size(13));
}
if failed.resource_id.is_some() {
content = content.push(control("Check previous write", Event::Check, enabled));
}
}
if let Some(review) = &state.review {
let remote = &review.remote;
let local = &app.document.bundle().files;
let server = &remote.compiled.bundle.files;
let paths: BTreeSet<_> = local.keys().chain(server.keys()).collect();
let mut changes = column![
text(format!("Review server revision {}", remote.revision)).size(17),
label(format!("{} - resource {}", review.connection.base(), remote.resource_id)),
label("Reload replaces accepted local content as one undoable change. Unapplied drafts are checked first."),
].spacing(8);
if app.document.bundle().entry != remote.compiled.bundle.entry {
changes = changes.push(
text(format!(
"Entry: {} -> {}",
app.document.bundle().entry,
remote.compiled.bundle.entry
))
.size(12),
);
}
for path in paths {
if local.get(path) != server.get(path) {
let change = match (local.contains_key(path), server.contains_key(path)) {
(false, true) => "Added on server",
(true, false) => "Removed on server",
_ => "Changed",
};
changes = changes.push(
row![
text(format!("{change}: {path}")).size(12).width(Fill),
control(
"View server text",
Event::ReviewFile(path.clone()),
enabled && server.contains_key(path)
),
]
.spacing(8)
.align_y(iced::Center),
);
}
}
changes = changes.push(control("Reload server version", Event::Reload, enabled));
content = content.push(container(changes).padding(12).style(style::panel));
}
content = content.push(text("Hosted model catalog").size(17)).push(
row![
text_input("Search model names", &state.query)
.padding(9)
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Query(value))))
.on_submit(Message::Hosted(Event::Connect)),
control("Search", Event::Connect, enabled),
]
.spacing(8),
);
if let Some(message) = state.catalog_message() {
content = content.push(label(message));
}
for item in &state.catalog {
let selected = state.selected == Some(item.resource_id);
content = content.push(
button(
column![
text(&item.name).size(14),
label(format!(
"{} - revision {} - {} objects / {} links",
item.resource_id, item.revision, item.elements, item.relations
)),
]
.spacing(4),
)
.padding(10)
.width(Fill)
.style(if selected {
style::selected
} else {
style::nav
})
.on_press_maybe(enabled.then_some(Message::Hosted(Event::Select(item.resource_id)))),
);
}
if !state.catalog_stale && state.next.is_some() {
content = content.push(control("More models", Event::More, enabled));
}
let publish_label = if state
.uncertain
.as_ref()
.is_some_and(|r| r.operation == Operation::Publish)
{
"Publish another copy"
} else {
"Publish as new"
};
column![
text("Hosted models").size(24),
label("Optional shared storage for desktop, scripts and engineering integrations."),
scrollable(content.spacing(12)).height(Fill),
text(if app.busy { app.status.as_str() } else { "Publishing creates a separate hosted resource. Existing resources change only through Save to server." }).size(12),
row![
control("Close", Event::Close, enabled),
control(publish_label, Event::Publish, enabled),
control("Open selected", Event::OpenSelected, enabled && state.selected.is_some() && state.connection.is_some()),
control("Save to server", Event::Save, enabled && state.session.is_some() && state.review.is_none()),
].spacing(8),
].spacing(12).into()
}
// Kept here so a caller can integrate the feature without duplicating event wiring.
pub fn header_action() -> widget::Button<'static, Message> {
action("Hosted models...", Message::Hosted(Event::Show))
}
-4
View File
@@ -4,8 +4,6 @@ mod behavior;
mod connection_edit;
mod connection_tool;
mod context_menu;
mod data_values;
mod data_view;
mod diagram;
mod document;
mod explorer;
@@ -13,14 +11,12 @@ mod explorer_view;
mod forms;
mod guide;
mod highlight;
mod hosted;
mod model_menu;
mod navigation;
mod port_edit;
mod port_preview;
mod port_workflow;
mod route_edit;
mod semantic_review;
mod source_navigation;
mod source_tree;
mod source_workspace;
+1 -6
View File
@@ -415,15 +415,10 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
if p.layer_of(id).is_some() {
entries.push(item("Create relationship…", Message::AddRelation, true));
entries.push(item(
"Create counterpart / review prose…",
"Create / update counterpart…",
Message::StartTransition,
true,
));
entries.push(item(
"Review semantics…",
Message::StartSemanticReview(id),
p.traces.values().any(|t| t.oa == id || t.sa == id),
));
}
entries.push(item(
"Attach artifact reference…",
+13 -20
View File
@@ -157,9 +157,12 @@ pub fn port_context_rows(project: &Project, port: Id) -> Vec<String> {
}
if let Some(legacy) = legacy {
rows.push(legacy_line(&context, legacy));
if let Some(interface) = legacy.interface {
for item in data::interface_items(project, interface) {
origins.entry(item).or_default().push(format!(
if let Some(interface) = legacy.interface
&& let Some(SharedBody::Interface { items }) =
context.element(interface).and_then(Element::shared)
{
for item in items {
origins.entry(*item).or_default().push(format!(
"preserved interface {} (suggestion only; not an active typed contract)",
context.label(interface)
));
@@ -708,17 +711,11 @@ mod tests {
let (other_fe, commands) = exchange(&mut project, source, target, vec![second]);
project = execute_batch(&project, &commands).unwrap();
let interface = add(&mut project, Kind::Interface, "Incident service");
project.relate(
RelationKind::InterfaceItemAllocation,
"First item",
interface,
first,
);
project.relate(
RelationKind::InterfaceItemAllocation,
"Second item",
interface,
second,
project.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in(
Layer::Sa,
SharedBody::Interface {
items: vec![first, second],
},
);
let connection = &project.relations[&boundary];
let (a, b) = (connection.source(), connection.target());
@@ -793,12 +790,8 @@ mod tests {
..
} = fixture();
let interface = add(&mut project, Kind::Interface, "Preserved contract");
project.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
first,
);
project.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![first] });
let legacy = port(
&mut project,
source,
+6 -3
View File
@@ -168,9 +168,12 @@ impl State {
_ => {}
}
}
if let Some(interface) = old.and_then(|old| old.interface) {
let items = data::interface_items(p, interface);
incoming.extend(items.iter().copied());
if let Some(SharedBody::Interface { items }) = old
.and_then(|old| old.interface)
.and_then(|id| p.elements.get(&id))
.and_then(|e| e.shared())
{
incoming.extend(items);
outgoing.extend(items);
}
for r in p.relations.values() {
-571
View File
@@ -1,571 +0,0 @@
//! Deliberate semantic review of one existing trace; drafts never mutate the model.
use crate::{
app::{Choice, Message},
forms::Draft,
};
use iced::widget::text_editor;
use oa_core::{transitions::*, *};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
mod scalar;
mod view;
pub(crate) use view::summary;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Facts,
References,
Impacts,
}
#[derive(Debug, Clone)]
pub enum Event {
Trace(Choice),
Direction(TransitionDirection),
Select(FacetSelector),
Decide(FacetSelector, Option<SemanticDecision>),
Input(String, String),
Text(String, text_editor::Action),
Shape(String, Value),
Variant(String, Value),
Array(String, Option<usize>, i32),
Append(String, Value),
ValuePath(String, crate::data_values::FieldPathKey),
Tab(Tab),
ShowUnchanged(bool),
ReferenceOa(Choice),
ReferenceSa(Choice),
ReferenceSearch(String),
AddReference,
RemoveReference(Id),
AcceptImpact(usize, bool),
Preview,
}
pub struct State {
pub anchor: Id,
pub trace: Option<Id>,
pub direction: TransitionDirection,
pub facts: Vec<FacetDrift>,
pub selected: Option<FacetSelector>,
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
pub references: BTreeMap<Id, Id>,
pub draft: Option<Draft>,
pub plan: Option<SemanticTransitionPlan>,
pub proposed: Option<Project>,
pub error: Option<String>,
pub tab: Tab,
pub show_unchanged: bool,
pub reference_oa: Option<Choice>,
pub reference_sa: Option<Choice>,
pub reference_search: String,
accepted_impacts: Vec<SemanticImpact>,
}
impl State {
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
let explicit = project.traces.get(&selected);
let anchor = explicit.map_or(selected, |trace| trace.oa);
let layer = project
.layer_of(anchor)
.ok_or("Select an OA or SA object, relationship or trace to review.")?;
let choices: Vec<_> = project
.traces
.values()
.filter(|t| t.oa == anchor || t.sa == anchor)
.collect();
if choices.is_empty() {
return Err("This object has no trace. Create a counterpart or link existing counterparts first.".into());
}
let trace = explicit
.map(|t| t.id)
.or_else(|| (choices.len() == 1).then(|| choices[0].id));
let mut state = Self {
anchor,
trace,
direction: if layer == Layer::Oa {
TransitionDirection::OaToSa
} else {
TransitionDirection::SaToOa
},
facts: vec![],
selected: None,
decisions: BTreeMap::new(),
references: BTreeMap::new(),
draft: None,
plan: None,
proposed: None,
error: None,
tab: Tab::Facts,
show_unchanged: false,
reference_oa: None,
reference_sa: None,
reference_search: String::new(),
accepted_impacts: vec![],
};
state.load_trace(project);
Ok(state)
}
pub fn target(&self, project: &Project) -> Option<Id> {
let trace = project.traces.get(&self.trace?)?;
Some(if self.direction == TransitionDirection::OaToSa {
trace.sa
} else {
trace.oa
})
}
fn load_trace(&mut self, project: &Project) {
self.facts.clear();
self.decisions.clear();
self.selected = None;
self.accepted_impacts.clear();
self.references.clear();
self.draft = self
.target(project)
.and_then(|id| Draft::from_project(project, id));
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
self.references.insert(trace.oa, trace.sa);
}
self.refresh(project);
}
pub fn update(&mut self, project: &Project, event: Event) {
let mut replan = true;
let result = match event {
Event::Trace(choice) => {
if !project
.traces
.get(&choice.id)
.is_some_and(|t| t.oa == self.anchor || t.sa == self.anchor)
{
Err("Choose one of this object's existing traces.".into())
} else {
self.trace = Some(choice.id);
self.load_trace(project);
return;
}
}
Event::Direction(direction) => {
self.direction = direction;
self.load_trace(project);
return;
}
Event::Select(facet) => {
self.selected = Some(facet);
replan = false;
Ok(())
}
Event::Decide(facet, decision) => {
if let Some(decision) = decision {
if !self.editable(project, &facet)
&& !(decision == SemanticDecision::KeepTarget
&& self.can_acknowledge_constructor(project, &facet))
{
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
} else {
self.decisions.insert(facet, decision);
Ok(())
}
} else {
self.decisions.remove(&facet);
Ok(())
}
}
Event::Input(path, value) => self.edit(project, &path, |d| {
d.input(&path, value);
Ok(())
}),
Event::Text(path, action) => self.edit(project, &path, |d| {
d.edit_text(&path, action);
Ok(())
}),
Event::Shape(path, value) => {
self.edit(project, &path, |d| d.shape_scoped(&path, value))
}
Event::Variant(path, value) => self.edit(project, &path, |d| {
if d.value
.pointer(&path)
.is_some_and(|v| crate::forms::same_variant(v, &value))
{
Ok(())
} else {
d.shape_scoped(&path, value)
}
}),
Event::Array(path, index, direction) => {
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
}
Event::Append(path, value) => {
self.edit(project, &path, |d| d.append_scoped(&path, value))
}
Event::ValuePath(path, key) => {
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
}
Event::Tab(tab) => {
self.tab = tab;
replan = false;
Ok(())
}
Event::ShowUnchanged(show) => {
self.show_unchanged = show;
replan = false;
Ok(())
}
Event::ReferenceOa(choice) => {
self.reference_oa = Some(choice);
replan = false;
Ok(())
}
Event::ReferenceSa(choice) => {
self.reference_sa = Some(choice);
replan = false;
Ok(())
}
Event::ReferenceSearch(value) => {
self.reference_search = value;
replan = false;
Ok(())
}
Event::AddReference => match (&self.reference_oa, &self.reference_sa) {
(Some(oa), Some(sa)) => {
self.references.insert(oa.id, sa.id);
Ok(())
}
_ => Err("Choose both an Operational and a System reference.".into()),
},
Event::RemoveReference(id) => {
self.references.remove(&id);
Ok(())
}
Event::AcceptImpact(index, accepted) => {
if let Some(impact) = self
.plan
.as_ref()
.and_then(|p| p.impacts.get(index))
.cloned()
{
self.accepted_impacts.retain(|i| i.object != impact.object);
if accepted {
self.accepted_impacts.push(impact);
}
}
self.refresh(project);
return;
}
Event::Preview => Ok(()),
};
if let Err(error) = result {
self.error = Some(error);
self.plan = None;
self.proposed = None;
return;
}
if replan {
self.accepted_impacts.clear();
self.refresh(project);
}
}
fn edit(
&mut self,
project: &Project,
path: &str,
action: impl FnOnce(&mut Draft) -> Result<(), String>,
) -> Result<(), String> {
let facet = self.selected.as_ref().ok_or("Choose a property first.")?;
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
return Err("Choose Set final value before editing this property.".into());
}
let root = self
.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.ok_or("Selected scalar property is unavailable.")?;
if path != root && !path.starts_with(&format!("{root}/")) {
return Err("Only the selected scalar property can change in this draft.".into());
}
let draft = self.draft.as_mut().ok_or("Choose an existing target.")?;
// The scoped view sends only descendants of the selected scalar property.
// Protected identity and top-level body/perspective controls are never exposed.
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
return Err("Semantic candidates retain identity, alias and perspective.".into());
}
action(draft)
}
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
self.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.is_some()
&& !scalar::grouped(facet)
}
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
if !scalar::grouped(facet) {
return false;
}
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
return false;
};
if trace
.baseline
.as_ref()
.and_then(|b| b.semantic.as_ref())
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == *facet))
{
return false;
}
self.facts
.iter()
.find(|f| f.selector == *facet)
.is_some_and(|f| {
scalar::value(&f.current, Layer::Oa).is_some()
&& scalar::observation(&f.current, Layer::Oa)
== scalar::observation(&f.current, Layer::Sa)
})
}
fn request(&self, project: &Project) -> Result<SemanticTransitionRequest, String> {
let trace = self
.trace
.and_then(|id| project.traces.get(&id))
.ok_or("Choose the trace whose review history you want to update.")?;
let (source, target) = if self.direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
(trace.sa, trace.oa)
};
let mut transition = TransitionRequest::new(self.direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.traces.insert(source, trace.id);
request.references = self.references.clone();
request.reviews.push(SemanticReviewRequest {
source,
resolutions: self
.decisions
.iter()
.map(|(facet, decision)| FacetResolution {
facet: facet.clone(),
decision: *decision,
})
.collect(),
reset: vec![],
});
request.accepted_impacts = self.accepted_impacts.clone();
if self
.decisions
.values()
.any(|d| *d == SemanticDecision::Candidate)
{
let final_value = self.combined_value(project, false)?;
let candidate = if project.elements.contains_key(&target) {
SemanticRecord::Element(
serde_json::from_value(final_value)
.map_err(|e| format!("Final target value: {e}"))?,
)
} else {
SemanticRecord::Relation(
serde_json::from_value(final_value)
.map_err(|e| format!("Final target value: {e}"))?,
)
};
request.candidates.push(candidate);
}
Ok(request)
}
/// Compose selected scalar values for both the candidate and dependent form
/// controls. Incomplete inputs retain their stored shape only for controls;
/// request construction always requires every selected input to parse.
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
let trace = self
.trace
.and_then(|id| project.traces.get(&id))
.ok_or("Choose a trace.")?;
let (source, target) = if self.direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
(trace.sa, trace.oa)
};
let mut final_value = Draft::from_project(project, target)
.ok_or("Target has no editable record.")?
.value;
let draft = self.draft.as_ref().ok_or("Target draft is unavailable.")?;
let mut map = BTreeMap::new();
for (oa, sa) in &self.references {
let (from, to) = if self.direction == TransitionDirection::OaToSa {
(*oa, *sa)
} else {
(*sa, *oa)
};
if map.insert(from, to).is_some_and(|old| old != to) {
return Err("Reverse reference correspondence is ambiguous.".into());
}
}
map.insert(source, target);
for (facet, decision) in &self.decisions {
if *decision == SemanticDecision::KeepTarget {
continue;
}
let path = scalar::binding(project, target, facet)
.ok_or("Selected property is not applicable to the target.")?;
let value = match decision {
SemanticDecision::KeepTarget => continue,
SemanticDecision::Candidate => {
let value = match draft.materialize_scopes(std::slice::from_ref(&path)) {
Ok(value) => value,
Err(_) if form_context => draft.value.clone(),
Err(error) => return Err(error),
};
value
.pointer(&path)
.cloned()
.ok_or("Candidate property is no longer available.")?
}
SemanticDecision::TakeSource => {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::source_value(&fact, self.direction.source_layer(), &map)?
}
};
*final_value
.pointer_mut(&path)
.ok_or("Final target property is unavailable.")? = value;
}
Ok(final_value)
}
pub fn refresh(&mut self, project: &Project) {
self.plan = None;
self.proposed = None;
self.error = None;
let context = self.combined_value(project, true).ok();
if let Some(draft) = &mut self.draft {
draft.set_context(context);
}
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
self.error = Some(
"Several traces are available. Choose the correspondence and review history above."
.into(),
);
return;
};
match semantic_facets(project, trace, &self.references) {
Ok(facts) => self.facts = facts,
Err(error) => {
self.error = Some(error);
return;
}
}
if self
.selected
.as_ref()
.is_none_or(|s| !self.facts.iter().any(|f| &f.selector == s))
{
self.selected = self
.facts
.iter()
.find(|f| f.drift != Drift::Unchanged)
.or(self.facts.first())
.map(|f| f.selector.clone());
}
let request = match self.request(project) {
Ok(request) => request,
Err(error) => {
self.error = Some(error);
return;
}
};
match plan_semantic_transition(project, request) {
Ok(plan) => {
if plan.conflicts.is_empty() {
match prepare_semantic_transition(project, &plan) {
Ok(prepared) => self.proposed = Some(prepared.project),
Err(error) => self.error = Some(error.to_string()),
}
}
self.plan = Some(plan);
}
Err(error) => self.error = Some(error.to_string()),
}
}
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
if self.decisions.is_empty() {
return Err("Choose at least one property to review.".into());
}
if let Some(error) = &self.error {
return Err(error.clone());
}
let plan = self
.plan
.as_ref()
.ok_or("Refresh the review preview before applying.")?;
prepare_semantic_transition(project, plan).map_err(|e| e.to_string())?;
Ok(Command::Batch(plan.commands.clone()))
}
pub fn can_apply(&self) -> bool {
!self.decisions.is_empty()
&& self.error.is_none()
&& self
.plan
.as_ref()
.is_some_and(|p| p.conflicts.is_empty() && p.prepared_fingerprint.is_some())
}
pub fn all_references(&self, project: &Project, layer: Layer) -> Vec<Choice> {
let historical: BTreeSet<_> = self
.facts
.iter()
.flat_map(|f| scalar::references(&f.current))
.chain(
self.trace
.and_then(|id| project.traces.get(&id))
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.into_iter()
.flat_map(|b| b.facets.iter().flat_map(scalar::references)),
)
.collect();
let mut ids: BTreeSet<_> = project
.elements
.keys()
.chain(project.relations.keys())
.chain(project.artifact_links.keys())
.filter(|id| project.layer_of(**id) == Some(layer))
.copied()
.collect();
ids.extend(historical.into_iter().filter(|id| !project.contains(*id)));
let search = self.reference_search.to_lowercase();
let mut choices: Vec<_> = ids
.into_iter()
.map(|id| view::choice(project, id))
.filter(|c| search.is_empty() || c.label.to_lowercase().contains(&search))
.collect();
choices.sort_by(|a, b| a.label.cmp(&b.label));
choices
}
}
pub fn form_event(message: Message) -> Message {
let event = match message {
Message::Field(path, value) => Event::Input(path, value),
Message::FieldText(path, action) => Event::Text(path, action),
Message::FieldShape(path, value) => Event::Shape(path, value),
Message::FieldVariant(path, value) => Event::Variant(path, value),
Message::FieldArray(path, index, direction) => Event::Array(path, index, direction),
Message::FieldAppend(path, value) => Event::Append(path, value),
Message::FieldValuePath(path, key) => Event::ValuePath(path, key),
_ => Event::Preview,
};
Message::Workflow(crate::workflow::Event::Semantic(event))
}
@@ -1,288 +0,0 @@
//! Fixed typed scalar bindings for property forms. Core validates every result.
use oa_core::*;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
struct Own {
whole: Value,
prefix: Vec<String>,
kind: String,
}
fn enum_tag(value: &Value) -> Option<&str> {
value.as_str().or_else(|| {
value
.as_object()
.and_then(|v| v.keys().next().map(String::as_str))
})
}
impl Own {
fn get(project: &Project, id: Id) -> Option<Self> {
let (whole, element) = if let Some(e) = project.elements.get(&id) {
(serde_json::to_value(e).ok()?, true)
} else {
(
serde_json::to_value(project.relations.get(&id)?).ok()?,
false,
)
};
let mut prefix = vec!["body".to_string()];
let mut value = &whole["body"];
if element {
let layer = enum_tag(value)?.to_string();
prefix.push(layer.clone());
value = &value[&layer];
if enum_tag(value) == Some("Shared") {
prefix.push("Shared".into());
value = &value["Shared"];
}
}
let kind = enum_tag(value)?.to_string();
prefix.push(kind.clone());
Some(Self {
whole,
prefix,
kind,
})
}
}
fn at<'a>(mut value: &'a Value, path: &[String]) -> Option<&'a Value> {
for part in path {
value = value.get(part)?;
}
Some(value)
}
fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
use FacetSelector as F;
let (family, parts): (&str, &[&str]) = match facet {
F::Header(HeaderFacet::Name) => return Some(vec!["name".into()]),
F::Header(HeaderFacet::Description) => return Some(vec!["description".into()]),
F::Header(HeaderFacet::Parent) => {
return own.whole.get("parent").map(|_| vec!["parent".into()]);
}
F::ExchangeItem(ExchangeItemFacet::Nature) => ("ExchangeItem", &["kind"]),
F::ExchangeItem(ExchangeItemFacet::Mechanism) => ("ExchangeItem", &["mechanism"]),
F::ExchangeItem(ExchangeItemFacet::Supertype) => ("ExchangeItem", &["supertype"]),
F::Interface(InterfaceFacet::Supertype) => ("Interface", &["supertype"]),
F::Data(DataFacet::Constructor) => ("DataType", &["definition"]),
F::Data(DataFacet::IntegerMinimum) => ("DataType", &["definition", "Integer", "minimum"]),
F::Data(DataFacet::IntegerMaximum) => ("DataType", &["definition", "Integer", "maximum"]),
F::Data(DataFacet::RealMinimum) => ("DataType", &["definition", "Real", "minimum"]),
F::Data(DataFacet::RealMaximum) => ("DataType", &["definition", "Real", "maximum"]),
F::Data(DataFacet::StringMinimumLength) => {
("DataType", &["definition", "String", "minimum_length"])
}
F::Data(DataFacet::StringMaximumLength) => {
("DataType", &["definition", "String", "maximum_length"])
}
F::Data(DataFacet::RecordSupertype) => ("DataType", &["definition", "Record", "supertype"]),
F::Data(DataFacet::Default) => ("DataType", &["default"]),
F::Data(DataFacet::Unit) => ("DataType", &["unit"]),
F::Field(FieldFacet::Type) => ("DataField", &["type_ref"]),
F::Field(FieldFacet::Minimum) => ("DataField", &["minimum"]),
F::Field(FieldFacet::Maximum) => ("DataField", &["maximum"]),
F::Field(FieldFacet::Unit) => ("DataField", &["unit"]),
F::Field(FieldFacet::Direction) => ("DataField", &["direction"]),
F::Field(FieldFacet::Storage) => ("DataField", &["storage"]),
F::Field(FieldFacet::Default) => ("DataField", &["default"]),
F::Port(PortFacet::Constructor) => ("Port", &["contract"]),
F::Port(PortFacet::ComponentKind) => ("Port", &["contract", "Component", "kind"]),
F::Port(PortFacet::Orientation) => ("Port", &["contract", "Component", "orientation"]),
F::Port(PortFacet::LegacyDirection) => ("Port", &["legacy", "direction"]),
F::Port(PortFacet::LegacyInterface) => ("Port", &["legacy", "interface"]),
F::Scenario(ScenarioFacet::Viewpoint) => ("Scenario", &["viewpoint"]),
F::Scenario(ScenarioFacet::Context) => ("Scenario", &["context"]),
F::Scenario(ScenarioFacet::Precondition) => ("Scenario", &["precondition"]),
F::Scenario(ScenarioFacet::Postcondition) => ("Scenario", &["postcondition"]),
F::Lifeline(LifelineFacet::Represented) => ("Lifeline", &["represented"]),
F::Occurrence(OccurrenceFacet::Constructor) => ("Occurrence", &["body"]),
F::Occurrence(OccurrenceFacet::Precondition) => ("Occurrence", &["precondition"]),
F::Occurrence(OccurrenceFacet::Postcondition) => ("Occurrence", &["postcondition"]),
F::Occurrence(OccurrenceFacet::Exchange) => {
("Occurrence", &["body", "Exchange", "exchange"])
}
F::Occurrence(OccurrenceFacet::Sender) => ("Occurrence", &["body", "Exchange", "sender"]),
F::Occurrence(OccurrenceFacet::Receiver) => {
("Occurrence", &["body", "Exchange", "receiver"])
}
F::Occurrence(OccurrenceFacet::MessageKind) => {
("Occurrence", &["body", "Exchange", "message_kind"])
}
F::Occurrence(OccurrenceFacet::TimerExpression) => (
"Occurrence",
&["body", "Exchange", "message_kind", "Timer", "expression"],
),
F::Occurrence(OccurrenceFacet::ReplyTo) => {
("Occurrence", &["body", "Exchange", "reply_to"])
}
F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]),
F::Occurrence(OccurrenceFacet::ActionLifeline) => {
("Occurrence", &["body", "Action", "lifeline"])
}
F::Occurrence(OccurrenceFacet::ControlKind) => ("Occurrence", &["body", "Control", "kind"]),
F::Occurrence(OccurrenceFacet::Sequence) => {
("Occurrence", &["body", "SequenceReference", "sequence"])
}
F::Occurrence(OccurrenceFacet::Scenario) => {
("Occurrence", &["body", "ScenarioUse", "scenario"])
}
F::Occurrence(OccurrenceFacet::State) => ("Occurrence", &["body", "State", "state"]),
F::Occurrence(OccurrenceFacet::StateLifeline) => {
("Occurrence", &["body", "State", "lifeline"])
}
F::Occurrence(OccurrenceFacet::FragmentOperator) => {
("Occurrence", &["body", "Fragment", "operator"])
}
F::Occurrence(OccurrenceFacet::BranchGuard) => ("Occurrence", &["body", "Branch", "guard"]),
F::Event(EventFacet::Constructor) => ("Event", &["body"]),
F::Event(EventFacet::TimeKind) => ("Event", &["body", "Time", "kind"]),
F::Event(EventFacet::TimeExpression) => ("Event", &["body", "Time", "expression"]),
F::Event(EventFacet::ChangeExpression) => ("Event", &["body", "Change", "expression"]),
F::State(StateFacet::Kind) => ("State", &["kind"]),
F::State(StateFacet::Entry) => ("State", &["entry"]),
F::State(StateFacet::Exit) => ("State", &["exit"]),
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
F::Relation(RelationFacet::Source) => ("*", &["source"]),
F::Relation(RelationFacet::Target) => ("*", &["target"]),
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]),
F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]),
F::Relation(RelationFacet::SourceRole) => ("DataAssociation", &["source_role"]),
F::Relation(RelationFacet::TargetRole) => ("DataAssociation", &["target_role"]),
F::Relation(RelationFacet::SourceMinimum) => ("DataAssociation", &["source_minimum"]),
F::Relation(RelationFacet::SourceMaximum) => ("DataAssociation", &["source_maximum"]),
F::Relation(RelationFacet::TargetMinimum) => ("DataAssociation", &["target_minimum"]),
F::Relation(RelationFacet::TargetMaximum) => ("DataAssociation", &["target_maximum"]),
F::Relation(RelationFacet::SourceNavigable) => ("DataAssociation", &["source_navigable"]),
F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]),
F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]),
F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]),
F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]),
F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]),
F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]),
F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]),
F::Relation(RelationFacet::Trigger) => ("StateTransition", &["trigger"]),
F::Relation(RelationFacet::Guard) => ("StateTransition", &["guard"]),
F::Relation(RelationFacet::Effect) => ("StateTransition", &["effect"]),
F::Relation(RelationFacet::StateActionPhase) => ("StateAction", &["phase"]),
_ => return None,
};
if family != "*" && own.kind != family {
return None;
}
let mut path = own.prefix.clone();
path.extend(parts.iter().map(|s| s.to_string()));
at(&own.whole, &path).map(|_| path)
}
pub(super) fn binding(project: &Project, id: Id, facet: &FacetSelector) -> Option<String> {
let own = Own::get(project, id)?;
path(&own, facet).map(|parts| format!("/{}", parts.join("/")))
}
pub(super) fn grouped(facet: &FacetSelector) -> bool {
matches!(
facet,
FacetSelector::Data(DataFacet::Constructor)
| FacetSelector::Port(PortFacet::Constructor)
| FacetSelector::Occurrence(
OccurrenceFacet::Constructor | OccurrenceFacet::MessageKind
)
| FacetSelector::Event(EventFacet::Constructor)
)
}
fn pair_json(fact: &FacetReview) -> Value {
let json = serde_json::to_value(fact).expect("Typed semantic fact");
let (family, value) = json
.as_object()
.expect("Typed family")
.iter()
.next()
.expect("Family");
match family.as_str() {
"Member" | "Order" | "Literal" | "Annotation" | "Binding" | "Child" | "Incident" => {
value["review"].clone()
}
"Artifact" => value["review"]
.as_object()
.expect("Artifact leaf")
.values()
.next()
.expect("Artifact value")
.clone(),
_ => value
.as_object()
.expect("Scalar leaf")
.values()
.next()
.expect("Scalar value")
.clone(),
}
}
pub(super) fn observation(fact: &FacetReview, layer: Layer) -> Value {
pair_json(fact)[match layer {
Layer::Oa => "oa",
Layer::Sa => "sa",
}]
.clone()
}
pub(super) fn value(fact: &FacetReview, layer: Layer) -> Option<Value> {
observation(fact, layer).get("Value").cloned()
}
pub(super) fn source_value(
fact: &FacetReview,
source: Layer,
correspondence: &BTreeMap<Id, Id>,
) -> Result<Value, String> {
// Retain only the source observation before invoking the core's typed UUID
// visitor. UUID-shaped prose must never be treated as a reference.
let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?;
let leaf = json
.as_object_mut()
.ok_or("Missing scalar family")?
.values_mut()
.next()
.ok_or("Missing scalar family")?
.as_object_mut()
.ok_or("Missing scalar leaf")?
.values_mut()
.next()
.ok_or("Missing scalar leaf")?;
let selected = observation(fact, source);
leaf["oa"] = selected.clone();
leaf["sa"] = selected;
let typed = serde_json::from_value(json).map_err(|e| e.to_string())?;
let mut baseline = SemanticBaseline {
facets: vec![typed],
};
let mut missing = BTreeSet::new();
visit_semantic_references(&mut baseline, |id| {
if let Some(mapped) = correspondence.get(id) {
*id = *mapped;
} else {
missing.insert(*id);
}
});
if !missing.is_empty() {
return Err(format!(
"Choose reference counterparts before taking this value: {}",
missing
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
));
}
value(&baseline.facets[0], source)
.ok_or("Source property is not applicable; review its constructor first.".into())
}
pub(super) fn references(fact: &FacetReview) -> BTreeSet<Id> {
let mut ids = BTreeSet::new();
visit_semantic_references(
&mut SemanticBaseline {
facets: vec![fact.clone()],
},
|id| {
ids.insert(*id);
},
);
ids
}
@@ -1,988 +0,0 @@
use super::*;
use crate::{app::App, document::Document, workflow::Workflow};
use serde_json::json;
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
}
fn apply(app: &mut App) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
}
fn state(app: &App) -> &State {
let Some(Workflow::Semantic(state)) = &app.workflow else {
panic!("Semantic workflow missing: {}", app.status)
};
state
}
fn fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Operational range", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "System range", None)
.unwrap();
for (id, min, max, unit) in [(oa, 0, 20, "m"), (sa, 1, 30, "cm")] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(min),
maximum: Some(max),
},
default: None,
unit: unit.into(),
};
}
let trace = add_trace(&mut p, oa, sa, "Range rationale");
(p, oa, sa, trace)
}
fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
let id = Id::new_v4();
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{}", id.simple()),
name: name.into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
id
}
fn start(app: &mut App, source: Id) {
let _ = app.update(Message::StartSemanticReview(source));
}
fn facet(value: DataFacet) -> FacetSelector {
FacetSelector::Data(value)
}
#[test]
fn independent_review_clear_bound_ignores_retained_invalid_other_facet() {
let (p, oa, sa, _) = fixture();
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, oa);
let minimum = "/body/Sa/Shared/DataType/definition/Integer/minimum";
let maximum = "/body/Sa/Shared/DataType/definition/Integer/maximum";
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(minimum.into(), "-".into()));
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::IntegerMaximum),
SemanticDecision::Candidate,
);
assert!(state(&app).can_apply());
// Exact event emitted by the upper bound's visible Clear bound button.
send(&mut app, Event::Shape(maximum.into(), Value::Null));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[minimum].raw, "-");
assert!(
state(&app)
.draft
.as_ref()
.unwrap()
.value
.pointer(maximum)
.unwrap()
.is_null()
);
// Clear must also discard the selected invalid value, without requiring it
// to parse before removal. A valid but uncommitted neighboring input survives.
send(&mut app, Event::Shape(maximum.into(), json!(30)));
send(&mut app, Event::Input(maximum.into(), "29".into()));
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
assert!(!state(&app).can_apply());
send(&mut app, Event::Shape(minimum.into(), Value::Null));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs[maximum].raw,
"29"
);
assert!(
!state(&app)
.draft
.as_ref()
.unwrap()
.inputs
.contains_key(minimum)
);
assert_eq!(app.document.bundle(), &before.bundle);
apply(&mut app);
assert!(matches!(
app.project().elements[&sa].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: None,
maximum: Some(29)
},
..
})
));
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn scoped_literal_list_edits_preserve_unfinished_values_and_other_facet_buffers() {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let item = p
.add_element(Kind::ExchangeItem, layer, "Payload", None)
.unwrap();
let field = p
.add_element(Kind::DataField, layer, "Numbers", Some(item))
.unwrap();
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref: TypeRef::Integer,
minimum: 0,
maximum: None,
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: Some(vec![DataValue::Integer(1), DataValue::Integer(2)]),
};
pair.push(field);
}
add_trace(&mut p, pair[0], pair[1], "Numbers review");
let mut app = App::with_project(p);
start(&mut app, pair[0]);
let root = "/body/Sa/Shared/DataField";
let minimum = format!("{root}/minimum");
let default = format!("{root}/default");
let first = format!("{default}/0/Integer");
let second = format!("{default}/1/Integer");
decide(
&mut app,
FacetSelector::Field(FieldFacet::Minimum),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(minimum.clone(), "-".into()));
decide(
&mut app,
FacetSelector::Field(FieldFacet::Minimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
FacetSelector::Field(FieldFacet::Default),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(first.clone(), "-".into()));
send(&mut app, Event::Input(second.clone(), "23".into()));
send(
&mut app,
Event::Append(default.clone(), json!({"Integer": 3})),
);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs[&second].raw,
"23"
);
// Same-variant reselection must not throw away the invalid authored value.
send(
&mut app,
Event::Variant(format!("{default}/0"), json!({"Integer": 0})),
);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
send(&mut app, Event::Array(default.clone(), Some(0), 1));
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "23");
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&second].raw, "-");
assert!(!state(&app).can_apply());
send(&mut app, Event::Array(default, Some(1), 0));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs[&minimum].raw,
"-"
);
apply(&mut app);
assert!(
matches!(app.project().elements[&pair[1]].shared(), Some(SharedBody::DataField {
default: Some(values), minimum: 0, ..
}) if values == &vec![DataValue::Integer(23), DataValue::Integer(3)])
);
}
#[test]
fn independent_review_keyed_fact_titles_keep_live_deleted_and_qualified_identity() {
let mut p = Project::empty();
let a = Id::new_v4();
let b = Id::new_v4();
let first = FacetSelector::Child(KeyPair {
oa: Some(a),
sa: None,
});
let second = FacetSelector::Child(KeyPair {
oa: Some(b),
sa: None,
});
let first_title = super::view::title(&p, &first);
let second_title = super::view::title(&p, &second);
assert_ne!(first_title, second_title);
assert!(first_title.contains(&a.to_string()));
assert!(second_title.contains(&b.to_string()));
let one = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Same name", None)
.unwrap();
let two = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Same name", None)
.unwrap();
let field = p
.add_element(Kind::DataField, Layer::Oa, "Value", Some(one))
.unwrap();
let annotation = |path| {
FacetSelector::Annotation(KeyPair {
oa: Some(AnnotationKey { path, field }),
sa: None,
})
};
let first_title = super::view::title(&p, &annotation(vec![one, a]));
let second_title = super::view::title(&p, &annotation(vec![two, b]));
assert_ne!(first_title, second_title);
for id in [one, field] {
assert!(first_title.contains(&format!("@{}", p.elements[&id].alias)));
}
assert!(first_title.contains(&a.to_string()));
assert!(second_title.contains(&b.to_string()));
assert_ne!(super::view::title(&p, &annotation(vec![])), first_title);
}
fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) {
send(app, Event::Select(selected.clone()));
send(app, Event::Decide(selected, Some(decision)));
}
#[test]
fn scalar_keep_take_candidate_commit_exact_source_and_history_in_both_directions() {
for reverse in [false, true] {
let (p, oa, sa, trace) = fixture();
let mut app = App::with_project(p);
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Retain this engineering rationale.\r\n");
app.document.apply_bundle(bundle).unwrap();
let before = app.document.snapshot();
let revision = app.document.revision();
let (source, target, prefix, expected_max, kept_min) = if reverse {
(sa, oa, "/body/Oa/Shared/DataType", 30, 0)
} else {
(oa, sa, "/body/Sa/Shared/DataType", 20, 1)
};
start(&mut app, source);
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::IntegerMaximum),
SemanticDecision::TakeSource,
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(format!("{prefix}/unit"), "kg\n exact suffix ".into()),
);
let prepared =
prepare_semantic_transition(app.project(), state(&app).plan.as_ref().unwrap())
.unwrap()
.project;
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
app.project(),
&before.decoded.project,
"Preview must not mutate accepted state"
);
apply(&mut app);
assert!(app.workflow.is_none(), "{}", app.status);
assert_eq!(
app.project(),
&prepared,
"Apply must equal the exact reviewed project including diagrams"
);
assert_eq!(app.document.revision(), revision + 1);
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer { minimum, maximum },
unit,
..
}) = app.project().elements[&target].shared()
else {
panic!()
};
assert_eq!((*minimum, *maximum), (Some(kept_min), Some(expected_max)));
assert_eq!(unit, "kg\n exact suffix ");
let baseline = app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
assert_eq!(baseline.facets.len(), 3, "Unselected facts stay unreviewed");
assert!(
app.document.bundle().files[&app.document.bundle().entry]
.contains("// Retain this engineering rationale.")
);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.project(), &accepted.decoded.project);
}
}
#[test]
fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() {
let (p, oa, _, _) = fixture();
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
send(&mut app, Event::Input(path.into(), "-".into()));
assert!(!state(&app).can_apply());
send(&mut app, Event::Tab(Tab::References));
assert!(
state(&app).error.is_some(),
"Navigation must retain visible validation errors"
);
apply(&mut app);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-");
assert_eq!(app.document.bundle(), &before.bundle);
send(&mut app, Event::Input(path.into(), "2".into()));
send(&mut app, Event::Input("/alias".into(), "unexpected".into()));
assert!(!state(&app).can_apply());
assert!(
state(&app)
.error
.as_ref()
.unwrap()
.contains("selected scalar")
);
assert_eq!(app.project(), &before.decoded.project);
send(&mut app, Event::Preview);
assert!(state(&app).can_apply());
}
#[test]
fn explicit_trace_selection_updates_only_that_history() {
let (mut p, oa, sa, first) = fixture();
let second = add_trace(&mut p, oa, sa, "Independent assessment");
p.traces.get_mut(&second).unwrap().kind = TraceKind::Item;
let mut app = App::with_project(p);
start(&mut app, oa);
assert!(state(&app).trace.is_none());
assert!(!state(&app).can_apply());
send(
&mut app,
Event::Trace(Choice {
id: second,
label: "Independent assessment".into(),
}),
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
apply(&mut app);
assert!(app.workflow.is_none(), "{}", app.status);
assert!(
app.project().traces[&first]
.baseline
.as_ref()
.and_then(|b| b.semantic.as_ref())
.is_none()
);
assert!(
app.project().traces[&second]
.baseline
.as_ref()
.unwrap()
.semantic
.is_some()
);
}
#[test]
fn stale_plan_and_source_writeback_failure_do_not_lose_decisions_or_history() {
let (p, oa, _, _) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
app.document
.execute(Command::Rename(oa, "Changed outside preview".into()))
.unwrap();
let before = app.document.snapshot();
let revision = app.document.revision();
apply(&mut app);
assert!(state(&app).error.as_ref().unwrap().contains("stale"));
assert_eq!(state(&app).decisions.len(), 1);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), revision);
let (p, oa, _, _) = fixture();
let mut app = App::with_project(p);
let bundle = app.document.bundle().clone();
let mut limits = source_project::ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 10;
app.document = Document::from_bundle(bundle, limits).unwrap();
let before = app.document.snapshot();
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(
"/body/Sa/Shared/DataType/unit".into(),
"long final unit ".repeat(40),
),
);
assert!(state(&app).can_apply());
apply(&mut app);
assert!(state(&app).error.is_some());
assert_eq!(state(&app).decisions.len(), 1);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo());
}
#[test]
fn candidate_mixed_with_reference_take_uses_typed_correspondence_not_text_guessing() {
let (mut p, oa, sa, _) = fixture();
let parent_oa = p
.add_element(Kind::DataType, Layer::Oa, "OA parent", None)
.unwrap();
let parent_sa = p
.add_element(Kind::DataType, Layer::Sa, "SA parent", None)
.unwrap();
for id in [parent_oa, parent_sa] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
}
// ExchangeItem supertypes are an independent optional typed reference.
for (id, parent) in [(oa, Some(parent_oa)), (sa, None)] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: parent },
default: None,
unit: String::new(),
};
}
let mut app = App::with_project(p);
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::RecordSupertype),
SemanticDecision::TakeSource,
);
assert!(!state(&app).can_apply());
send(
&mut app,
Event::ReferenceOa(Choice {
id: parent_oa,
label: "OA parent".into(),
}),
);
send(
&mut app,
Event::ReferenceSa(Choice {
id: parent_sa,
label: "SA parent".into(),
}),
);
send(&mut app, Event::AddReference);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(
"/body/Sa/Shared/DataType/unit".into(),
parent_oa.to_string(),
),
);
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
apply(&mut app);
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
unit,
..
}) = app.project().elements[&sa].shared()
else {
panic!()
};
assert_eq!(*supertype, Some(parent_sa));
assert_eq!(
unit,
&parent_oa.to_string(),
"UUID-shaped prose must not be remapped"
);
}
#[test]
fn read_only_structured_facts_and_partial_rollup_are_honest() {
let (p, oa, _, trace) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
send(
&mut app,
Event::Decide(
facet(DataFacet::Constructor),
Some(SemanticDecision::TakeSource),
),
);
assert!(state(&app).decisions.is_empty());
assert!(state(&app).error.is_some());
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
apply(&mut app);
let rollup = trace_semantic_drift(
app.project(),
&app.project().traces[&trace],
&BTreeMap::new(),
)
.unwrap();
assert!(rollup.own_unreviewed);
assert!(summary(app.project(), &app.project().traces[&trace]).contains("Unreviewed"));
let mut request = State::new(app.project(), trace).unwrap();
request.update(
app.project(),
Event::Select(facet(DataFacet::IntegerMaximum)),
);
let _ = request.view(app.project());
assert_eq!(
scalar::value(
&capture_facet(
app.project(),
oa,
request.target(app.project()).unwrap(),
&facet(DataFacet::Default)
)
.unwrap(),
Layer::Oa
),
Some(json!(null))
);
}
#[test]
fn first_equal_constructor_acknowledges_only_kind_and_allows_independent_leaves() {
let (p, oa, _, trace) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
assert!(state(&app).can_acknowledge_constructor(app.project(), &facet(DataFacet::Constructor)));
decide(
&mut app,
facet(DataFacet::Constructor),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input("/body/Sa/Shared/DataType/unit".into(), "local unit".into()),
);
assert!(
state(&app).can_apply(),
"{:?}",
state(&app).plan.as_ref().map(|p| &p.conflicts)
);
apply(&mut app);
let facts = &app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets;
assert_eq!(facts.len(), 2);
assert!(
facts
.iter()
.any(|f| f.selector() == facet(DataFacet::Constructor))
);
assert!(
!facts
.iter()
.any(|f| f.selector() == facet(DataFacet::IntegerMinimum))
);
start(&mut app, oa);
assert!(
!state(&app).can_acknowledge_constructor(app.project(), &facet(DataFacet::Constructor))
);
let remaining: Vec<_> = state(&app)
.facts
.iter()
.filter(|f| f.drift == Drift::Unreviewed)
.map(|f| f.selector.clone())
.collect();
for selector in remaining {
decide(&mut app, selector, SemanticDecision::KeepTarget);
}
apply(&mut app);
assert!(app.workflow.is_none(), "{}", app.status);
assert!(
!trace_semantic_drift(
app.project(),
&app.project().traces[&trace],
&BTreeMap::new()
)
.unwrap()
.own_unreviewed
);
}
#[test]
fn state_kind_scalar_uses_typed_candidate_and_core_validation() {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let machine = p
.add_element(Kind::StateMachine, layer, "Machine", None)
.unwrap();
let region = p
.add_element(Kind::Region, layer, "Region", Some(machine))
.unwrap();
pair.push(
p.add_element(Kind::State, layer, "Ready", Some(region))
.unwrap(),
);
}
add_trace(&mut p, pair[0], pair[1], "State review");
let mut app = App::with_project(p);
start(&mut app, pair[0]);
decide(
&mut app,
FacetSelector::State(StateFacet::Kind),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Shape("/body/Sa/Shared/State/kind".into(), json!("Final")),
);
assert!(
state(&app).can_apply(),
"{:?}",
state(&app).plan.as_ref().map(|p| &p.conflicts)
);
let expected = state(&app).proposed.clone().unwrap();
apply(&mut app);
assert_eq!(app.project(), &expected);
assert_eq!(app.project().elements[&pair[1]].kind(), Kind::Final);
}
#[test]
fn relation_candidates_preserve_endpoints_and_combine_selected_roles_and_bounds() {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let a = p
.add_element(Kind::DataType, layer, "Origin type", None)
.unwrap();
let b = p
.add_element(Kind::DataType, layer, "Destination type", None)
.unwrap();
for id in [a, b] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
}
let relation = p.relate(RelationKind::DataAssociation, "Association", a, b);
if let RelationBody::DataAssociation { source_role, .. } =
&mut p.relations.get_mut(&relation).unwrap().body
{
*source_role = if layer == Layer::Oa {
"Authored source role"
} else {
"Local role"
}
.into();
}
pair.push(relation);
}
add_trace(&mut p, pair[0], pair[1], "Association review");
let original = p.relations[&pair[1]].clone();
let mut app = App::with_project(p);
start(&mut app, pair[0]);
decide(
&mut app,
FacetSelector::Relation(RelationFacet::SourceRole),
SemanticDecision::TakeSource,
);
decide(
&mut app,
FacetSelector::Relation(RelationFacet::TargetMaximum),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Shape("/body/DataAssociation/target_maximum".into(), json!(4)),
);
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
apply(&mut app);
let accepted = &app.project().relations[&pair[1]];
assert_eq!(accepted.alias, original.alias);
assert_eq!(
(accepted.source(), accepted.target()),
(original.source(), original.target())
);
let RelationBody::DataAssociation {
source_role,
target_maximum,
..
} = &accepted.body
else {
panic!()
};
assert_eq!(source_role, "Authored source role");
assert_eq!(*target_maximum, Some(4));
}
#[test]
fn deselected_invalid_candidate_is_retained_but_does_not_poison_another_selected_scalar() {
let (p, oa, sa, _) = fixture();
let mut app = App::with_project(p);
start(&mut app, oa);
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(path.into(), "-".into()));
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(
"/body/Sa/Shared/DataType/unit".into(),
"reviewed local".into(),
),
);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-");
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
apply(&mut app);
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer { minimum, .. },
unit,
..
}) = app.project().elements[&sa].shared()
else {
panic!()
};
assert_eq!(*minimum, Some(1));
assert_eq!(unit, "reviewed local");
}
#[test]
fn inherited_annotation_impact_requires_exact_acceptance_and_edits_clear_approval() {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Operational record", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "System record", None)
.unwrap();
let base = p
.add_element(Kind::DataType, Layer::Sa, "Inherited record", None)
.unwrap();
for (id, supertype) in [(oa, None), (sa, Some(base)), (base, None)] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
default: None,
unit: String::new(),
};
}
let leaf = p
.add_element(Kind::DataField, Layer::Sa, "Inherited value", Some(base))
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Telemetry", None)
.unwrap();
let root = p
.add_element(Kind::DataField, Layer::Sa, "Position", Some(item))
.unwrap();
for (id, type_ref) in [(leaf, TypeRef::Real), (root, TypeRef::Named(sa))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
}
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let exchange = p.relate(RelationKind::FunctionalExchange, "Telemetry exchange", a, b);
let (source_port, target_port) = match &mut p.relations.get_mut(&exchange).unwrap().body {
RelationBody::FunctionalExchange {
source_port,
target_port,
items,
..
} => {
items.push(item);
(*source_port, *target_port)
}
_ => panic!(),
};
for id in [source_port, target_port] {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
} => incoming_items.push(item),
SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
} => outgoing_items.push(item),
_ => panic!(),
}
}
let scenario = p
.add_element(Kind::Scenario, Layer::Sa, "Telemetry scenario", None)
.unwrap();
let message = p
.add_element(Kind::Occurrence, Layer::Sa, "Send position", Some(scenario))
.unwrap();
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![MessageValue {
path: vec![root],
field: leaf,
expression: "current position".into(),
}],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(message);
add_trace(&mut p, oa, sa, "Record inheritance review");
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, oa);
decide(
&mut app,
facet(DataFacet::RecordSupertype),
SemanticDecision::TakeSource,
);
assert!(!state(&app).can_apply());
let impacts = &state(&app).plan.as_ref().unwrap().impacts;
assert_eq!(impacts.len(), 1);
assert_eq!(impacts[0].object, message);
let exact = impacts[0].clone();
send(&mut app, Event::AcceptImpact(0, true));
assert!(
state(&app).can_apply(),
"{:?}",
state(&app).plan.as_ref().map(|p| &p.conflicts)
);
assert_eq!(
state(&app).plan.as_ref().unwrap().request.accepted_impacts,
vec![exact]
);
decide(
&mut app,
facet(DataFacet::Unit),
SemanticDecision::KeepTarget,
);
assert!(
state(&app)
.plan
.as_ref()
.unwrap()
.request
.accepted_impacts
.is_empty()
);
assert!(!state(&app).can_apply());
send(&mut app, Event::AcceptImpact(0, true));
let expected = state(&app).proposed.clone().unwrap();
assert_eq!(app.document.bundle(), &before.bundle);
apply(&mut app);
assert_eq!(app.project(), &expected);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = app.project().elements[&message].shared()
else {
panic!()
};
assert!(values.is_empty());
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
}
@@ -1,592 +0,0 @@
use super::*;
use crate::{style, views::short};
use iced::{
Element, Fill,
widget::{
Space, button, checkbox, column, container, pick_list, row, scrollable, text, text_input,
},
};
fn event(event: Event) -> Message {
Message::Workflow(crate::workflow::Event::Semantic(event))
}
fn action<'a>(label: impl Into<String>, event_value: Event) -> iced::widget::Button<'a, Message> {
button(text(label.into()).size(12))
.padding([7, 9])
.style(style::nav)
.on_press(event(event_value))
}
pub(super) fn choice(project: &Project, id: Id) -> Choice {
let alias = project
.elements
.get(&id)
.map(|v| v.alias.as_str())
.or_else(|| project.relations.get(&id).map(|v| v.alias.as_str()))
.or_else(|| project.artifact_links.get(&id).map(|v| v.alias.as_str()));
Choice {
id,
label: alias.map_or_else(
|| format!("Deleted reference · {id}"),
|alias| format!("{} · @{alias}", project.named(id)),
),
}
}
fn words(value: &str) -> String {
let mut out = String::new();
for (index, c) in value.chars().enumerate() {
if c == '_' {
out.push(' ');
} else {
if index > 0 && c.is_uppercase() {
out.push(' ');
}
out.push(c);
}
}
out
}
pub(super) fn group(facet: &FacetSelector) -> &'static str {
match facet {
FacetSelector::Header(_) => "Name and ownership",
FacetSelector::Data(_) | FacetSelector::Field(_) | FacetSelector::Literal(_) => {
"Data and defaults"
}
FacetSelector::Port(_) | FacetSelector::Interface(_) | FacetSelector::ExchangeItem(_) => {
"Contracts and interfaces"
}
FacetSelector::Scenario(_)
| FacetSelector::Lifeline(_)
| FacetSelector::Occurrence(_)
| FacetSelector::Event(_)
| FacetSelector::State(_)
| FacetSelector::Annotation(_)
| FacetSelector::Binding(_) => "Behavior and scenarios",
FacetSelector::Relation(_) | FacetSelector::Constraint(_) => {
"Relationships and constraints"
}
FacetSelector::Member { .. } | FacetSelector::Order(_) => "Membership and order",
FacetSelector::Child(_)
| FacetSelector::Incident { .. }
| FacetSelector::Artifact { .. } => "Attached records",
}
}
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
let keys = |keys: &KeyPair<Id>| {
format!(
"{} ↔ {}",
keys.oa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into()),
keys.sa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into())
)
};
match facet {
FacetSelector::Member {
collection,
keys: pair,
} => format!("{} · {}", words(&format!("{collection:?}")), keys(pair)),
FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
FacetSelector::Literal(pair) => format!(
"Literal · {} ↔ {}",
pair.oa.as_deref().unwrap_or("Absent"),
pair.sa.as_deref().unwrap_or("Absent")
),
FacetSelector::Annotation(pair) => format!(
"Message value · {} ↔ {}",
pair.oa
.as_ref()
.map(|key| annotation_title(project, key))
.unwrap_or_else(|| "Absent".into()),
pair.sa
.as_ref()
.map(|key| annotation_title(project, key))
.unwrap_or_else(|| "Absent".into())
),
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)),
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
FacetSelector::Incident { keys: pair, end } => format!("Incident {end:?} · {}", keys(pair)),
FacetSelector::Artifact { keys: pair, facet } => {
format!("Artifact {} · {}", words(&format!("{facet:?}")), keys(pair))
}
_ => {
let json = serde_json::to_value(facet).expect("Typed selector");
let value = json
.as_object()
.expect("Family")
.values()
.next()
.expect("Leaf");
words(value.as_str().unwrap_or("Property"))
}
}
}
fn annotation_title(project: &Project, key: &AnnotationKey) -> String {
key.path
.iter()
.chain(std::iter::once(&key.field))
.map(|id| choice(project, *id).label)
.collect::<Vec<_>>()
.join(" / ")
}
fn drift(drift: Drift) -> &'static str {
match drift {
Drift::Unreviewed => "Unreviewed",
Drift::Unchanged => "Unchanged since review",
Drift::OaChanged => "Operational changed",
Drift::SaChanged => "System changed",
Drift::BothChanged => "Both changed",
}
}
pub(crate) fn summary(project: &Project, trace: &Trace) -> String {
let references = BTreeMap::from([(trace.oa, trace.sa)]);
match trace_semantic_drift(project, trace, &references) {
Ok(value) => format!(
"Semantics: {}{} · Linked: {}{}",
drift(value.own),
if value.own_unreviewed && value.own != Drift::Unreviewed {
" · incomplete review"
} else {
""
},
drift(value.linked),
if value.linked_unreviewed && value.linked != Drift::Unreviewed {
" · incomplete review"
} else {
""
}
),
Err(error) => format!("Semantic review unavailable: {error}"),
}
}
fn formatted(project: &Project, value: &Value, references: &BTreeSet<Id>) -> String {
match value {
Value::Null => "Not set".into(),
Value::Bool(value) => if *value { "Yes" } else { "No" }.into(),
Value::Number(value) => value.to_string(),
Value::String(value) => {
if let Ok(id) = value.parse::<Id>()
&& references.contains(&id)
{
choice(project, id).label
} else if value.is_empty() {
"Empty text".into()
} else {
format!("“{value}”")
}
}
Value::Array(values) if values.is_empty() => "Empty list (0 members)".into(),
Value::Array(values) => values
.iter()
.enumerate()
.map(|(i, v)| format!("{}. {}", i + 1, formatted(project, v, references)))
.collect::<Vec<_>>()
.join("\n"),
Value::Object(values) => values
.iter()
.map(|(key, value)| {
format!("{}: {}", words(key), formatted(project, value, references))
})
.collect::<Vec<_>>()
.join("\n"),
}
}
fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> String {
let Some(fact) = fact else {
return "Unreviewed — no previous observation".into();
};
let value = scalar::observation(fact, layer);
if value.as_str() == Some("NotApplicable") {
"Not applicable".into()
} else {
formatted(project, &value["Value"], &scalar::references(fact))
}
}
impl State {
pub fn view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let trace = self.trace.and_then(|id| project.traces.get(&id));
let mut traces: Vec<_> = project
.traces
.values()
.filter(|t| t.oa == self.anchor || t.sa == self.anchor)
.map(|t| Choice {
id: t.id,
label: format!(
"{} · {:?} · {} ↔ {} · @{}",
t.name,
t.kind,
project.named(t.oa),
project.named(t.sa),
t.alias
),
})
.collect();
traces.sort_by(|a, b| a.label.cmp(&b.label));
let selected_trace = traces.iter().find(|c| Some(c.id) == self.trace).cloned();
let direction = if self.direction == TransitionDirection::OaToSa {
"Use Operational changes in System"
} else {
"Use System changes in Operational"
};
let mut content = column![
text("Review semantics").size(22),
row![
pick_list(traces, selected_trace, |c| event(Event::Trace(c))).placeholder("Choose a trace / review history").text_size(12).padding(7).width(Fill),
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
].spacing(8),
text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED),
row![action("Properties", Event::Tab(Tab::Facts)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6),
].spacing(8);
let body = match self.tab {
Tab::Facts => self.facts_view(project),
Tab::References => self.references_view(project),
Tab::Impacts => self.impacts_view(project),
};
content = content.push(body);
if let Some(error) = &self.error {
content = content.push(
text(error)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
if let Some(plan) = &self.plan {
for error in &plan.conflicts {
content = content.push(
text(error)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
}
let unreviewed = self
.facts
.iter()
.filter(|f| f.drift == Drift::Unreviewed)
.count();
content = content.push(text(format!("{} selected decisions · {unreviewed} currently unreviewed facts · Constructors and structured collections require further review.", self.decisions.len())).size(10).color(style::MUTED));
if let Some(trace) = trace {
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
}
content
.push(
row![
text(
"Apply updates the selected history and model together; Undo restores both."
)
.size(10)
.width(Fill),
button(text("Cancel").size(12))
.padding(8)
.style(style::nav)
.on_press(Message::Workflow(crate::workflow::Event::Cancel)),
button(text("Apply reviewed changes").size(12))
.padding(8)
.on_press_maybe(
self.can_apply()
.then_some(Message::Workflow(crate::workflow::Event::Apply))
),
]
.spacing(8)
.align_y(iced::Center),
)
.into()
}
fn facts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut list = column![
checkbox(self.show_unchanged)
.label("Include unchanged facts")
.text_size(11)
.on_toggle(|v| event(Event::ShowUnchanged(v)))
]
.spacing(5);
let mut groups: BTreeMap<&str, Vec<&FacetDrift>> = BTreeMap::new();
for fact in &self.facts {
if self.show_unchanged
|| fact.drift != Drift::Unchanged
|| self.decisions.contains_key(&fact.selector)
|| self.selected.as_ref() == Some(&fact.selector)
{
groups.entry(group(&fact.selector)).or_default().push(fact);
}
}
for (group, facts) in groups {
list = list.push(text(group).size(12).color(style::TEAL));
for fact in facts {
let mark = match self.decisions.get(&fact.selector) {
Some(SemanticDecision::KeepTarget) => "Keep · ",
Some(SemanticDecision::TakeSource) => "Take · ",
Some(SemanticDecision::Candidate) => "Final value · ",
None => "",
};
list = list.push(
button(
column![
text(short(&title(project, &fact.selector), 52)).size(12),
text(format!("{mark}{}", drift(fact.drift))).size(10)
]
.spacing(3),
)
.padding(7)
.width(Fill)
.style(if self.selected.as_ref() == Some(&fact.selector) {
style::selected
} else {
style::nav
})
.on_press(event(Event::Select(fact.selector.clone()))),
);
}
}
row![
scrollable(list).height(305).width(260),
scrollable(self.detail(project)).height(305).width(Fill)
]
.spacing(12)
.into()
}
fn detail<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let Some(fact) = self
.selected
.as_ref()
.and_then(|s| self.facts.iter().find(|f| &f.selector == s))
else {
return text("Choose a trace and property to compare.")
.size(13)
.into();
};
let trace = self.trace.and_then(|id| project.traces.get(&id));
let previous = trace
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector));
let side = |layer, label: &str| {
column![
text(format!(
"{label}{}",
if layer == self.direction.target_layer() {
" · destination"
} else {
" · source"
}
))
.size(12)
.color(style::TEAL),
text("Previously reviewed").size(10).color(style::MUTED),
text(observed(project, previous, layer)).size(12),
text("Current").size(10).color(style::MUTED),
text(observed(project, Some(&fact.current), layer)).size(12),
]
.spacing(5)
.width(Fill)
};
let mut detail = column![
text(title(project, &fact.selector)).size(17),
text(drift(fact.drift)).size(11),
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
]
.spacing(10);
if let Some(proposed) = &self.proposed
&& let Some(trace) = trace
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
{
detail = detail
.push(
text("Proposed destination value · validated preview")
.size(11)
.color(style::TEAL),
)
.push(
text(observed(
proposed,
Some(&after),
self.direction.target_layer(),
))
.size(12),
);
} else {
detail = detail.push(
text("No validated final preview. Resolve the messages below before applying.")
.size(11)
.color(style::MUTED),
);
}
if self.editable(project, &fact.selector) {
let destination = if self.direction.target_layer() == Layer::Sa {
"System"
} else {
"Operational"
};
let source = if self.direction.source_layer() == Layer::Oa {
"Operational"
} else {
"System"
};
let mut buttons = row![
action(
format!("Keep {destination}"),
Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))
),
action(
format!("Take {source}"),
Event::Decide(fact.selector.clone(), Some(SemanticDecision::TakeSource))
)
]
.spacing(5);
buttons = buttons.push(action(
"Set final value…",
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
));
detail = detail.push(buttons).push(action(
"Leave unselected",
Event::Decide(fact.selector.clone(), None),
));
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
&& let Some(draft) = &self.draft
&& let Some(path) = self
.target(project)
.and_then(|id| scalar::binding(project, id, &fact.selector))
{
detail = detail.push(text("Final destination value").size(12)).push(
crate::forms::scoped(draft, project, &path, &title(project, &fact.selector))
.map(form_event),
);
}
} else if self.can_acknowledge_constructor(project, &fact.selector) {
detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12))
.push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
} else {
detail = detail.push(text(if scalar::grouped(&fact.selector) { "Constructor groups are shown for comparison. Group replacement and its complete descendant review are a later milestone." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
}
if matches!(
fact.selector,
FacetSelector::Child(_)
| FacetSelector::Incident { .. }
| FacetSelector::Artifact { .. }
) {
detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED));
}
detail.into()
}
fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut content = column![
text("Reference correspondence · always Operational → System").size(15),
text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires reset in the structured workflow.").size(12),
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12),
row![
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
pick_list(self.all_references(project, Layer::Sa), self.reference_sa.clone(), |c| event(Event::ReferenceSa(c))).placeholder("System reference").padding(7).text_size(12).width(Fill),
action("Use pair", Event::AddReference),
].spacing(7),
].spacing(9);
for (oa, sa) in &self.references {
content = content.push(
row![
text(format!(
"{}\n↔ {}",
choice(project, *oa).label,
choice(project, *sa).label
))
.size(12)
.width(Fill),
action("Remove pair", Event::RemoveReference(*oa))
]
.spacing(6),
);
}
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
content = content
.push(text("Referenced definitions · reviewed on their own traces").size(14));
match linked_semantic_impacts(project, trace, &self.references) {
Ok(impacts) => {
for impact in impacts {
content = content.push(
text(format!(
"{} ↔ {} · {}{} · {} trace(s)",
impact
.objects
.oa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into()),
impact
.objects
.sa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into()),
drift(impact.drift),
if impact.unreviewed {
" · incomplete review"
} else {
""
},
impact.traces.len()
))
.size(12),
);
}
}
Err(error) => content = content.push(text(error).size(12)),
}
}
scrollable(content).height(305).into()
}
fn impacts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut content = column![text("Additional prepared changes").size(16), text("Accept each exact change required by model preparation. Acceptance never overrides a selected Keep or Take. Editing a decision clears these approvals.").size(12)].spacing(10);
if let Some(plan) = &self.plan {
if plan.impacts.is_empty() {
content =
content.push(text("No additional prepared changes in this preview.").size(12));
}
for (index, impact) in plan.impacts.iter().enumerate() {
let before = impact
.before
.as_ref()
.map(|r| serde_json::to_value(r).expect("Typed impact"));
let after = impact
.after
.as_ref()
.map(|r| serde_json::to_value(r).expect("Typed impact"));
let ids = project.identities().into_iter().map(|(id, _)| id).collect();
content = content.push(
container(
column![
checkbox(self.accepted_impacts.contains(impact))
.label(format!("Accept · {}", project.named(impact.object)))
.text_size(12)
.on_toggle(move |accepted| event(Event::AcceptImpact(
index, accepted
))),
text("Before").size(11).color(style::MUTED),
text(
before
.as_ref()
.map(|v| formatted(project, v, &ids))
.unwrap_or_else(|| "Absent · record creation".into())
)
.size(12),
text("After").size(11).color(style::MUTED),
text(
after
.as_ref()
.map(|v| formatted(project, v, &ids))
.unwrap_or_else(|| "Absent · record deletion".into())
)
.size(12),
]
.spacing(6),
)
.padding(10)
.style(style::subtle),
);
}
}
scrollable(content).height(305).into()
}
}
+1 -4
View File
@@ -250,10 +250,7 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
assert_eq!(app.page, Page::Validation);
assert_eq!(app.project(), &original);
assert!(!app.dirty());
assert!(
app.source_snapshot
.starts_with(&format!("arcadia {SCHEMA_VERSION};"))
);
assert!(app.source_snapshot.starts_with("arcadia 3;"));
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
}
+85 -87
View File
@@ -55,14 +55,22 @@ pub fn view(app: &App) -> View<'_, Message> {
]
.spacing(10)
.width(220),
column![text(short(&p.name, 46)).size(17), label(app.hosted_label())]
.spacing(3)
.width(Fill),
column![
text(short(&p.name, 46)).size(17),
label(if app.dirty() {
"Unsaved changes"
} else if app.path.is_some() {
"All changes saved"
} else {
"Local project"
})
]
.spacing(3)
.width(Fill),
action("New", Message::Request(Pending::New)),
crate::hosted::view::header_action(),
action("Open", Message::Request(Pending::Open)),
action("Save as", Message::Save(true)),
button(text(app.save_label()).size(12))
button(text("Save all").size(12))
.padding([10, 16])
.on_press(Message::Save(false))
]
@@ -239,13 +247,6 @@ pub fn view(app: &App) -> View<'_, Message> {
.style(style::panel);
let root: View<'_, Message> = column![header, task_nav, body, footer].into();
if let Some(workflow) = &app.workflow {
if matches!(workflow, crate::workflow::Workflow::Semantic(_)) {
return modal_width(
root,
workflow.view(p),
(app.window_size.width - 50.).clamp(800., 1120.),
);
}
return modal(root, workflow.view(p));
}
if let Some(p) = &app.file_prompt {
@@ -298,27 +299,15 @@ pub fn view(app: &App) -> View<'_, Message> {
root
}
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
modal_width(base, content, 800.)
}
fn modal_width<'a>(
base: View<'a, Message>,
content: View<'a, Message>,
width: f32,
) -> View<'a, Message> {
widget::stack![
base,
widget::opaque(
container(
container(content)
.padding(26)
.width(width)
.style(style::card)
)
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
container(container(content).padding(26).width(800).style(style::card))
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
)
]
.into()
@@ -714,7 +703,69 @@ fn inspector(app: &App) -> View<'_, Message> {
}
fn data_catalog(app: &App) -> View<'_, Message> {
crate::data_view::view(app)
let p = app.project();
let mut content = column![heading(
"Data & interfaces",
"Define information, material and energy at the system boundary."
)]
.spacing(15);
for kind in Kind::ALL {
let is_data = matches!(
kind,
Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port
);
if !is_data {
continue;
}
let elements = p
.elements
.values()
.filter(|e| e.layer() == app.layer && e.kind() == kind)
.collect::<Vec<_>>();
if elements.is_empty() {
continue;
}
content = content.push(
text(format!("{} · {}", kind.label(), elements.len()))
.size(15)
.color(style::kind_color(kind)),
);
for e in elements {
content = content.push(crate::context_menu::Area::new(
button(
row![
column![
text(&e.name).size(14),
label(format!(
"@{}{}",
e.alias,
e.parent
.map(|id| format!(" · in {}", p.named(id)))
.unwrap_or_default()
)),
text(short(&e.description, 125))
.size(12)
.color(style::MUTED)
]
.spacing(5)
.width(Fill),
text("→").size(17)
]
.align_y(iced::Center),
)
.padding(14)
.width(Fill)
.style(if e.id == app.selected {
style::selected
} else {
style::nav
})
.on_press(Message::Select(e.id)),
move |anchor| crate::model_menu::open(e.id, anchor),
));
}
}
scrollable(content.padding(24)).into()
}
fn traces(app: &App) -> View<'_, Message> {
@@ -725,14 +776,10 @@ fn traces(app: &App) -> View<'_, Message> {
"Connect operational needs, system responsibilities and development evidence."
),
row![
button(text("Create counterpart / review prose…").size(12))
button(text("Create / update counterpart…").size(12))
.padding(9)
.on_press(Message::StartTransition),
action("+ Manual trace", Message::AddTrace),
action(
"Review semantics…",
Message::StartSemanticReview(app.selected)
),
action("+ Artifact", Message::AddArtifact)
]
.spacing(8)
@@ -755,14 +802,10 @@ fn traces(app: &App) -> View<'_, Message> {
.spacing(8)
.align_y(iced::Center),
row![
label(format!("{:?} · Prose: {current}", t.kind)),
label(format!("{:?} · {current}", t.kind)),
Space::new().width(Fill),
action("Review semantics…", Message::StartSemanticReview(t.id)),
action("Edit link", Message::Select(t.id))
action("Review link", Message::Select(t.id))
],
text(crate::semantic_review::summary(p, t))
.size(11)
.color(style::MUTED),
text(short(&t.description, 130)).size(12)
]
.spacing(10),
@@ -1056,51 +1099,6 @@ fn checks(app: &App) -> View<'_, Message> {
app.project().named(endpoint.exchange)
)));
}
card=card.push(label(format!("Data migration: {} semantic defaults · {} interface allocations · {} unresolved value addresses",report.semantic_defaults.len(),report.generated_interface_allocations.len(),report.unresolved_message_values.len())));
for default in report.semantic_defaults.iter().take(6) {
card = card
.push(action(
&format!(
"{} · {}",
app.project().named(default.object),
default.field
),
Message::Select(default.object),
))
.push(label(&default.description));
}
for allocation in report.generated_interface_allocations.iter().take(6) {
card = card
.push(action(
&format!(
"Created item allocation · @{} · order {}",
allocation.alias, allocation.order
),
Message::Select(allocation.id),
))
.push(label(format!(
"{} → {}",
app.project().named(allocation.source),
app.project().named(allocation.target)
)));
}
for value in report.unresolved_message_values.iter().take(6) {
card = card
.push(action(
&format!(
"Review qualified value · {}",
app.project().named(value.occurrence)
),
Message::EditProperties(value.occurrence),
))
.push(label(&value.reason));
}
if report.semantic_defaults.len() > 6
|| report.generated_interface_allocations.len() > 6
|| report.unresolved_message_values.len() > 6
{
card=card.push(label("Showing the first six entries in each data migration category. Model checks below link remaining unresolved contracts."));
}
for boundary in &report.orientation_defaults {
card = card.push(action(
&format!(
+1 -13
View File
@@ -15,7 +15,6 @@ use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub enum Event {
Semantic(crate::semantic_review::Event),
Port(crate::port_workflow::Event),
ExchangeItem(Id, bool),
Endpoint(bool, EndpointChoice),
@@ -34,7 +33,6 @@ pub enum Event {
Preview,
}
pub enum Workflow {
Semantic(Box<crate::semantic_review::State>),
Ports(Box<crate::port_workflow::State>),
Relation(Box<Relationship>),
ManualTrace {
@@ -335,11 +333,6 @@ impl Workflow {
/// choices and previews never mutate the Session.
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Project>, String> {
match self {
Self::Semantic(_) => {
return Err(
"Semantic review commits its exact command batch through Document.".into(),
);
}
Self::Ports(state) => return state.update(p, event),
Self::ManualTrace {
name,
@@ -578,7 +571,6 @@ impl Workflow {
}
pub fn title(&self) -> &'static str {
match self {
Self::Semantic(_) => "Review semantics",
Self::Ports(state) => state.title(),
Self::ManualTrace { .. } => "Link existing counterparts",
Self::Relation(_) => "Create relationship",
@@ -592,9 +584,6 @@ impl Workflow {
}
}
pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> {
if let Self::Semantic(state) = self {
return state.view(p);
}
if let Self::Ports(state) = self {
return state.view(p);
}
@@ -605,7 +594,7 @@ impl Workflow {
None
};
match self {
Self::Ports(_) | Self::Semantic(_) => unreachable!("Dedicated dialog returned above"),
Self::Ports(_) => unreachable!("Port dialog returned above"),
Self::ManualTrace {
name,
source,
@@ -1084,7 +1073,6 @@ impl Workflow {
}
}
let enabled = match self {
Self::Semantic(_) => false,
Self::Ports(_) => false,
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok),
+8 -180
View File
@@ -72,18 +72,6 @@ pub(crate) fn router() -> Router<AppState> {
"/api/v1/projects/{id}/transitions/apply",
post(commit_transition),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/query",
post(query_semantic_transitions),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/preview",
post(preview_semantic_transition),
)
.route(
"/api/v1/projects/{id}/semantic-transitions/apply",
post(commit_semantic_transition),
)
.fallback(|| async {
ApiError::new(
StatusCode::NOT_FOUND,
@@ -243,7 +231,7 @@ fn parse_source(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
}
fn import_json(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
// Format is chosen by HTTP. The shared decoder owns supported older-schema migration.
// Format is chosen by HTTP. The shared decoder owns explicit schema-1 and schema-2 migration.
let _: Value = json_body(body)?;
oa_core::persistence::decode_with_report_limited(body, MODEL_LIMITS).map_err(
|error| match error {
@@ -537,7 +525,7 @@ async fn put_source_project(
StatusCode::OK,
json!({
"revision": stored.revision, "changed": changed, "issues": diagnostics,
"source_project": stored.source_project, "migration": stored.migration
"source_project": stored.source_project
}),
))
})
@@ -959,16 +947,14 @@ async fn validate_candidate(
// 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();
let migration = if !diagnostics.iter().any(|issue| issue.severity == "error") {
PreparedProject::from_bundle(source_project)?.migration().cloned()
} else {
None
};
Ok((diagnostics, migration))
if !diagnostics.iter().any(|issue| issue.severity == "error") {
PreparedProject::from_bundle(source_project)?;
}
Ok(diagnostics)
});
let (diagnostics, migration) = result.unwrap_or_else(|error| (candidate_issues(error), None));
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":migration})));
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.")),
};
@@ -1101,164 +1087,6 @@ struct TransitionApplyRequest {
plan: oa_core::transitions::TransitionPlan,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SemanticQueryRequest {
traces: Vec<Id>,
#[serde(default)]
references: BTreeMap<Id, Id>,
revision: Option<u64>,
}
fn semantic_rejected(error: impl std::fmt::Display) -> ApiError {
ApiError::new(
StatusCode::UNPROCESSABLE_ENTITY,
"semantic_transition_rejected",
error.to_string(),
)
}
async fn query_semantic_transitions(
State(state): State<AppState>,
id: ProjectPath,
headers: HeaderMap,
body: Body,
) -> ApiResult<Response> {
let id = path(id)?;
media(&headers, "application/json")?;
let body = bytes(body)?;
blocking(state, move |store| {
let request: SemanticQueryRequest = json_body(&body)?;
if request.traces.is_empty() || request.traces.len() > 100 || request.references.len() > 5_000 {
return Err(ApiError::bad_request("Semantic queries require 1–100 trace IDs and at most 5,000 reference mappings."));
}
let selected: BTreeSet<_> = request.traces.iter().copied().collect();
if selected.len() != request.traces.len() {
return Err(ApiError::bad_request("Semantic query trace IDs must be unique."));
}
let current = store.get(id)?;
check_read_revision(&current, request.revision)?;
let mut items = Vec::new();
for trace_id in request.traces {
let trace = current.model.traces.get(&trace_id).ok_or_else(|| {
ApiError::new(StatusCode::NOT_FOUND, "trace_not_found", "Selected trace does not exist.")
})?;
let facets = oa_core::semantic::semantic_facets(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let drift = oa_core::semantic::trace_semantic_drift(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
let linked_impacts = oa_core::semantic::linked_semantic_impacts(&current.model, trace, &request.references)
.map_err(semantic_rejected)?;
items.push(json!({"trace":trace.id,"oa":trace.oa,"sa":trace.sa,"facets":facets,"drift":drift,"linked_impacts":linked_impacts}));
}
Ok(response(&current, StatusCode::OK, json!({"revision":current.revision,"items":items})))
}).await
}
fn check_semantic_request(request: &oa_core::semantic::SemanticTransitionRequest) -> ApiResult<()> {
let transition = &request.transition;
let decisions = request.reviews.iter().try_fold(0usize, |count, review| {
count
.checked_add(review.resolutions.len())?
.checked_add(review.reset.len())
});
if transition.sources.len() > 100
|| transition.mappings.len() > 200
|| transition.resolutions.len() > 400
|| request.traces.len() > 100
|| request.references.len() > 5_000
|| request.reviews.len() > 100
|| decisions.is_none_or(|count| count > 5_000)
|| request.candidates.len() > 200
|| request.accepted_impacts.len() > 200
{
return Err(ApiError::bad_request(
"Semantic transitions allow at most 100 sources/traces/reviews, 200 mappings/candidates/accepted impacts, 400 prose resolutions and 5,000 reference mappings or combined facet resolutions/resets.",
));
}
Ok(())
}
async fn preview_semantic_transition(
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| {
let request: oa_core::semantic::SemanticTransitionRequest = json_body(&body)?;
check_semantic_request(&request)?;
let current = store.checked(id, &expected)?;
let plan = oa_core::semantic::plan_semantic_transition(&current.model, request)
.map_err(semantic_rejected)?;
if !plan.commands.is_empty() {
check_commands(&plan.commands)?;
}
if plan.conflicts.is_empty() {
let candidate = oa_core::semantic::prepare_semantic_transition(&current.model, &plan)
.map_err(semantic_rejected)?;
PreparedProject::from_command(&current, candidate)?;
}
Ok(response(
&current,
StatusCode::OK,
json!({"base_revision":current.revision,"plan":plan}),
))
})
.await
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SemanticApplyRequest {
base_revision: u64,
plan: oa_core::semantic::SemanticTransitionPlan,
}
async fn commit_semantic_transition(
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| {
let request: SemanticApplyRequest = json_body(&body)?;
let current = store.checked(id, &expected)?;
if current.revision != request.base_revision {
let mut error = ApiError::new(
StatusCode::PRECONDITION_FAILED,
"plan_revision_conflict",
"Semantic preview revision changed. Create a new preview.",
);
error.current_revision = Some(current.revision);
return Err(error);
}
check_semantic_request(&request.plan.request)?;
if !request.plan.commands.is_empty() {
check_commands(&request.plan.commands)?;
}
let candidate =
oa_core::semantic::prepare_semantic_transition(&current.model, &request.plan)
.map_err(semantic_rejected)?;
let prepared = PreparedProject::from_command(&current, candidate)?;
let (stored, changed) = store.replace(id, &expected, prepared)?;
Ok(response(
&stored,
StatusCode::OK,
json!({"revision":stored.revision,"changed":changed,"issues":issues(&stored.model)}),
))
})
.await
}
async fn commit_transition(
State(state): State<AppState>,
id: ProjectPath,
+3 -13
View File
@@ -128,10 +128,9 @@ impl PreparedProject {
fn from_compiled(compiled: CompiledSourceProject) -> ApiResult<Self> {
check_model_size(&compiled.decoded.project)?;
check_source_size(&compiled.bundle)?;
let migration = compiled.decoded.migration.clone();
Ok(Self {
compiled,
migration,
migration: None,
})
}
@@ -174,10 +173,6 @@ impl PreparedProject {
&self.compiled.bundle
}
pub(crate) fn migration(&self) -> Option<&MigrationReport> {
self.migration.as_ref()
}
pub(crate) fn source_issues(&self) -> Vec<crate::error::IssueDto> {
self.compiled
.diagnostics
@@ -334,12 +329,7 @@ impl Store {
let prepared = if let Some(bundle) = envelope.source_project {
let prepared = PreparedProject::from_bundle(bundle.clone())
.map_err(|error| corrupt(error.detail))?;
// Explicit source upgrades may change tokens and generate new
// records. They must still agree with independently migrated
// JSON. Current-format unstabilized/inconsistent pairs reject.
if (prepared.bundle() != &bundle && prepared.migration.is_none())
|| prepared.model() != &decoded.project
{
if prepared.bundle() != &bundle || prepared.model() != &decoded.project {
return Err(corrupt(
"Stored source and model disagree, or source lacks stable identities."
.into(),
@@ -357,7 +347,7 @@ impl Store {
revision: envelope.revision,
model: decoded.project,
source_project: prepared.compiled.bundle,
migration: decoded.migration.or(prepared.migration),
migration: decoded.migration,
};
if stored.resource_id != filename_id || stored.revision == 0 {
return Err(corrupt(
+6 -10
View File
@@ -8,12 +8,8 @@ use serde_json::{Value, json};
use std::{path::Path, sync::Arc};
use tokio::{sync::oneshot, task::JoinHandle};
#[path = "http/data_contracts.rs"]
mod data_contracts;
#[path = "http/port_anchors.rs"]
mod port_anchors;
#[path = "http/semantic_transitions.rs"]
mod semantic_transitions;
#[path = "http/source_projects.rs"]
mod source_projects;
@@ -1933,7 +1929,7 @@ async fn legacy_json_import_and_model_reidentification_keep_the_service_address(
}
#[tokio::test]
async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_state() {
async fn schema3_migration_reports_and_invalid_typed_transaction_preserve_http_state() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let old = v2_missing_ports();
@@ -1949,7 +1945,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
assert_eq!(imported.status(), StatusCode::CREATED);
let etag = imported.headers()["etag"].to_str().unwrap().to_owned();
let summary: Value = imported.json().await.unwrap();
assert_eq!(summary["schema_version"], oa_core::SCHEMA_VERSION);
assert_eq!(summary["schema_version"], 3);
assert_eq!(
summary["migration"],
serde_json::to_value(&expected.migration).unwrap()
@@ -1962,7 +1958,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
let before = std::fs::read(&filename).unwrap();
let envelope: Value = serde_json::from_slice(&before).unwrap();
assert!(envelope.get("migration").is_none());
assert_eq!(envelope["model"]["schema_version"], oa_core::SCHEMA_VERSION);
assert_eq!(envelope["model"]["schema_version"], 3);
let validation = server
.client
.post(server.url(&format!("/projects/{id}/validation")))
@@ -2004,7 +2000,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 5;"));
assert!(source.starts_with("arcadia 3;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
let no_op = server
.client
@@ -2053,7 +2049,7 @@ async fn reopening_v2_envelope_reports_migration_without_saving_or_advancing_rev
let migrated = server.get(id).await;
assert_eq!(migrated.resource_id, id);
assert_eq!(migrated.revision, 7);
assert_eq!(migrated.model.schema_version, oa_core::SCHEMA_VERSION);
assert_eq!(migrated.model.schema_version, 3);
assert_eq!(
migrated
.migration
@@ -2078,7 +2074,7 @@ async fn reopening_v2_envelope_reports_migration_without_saving_or_advancing_rev
assert_eq!(current.model.name, "Reviewed migrated model");
let saved: Value = serde_json::from_slice(&std::fs::read(&filename).unwrap()).unwrap();
assert_eq!(saved["revision"], 8);
assert_eq!(saved["model"]["schema_version"], oa_core::SCHEMA_VERSION);
assert_eq!(saved["model"]["schema_version"], 3);
assert!(saved.get("migration").is_none());
server.stop().await;
}
@@ -1,734 +0,0 @@
//! Transport evidence for the published R6 contract, not a second model fixture.
use super::*;
use oa_core::source_project::{ProjectLimits, SourceBundle, compile_bundle};
use oa_core::{DataTypeDefinition, DataValue, SharedBody, TransmissionProtocol};
use std::collections::BTreeMap;
fn documented_data() -> Project {
let contract = include_str!("../../../../docs/language.rst");
let example = contract
.split("This complete data contract example is also exercised through the REST API:")
.nth(1)
.unwrap()
.split(".. code-block:: text")
.nth(1)
.unwrap();
let source = example
.lines()
.skip_while(|line| line.trim().is_empty())
.take_while(|line| line.starts_with(" ") || line.is_empty())
.map(|line| line.strip_prefix(" ").unwrap_or(line))
.collect::<Vec<_>>()
.join("\n");
let model = oa_core::language::parse(&source).unwrap();
oa_core::validate_candidate(&model).unwrap();
model
}
fn id(model: &Project, alias: &str) -> Id {
model
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn assert_pair(stored: &StoredProject) {
let compiled = compile_bundle(&stored.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, stored.model);
assert_eq!(compiled.bundle, stored.source_project);
}
async fn commands(
server: &TestServer,
current: &StoredProject,
commands: Vec<Command>,
) -> reqwest::Response {
server
.client
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
.header("if-match", current.etag())
.json(&json!({"commands":commands}))
.send()
.await
.unwrap()
}
#[tokio::test]
async fn published_data_contract_queries_atomic_edits_identity_and_restart() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let model = documented_data();
let (resource, _) = server.import(&model).await;
let before = server.get(resource).await;
assert_eq!(before.model, model);
assert_pair(&before);
for (kind, count) in [("InterfaceItemAllocation", 2), ("DataAssociation", 1)] {
let result: Value = server
.client
.get(server.url(&format!("/projects/{resource}/relations?kind={kind}")))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap();
assert_eq!(result["items"].as_array().unwrap().len(), count);
}
let voltage = id(&model, "voltage");
let mut invalid = model.elements[&voltage].clone();
let Some(SharedBody::DataType {
definition: DataTypeDefinition::Real { maximum, .. },
..
}) = invalid.shared_mut()
else {
panic!()
};
*maximum = Some(12.0); // Existing default24V is outside this bound.
let filename = directory.path().join(format!("{resource}.json"));
let disk_before = std::fs::read(&filename).unwrap();
assert_eq!(
commands(
&server,
&before,
vec![Command::EditElement(voltage, invalid.clone())]
)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(server.get(resource).await.etag(), before.etag());
assert_eq!(std::fs::read(&filename).unwrap(), disk_before);
let Some(SharedBody::DataType { default, .. }) = invalid.shared_mut() else {
panic!()
};
*default = Some(DataValue::Real(12.0));
let nav = id(&model, "nav_operation");
let other = id(&model, "monitor_operation");
let mut allocation = model.relations[&nav].clone();
let oa_core::RelationBody::InterfaceItemAllocation { transmission, .. } = &mut allocation.body
else {
panic!()
};
*transmission = TransmissionProtocol::Unicast;
let artifact = oa_core::ArtifactLink {
id: Id::new_v4(),
alias: "navigation_evidence".into(),
name: "Navigation CAD evidence".into(),
description: "Independent allocation evidence".into(),
owner: nav,
relation: oa_core::ArtifactRelation::Verifies,
uri: "cad://navigation/connector".into(),
revision: Some("review-17".into()),
fragment: Some("pin-3".into()),
};
let response = commands(
&server,
&before,
vec![
Command::EditElement(voltage, invalid),
Command::EditRelation(nav, allocation.clone()),
Command::CreateArtifact(artifact.clone()),
],
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let changed = server.get(resource).await;
assert_eq!(changed.revision, before.revision + 1);
assert_eq!(changed.model.relations[&other], model.relations[&other]);
assert_eq!(changed.model.relations[&nav], allocation);
assert_eq!(changed.model.artifact_links[&artifact.id], artifact);
assert_pair(&changed);
assert_eq!(
commands(&server, &before, vec![Command::Rename(nav, "Stale".into())])
.await
.status(),
StatusCode::PRECONDITION_FAILED
);
let renamed = Id::new_v4();
assert_eq!(
commands(&server, &changed, vec![Command::Reidentify(nav, renamed)])
.await
.status(),
StatusCode::OK
);
let reidentified = server.get(resource).await;
assert_eq!(
reidentified.model.artifact_links[&artifact.id].owner,
renamed
);
// This allocation is not enforcing a live boundary, so removal is valid.
assert_eq!(
commands(&server, &reidentified, vec![Command::Delete(renamed)])
.await
.status(),
StatusCode::OK
);
let removed = server.get(resource).await;
assert!(removed.model.elements.contains_key(&id(&model, "navigate")));
assert_eq!(removed.model.relations[&other], model.relations[&other]);
assert!(!removed.model.artifact_links.contains_key(&artifact.id));
assert_pair(&removed);
let source = server
.client
.get(server.url(&format!("/projects/{resource}/source")))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 5;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), removed.model);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(resource).await;
assert_eq!(reopened.model, removed.model);
assert_eq!(reopened.etag(), removed.etag());
assert_pair(&reopened);
server.stop().await;
}
#[tokio::test]
async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_files() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let old = SourceBundle {
entry: "legacy.arcadia".into(), files: BTreeMap::from([
("legacy.arcadia".into(), "// Entry rationale\narcadia 3; project old_data \"Old data\" { include \"dictionary.arcadia\"; }\n".into()),
("dictionary.arcadia".into(), "// Event rationale\nsa_exchange_item notification \"Notification\" { kind Event; }\n// Interface rationale\nsa_interface contract \"Contract\" { items [@notification]; }\n".into())
])
};
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(&old)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let summary: Value = response.json().await.unwrap();
let resource = summary["resource_id"].as_str().unwrap().parse().unwrap();
let stored = server.get(resource).await;
let report = stored.migration.as_ref().unwrap();
assert_eq!((report.from_schema, report.to_schema), (3, 5));
assert_eq!(report.generated_interface_allocations.len(), 1);
assert!(!report.semantic_defaults.is_empty());
let validated: Value = server
.client
.post(server.url(&format!("/projects/{resource}/validation")))
.json(&json!({"format":"source-project","source_project":old}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(validated["valid"], true);
assert_eq!(validated["migration"]["from_schema"], 3);
assert_eq!(
validated["migration"]["generated_interface_allocations"]
.as_array()
.unwrap()
.len(),
1
);
assert_eq!(stored.source_project.files.len(), 2);
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 5;"));
for text in [
"// Event rationale",
"// Interface rationale",
"interface_item_allocation",
] {
assert!(stored.source_project.files["dictionary.arcadia"].contains(text));
}
assert_pair(&stored);
let filename = directory.path().join(format!("{resource}.json"));
let bytes = std::fs::read(&filename).unwrap();
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(resource).await;
assert_eq!(reopened.model, stored.model);
assert_eq!(reopened.source_project, stored.source_project);
assert!(reopened.migration.is_none());
assert_eq!(std::fs::read(filename).unwrap(), bytes);
server.stop().await;
}
#[tokio::test]
async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_save() {
let directory = tempfile::tempdir().unwrap();
let resource = Id::new_v4();
let filename = directory.path().join(format!("{resource}.json"));
let old_model: Value = serde_json::from_str(include_str!(
"../../../oa-core/tests/fixtures/v3-data-contract.json"
))
.unwrap();
let old_bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([(
"project.arcadia".into(),
include_str!("../../../oa-core/tests/fixtures/v3-data-contract.arcadia").into(),
)]),
};
let original = serde_json::to_vec(&json!({"resource_id":resource,"revision":17,"model":old_model,"source_project":old_bundle})).unwrap();
std::fs::write(&filename, &original).unwrap();
let server = TestServer::start(directory.path(), None).await;
let opened = server.get(resource).await;
assert_eq!(opened.revision, 17);
assert_eq!(opened.model.schema_version, 5);
assert_eq!(opened.migration.as_ref().unwrap().from_schema, 3);
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 5;"));
assert_pair(&opened);
assert_eq!(std::fs::read(&filename).unwrap(), original);
assert_eq!(
commands(
&server,
&opened,
vec![Command::Rename(
opened.model.id,
"Reviewed data migration".into()
)]
)
.await
.status(),
StatusCode::OK
);
let saved = server.get(resource).await;
assert_eq!(saved.revision, 18);
let bytes = std::fs::read(&filename).unwrap();
assert_ne!(bytes, original);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(resource).await;
assert_eq!(reopened.model, saved.model);
assert_eq!(reopened.source_project, saved.source_project);
assert_eq!(reopened.etag(), saved.etag());
assert!(reopened.migration.is_none());
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
server.stop().await;
}
#[tokio::test]
async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_source_and_restart()
{
use oa_core::{
Element, FieldStorage, MessageKind, MessageValue, OccurrenceBody, ParameterDirection,
PortContract, RelationBody, ScenarioViewpoint, TypeRef,
};
fn values(model: &Project, occurrence: Id) -> &[MessageValue] {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = model.elements[&occurrence].shared()
else {
panic!("Expected message");
};
values
}
fn edited_values(model: &Project, occurrence: Id, next: Vec<MessageValue>) -> Element {
let mut element = model.elements[&occurrence].clone();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = element.shared_mut()
else {
panic!("Expected message");
};
*values = next;
element
}
let mut model = documented_data();
let operation = id(&model, "navigate");
let coordinate = id(&model, "coordinate");
let located = id(&model, "located");
let latitude = id(&model, "latitude");
let origin = id(&model, "origin");
let destination = id(&model, "destination");
let accepted = id(&model, "accepted");
// The second parameter inherits the very same latitude identity.
let Some(SharedBody::DataField { type_ref, .. }) =
model.elements.get_mut(&destination).unwrap().shared_mut()
else {
panic!();
};
*type_ref = TypeRef::Named(located);
let next = model
.add_element(
Kind::DataField,
Layer::Sa,
"Next coordinate",
Some(coordinate),
)
.unwrap();
*model.elements.get_mut(&next).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref: TypeRef::Named(coordinate),
minimum: 0,
maximum: Some(1),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
unit: String::new(),
default: None,
};
let requester = model
.add_element(Kind::Function, Layer::Sa, "Request navigation", None)
.unwrap();
let provider = model
.add_element(Kind::Function, Layer::Sa, "Provide navigation", None)
.unwrap();
let output = Id::new_v4();
let input = Id::new_v4();
for (port, contract) in [
(
output,
PortContract::FunctionOutput {
outgoing_items: vec![operation],
},
),
(
input,
PortContract::FunctionInput {
incoming_items: vec![operation],
},
),
] {
let (owner, name) = if port == output {
(requester, "Navigation output")
} else {
(provider, "Navigation input")
};
let element = oa_core::ports::make_port(&model, port, owner, name, contract).unwrap();
model.elements.insert(port, element);
}
let exchange = model.relate(
RelationKind::FunctionalExchange,
"Navigation request",
requester,
provider,
);
model.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
source: requester,
target: provider,
source_port: output,
target_port: input,
items: vec![operation],
};
let scenario = model
.add_element(Kind::Scenario, Layer::Sa, "Navigation conversation", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
model.elements.get_mut(&scenario).unwrap().shared_mut()
else {
panic!();
};
*viewpoint = ScenarioViewpoint::Functional;
let sender = model
.add_element(Kind::Lifeline, Layer::Sa, "Requester line", Some(scenario))
.unwrap();
let receiver = model
.add_element(Kind::Lifeline, Layer::Sa, "Provider line", Some(scenario))
.unwrap();
for (line, represented) in [(sender, requester), (receiver, provider)] {
*model.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let call = model
.add_element(
Kind::Occurrence,
Layer::Sa,
"Navigation call",
Some(scenario),
)
.unwrap();
let reply = model
.add_element(
Kind::Occurrence,
Layer::Sa,
"Navigation reply",
Some(scenario),
)
.unwrap();
for (occurrence, message_kind, from, to, reply_to) in [
(call, MessageKind::Call, sender, receiver, None),
(reply, MessageKind::Reply, receiver, sender, Some(call)),
] {
*model
.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: Some(from),
receiver: Some(to),
message_kind,
reply_to,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
}
model
.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.extend([call, reply]);
oa_core::validate_candidate(&model).unwrap();
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (resource, _) = server.import(&model).await;
let before = server.get(resource).await;
let call_values = vec![
MessageValue {
path: vec![origin],
field: latitude,
expression: " origin.latitude\n+ 0.25 ".into(),
},
MessageValue {
path: vec![destination],
field: latitude,
expression: "destination.latitude".into(),
},
MessageValue {
path: vec![origin, next],
field: latitude,
expression: "previous.latitude".into(),
},
];
let reply_values = vec![MessageValue {
path: vec![],
field: accepted,
expression: "true".into(),
}];
let response = commands(
&server,
&before,
vec![
Command::EditElement(call, edited_values(&model, call, call_values.clone())),
Command::EditElement(reply, edited_values(&model, reply, reply_values.clone())),
],
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let authored = server.get(resource).await;
assert_eq!(authored.revision, before.revision + 1);
assert_eq!(values(&authored.model, call), call_values);
assert_eq!(values(&authored.model, reply), reply_values);
assert_eq!(
authored.model.elements[&latitude],
model.elements[&latitude]
);
assert_pair(&authored);
let filename = directory.path().join(format!("{resource}.json"));
let bytes = std::fs::read(&filename).unwrap();
for (occurrence, value, code) in [
(
call,
MessageValue {
path: vec![],
field: accepted,
expression: "wrong call phase".into(),
},
"message_parameter_phase",
),
(
reply,
MessageValue {
path: vec![origin],
field: latitude,
expression: "wrong reply phase".into(),
},
"message_parameter_phase",
),
(
call,
MessageValue {
path: vec![origin, accepted],
field: latitude,
expression: "unrelated hop".into(),
},
"message_value_field",
),
] {
let invalid = edited_values(&authored.model, occurrence, vec![value]);
let mut invalid_model = authored.model.clone();
invalid_model.elements.insert(occurrence, invalid.clone());
let response = commands(
&server,
&authored,
vec![Command::EditElement(occurrence, invalid)],
)
.await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let problem: Value = response.json().await.unwrap();
assert_eq!(problem["code"], "command_rejected", "{problem}");
assert!(
problem["detail"].as_str().unwrap().starts_with(code),
"{problem}"
);
// Transactions report the first rejection; validation supplies located issues.
let validation: Value = server
.client
.post(server.url(&format!("/projects/{resource}/validation")))
.json(&json!({"format":"json", "model":invalid_model}))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap();
assert_eq!(validation["valid"], false);
assert!(
validation["issues"]
.as_array()
.unwrap()
.iter()
.any(|issue| issue["code"] == code
&& issue["object_id"] == json!(occurrence)
&& issue["field"] == "values.path"),
"{problem}"
);
let unchanged = server.get(resource).await;
assert_eq!(unchanged.etag(), authored.etag());
assert_eq!(unchanged.model, authored.model);
assert_eq!(unchanged.source_project, authored.source_project);
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
}
// Source transport changes one argument without conflating the shared leaf.
let source = server
.client
.get(server.url(&format!("/projects/{resource}/source")))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.text()
.await
.unwrap();
let edited_source = source.replace(
"\"destination.latitude\"",
"\"destination.corrected_latitude\"",
);
assert_ne!(edited_source, source);
let response = server
.client
.put(server.url(&format!("/projects/{resource}/source")))
.header("if-match", authored.etag())
.header("content-type", "text/plain")
.body(edited_source)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let source_edited = server.get(resource).await;
assert_eq!(source_edited.revision, authored.revision + 1);
let mut expected_call = call_values.clone();
expected_call[1].expression = "destination.corrected_latitude".into();
assert_eq!(values(&source_edited.model, call), expected_call);
assert_eq!(values(&source_edited.model, reply), reply_values);
assert_pair(&source_edited);
// Reidentify every kind of enclosing hop, the shared terminal, and its base type.
let mapping = [origin, destination, next, latitude, coordinate]
.into_iter()
.map(|old| (old, Id::new_v4()))
.collect::<BTreeMap<_, _>>();
let response = commands(
&server,
&source_edited,
mapping
.iter()
.map(|(old, new)| Command::Reidentify(*old, *new))
.collect(),
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let changed = server.get(resource).await;
assert_eq!(changed.revision, source_edited.revision + 1);
for value in &mut expected_call {
value.path = value.path.iter().map(|hop| mapping[hop]).collect();
value.field = mapping[&value.field];
}
assert_eq!(values(&changed.model, call), expected_call);
assert_eq!(values(&changed.model, reply), reply_values);
assert_eq!(
changed.model.elements[&mapping[&latitude]].parent,
Some(mapping[&coordinate])
);
assert!(
matches!(changed.model.elements[&located].shared(), Some(SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: Some(base) }, ..
}) if *base == mapping[&coordinate])
);
assert!(
oa_core::data::effective_fields(&changed.model, located)
.iter()
.any(|field| field.id == mapping[&latitude])
);
for old in mapping.keys() {
assert!(!changed.model.contains(*old));
}
assert!(
matches!(changed.model.elements[&reply].shared(), Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { reply_to: Some(correlation), .. }, ..
}) if *correlation == call)
);
assert_pair(&changed);
let source = server
.client
.get(server.url(&format!("/projects/{resource}/source")))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 5;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model);
let saved = std::fs::read(&filename).unwrap();
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(resource).await;
assert_eq!(reopened.etag(), changed.etag());
assert_eq!(reopened.model, changed.model);
assert_eq!(reopened.source_project, changed.source_project);
assert_eq!(values(&reopened.model, call), expected_call);
assert_eq!(values(&reopened.model, reply), reply_values);
assert_pair(&reopened);
assert_eq!(std::fs::read(&filename).unwrap(), saved);
server.stop().await;
}
@@ -1,675 +0,0 @@
use super::*;
use oa_core::{semantic::*, transitions::*, *};
fn fixture() -> Project {
oa_core::persistence::decode(include_bytes!(
"../../../oa-core/tests/fixtures/v4-reviewed-prose.json"
))
.unwrap()
}
fn key(n: u128) -> Id {
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
}
fn request(
source: Id,
target: Id,
decisions: Vec<(FacetSelector, SemanticDecision)>,
) -> SemanticTransitionRequest {
let direction = if source == key(3) {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
};
let mut transition = TransitionRequest::new(direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.reviews.push(SemanticReviewRequest {
source,
resolutions: decisions
.into_iter()
.map(|(facet, decision)| FacetResolution { facet, decision })
.collect(),
reset: vec![],
});
request
}
async fn post(
server: &TestServer,
id: Id,
operation: &str,
etag: Option<&str>,
value: &impl serde::Serialize,
) -> reqwest::Response {
let mut call = server
.client
.post(server.url(&format!("/projects/{id}/semantic-transitions/{operation}")))
.json(value);
if let Some(etag) = etag {
call = call.header("if-match", etag);
}
call.send().await.unwrap()
}
async fn ok(response: reqwest::Response) -> Value {
let status = response.status();
let value = response.json().await.unwrap();
assert_eq!(status, StatusCode::OK, "{value}");
value
}
async fn preview(
server: &TestServer,
stored: &StoredProject,
request: &SemanticTransitionRequest,
) -> Value {
ok(post(
server,
stored.resource_id,
"preview",
Some(&stored.etag()),
request,
)
.await)
.await
}
fn assert_pair(stored: &StoredProject) {
assert_eq!(
oa_core::source_project::compile_bundle(&stored.source_project, Default::default())
.unwrap()
.decoded
.project,
stored.model
);
}
#[tokio::test]
async fn semantic_partial_review_query_source_and_restart_share_one_revision() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (id, _) = server.import(&fixture()).await;
let mut stored = server.get(id).await;
let mut bundle = stored.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Authored review rationale\n");
let changed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", stored.etag())
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(changed.status(), StatusCode::OK);
stored = server.get(id).await;
let disk_path = directory.path().join(format!("{id}.json"));
let before_bytes = std::fs::read(&disk_path).unwrap();
let query = json!({"traces":[key(4)],"references":{},"revision":stored.revision});
let queried = ok(post(&server, id, "query", None, &query).await).await;
assert_eq!(queried["items"][0]["drift"]["own"], "Unreviewed");
let req = request(
key(2),
key(3),
vec![
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::KeepTarget,
),
(
FacetSelector::Header(HeaderFacet::Name),
SemanticDecision::TakeSource,
),
],
);
let plan = preview(&server, &stored, &req).await;
assert_eq!(plan["plan"]["conflicts"], json!([]));
assert_eq!(
plan["plan"]["reviews"][0]["acknowledged"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(std::fs::read(&disk_path).unwrap(), before_bytes);
assert_eq!(server.get(id).await.etag(), stored.etag());
let applied = ok(post(&server, id, "apply", Some(&stored.etag()), &plan).await).await;
assert_eq!(applied["revision"], stored.revision + 1);
let committed = server.get(id).await;
assert_pair(&committed);
assert!(
committed.source_project.files[&committed.source_project.entry]
.contains("// Authored review rationale")
);
assert_eq!(committed.model.elements[&key(3)].name, "OA dimension now");
assert!(
matches!(committed.model.elements[&key(3)].shared(), Some(SharedBody::DataType { unit, definition: DataTypeDefinition::Real { .. }, .. }) if unit == "ft")
);
let queried = ok(post(&server, id, "query", None, &json!({"traces":[key(4)]})).await).await;
let facts: Vec<FacetDrift> =
serde_json::from_value(queried["items"][0]["facets"].clone()).unwrap();
assert_eq!(
facts
.iter()
.find(|f| f.selector == FacetSelector::Data(DataFacet::Unit))
.unwrap()
.drift,
Drift::Unchanged
);
assert_eq!(
facts
.iter()
.find(|f| f.selector == FacetSelector::Data(DataFacet::Constructor))
.unwrap()
.drift,
Drift::Unreviewed
);
assert!(
queried["items"][0]["drift"]["own_unreviewed"]
.as_bool()
.unwrap()
);
let repeat = preview(&server, &committed, &req).await;
let reapplied = ok(post(&server, id, "apply", Some(&committed.etag()), &repeat).await).await;
assert_eq!(reapplied["changed"], false);
assert_eq!(reapplied["revision"], committed.revision);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(id).await;
assert_eq!(reopened.model, committed.model);
assert_eq!(reopened.source_project, committed.source_project);
assert_eq!(reopened.etag(), committed.etag());
server.stop().await;
}
#[tokio::test]
async fn semantic_strict_rejection_stale_plans_and_source_only_changes_never_write() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (id, etag) = server.import(&fixture()).await;
let stored = server.get(id).await;
let disk_path = directory.path().join(format!("{id}.json"));
let before = std::fs::read(&disk_path).unwrap();
let req = request(
key(2),
key(3),
vec![(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::TakeSource,
)],
);
let plan = preview(&server, &stored, &req).await;
for operation in ["preview", "apply"] {
let body = if operation == "preview" {
serde_json::to_value(&req).unwrap()
} else {
plan.clone()
};
assert_eq!(
post(&server, id, operation, None, &body).await.status(),
StatusCode::PRECONDITION_REQUIRED
);
}
let mut malformed = serde_json::to_value(&req).unwrap();
malformed["reviews"][0]["resolutions"][0]["facet"] = json!({"Data":"arbitrary_path"});
assert_eq!(
post(&server, id, "preview", Some(&etag), &malformed)
.await
.status(),
StatusCode::BAD_REQUEST
);
malformed = serde_json::to_value(&req).unwrap();
malformed["unexpected"] = true.into();
assert_eq!(
post(&server, id, "preview", Some(&etag), &malformed)
.await
.status(),
StatusCode::BAD_REQUEST
);
for (query, expected) in [
(json!({"traces":[]}), StatusCode::BAD_REQUEST),
(json!({"traces":[key(4),key(4)]}), StatusCode::BAD_REQUEST),
(json!({"traces":[key(99)]}), StatusCode::NOT_FOUND),
(
json!({"traces":[key(4)],"revision":99}),
StatusCode::CONFLICT,
),
] {
assert_eq!(
post(&server, id, "query", None, &query).await.status(),
expected
);
}
for mutation in [
"commands",
"base_fingerprint",
"prepared_fingerprint",
"base_revision",
] {
let mut rejected = plan.clone();
let expected = if mutation == "base_revision" {
rejected[mutation] = 99.into();
StatusCode::PRECONDITION_FAILED
} else {
rejected["plan"][mutation] = if mutation == "commands" {
json!([])
} else {
json!("altered")
};
StatusCode::UNPROCESSABLE_ENTITY
};
assert_eq!(
post(&server, id, "apply", Some(&etag), &rejected)
.await
.status(),
expected
);
}
let mut duplicate = req.clone();
let repeated = duplicate.reviews[0].resolutions[0].clone();
duplicate.reviews[0].resolutions.push(repeated);
let conflicted = preview(&server, &stored, &duplicate).await;
assert!(
!conflicted["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(
post(&server, id, "apply", Some(&etag), &conflicted)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(std::fs::read(&disk_path).unwrap(), before);
assert_eq!(server.get(id).await.etag(), etag);
let mut bundle = stored.source_project.clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str("\n// Source-only concurrent edit\n");
let changed = server
.client
.put(server.url(&format!("/projects/{id}/source-project")))
.header("if-match", &etag)
.json(&bundle)
.send()
.await
.unwrap();
assert_eq!(changed.status(), StatusCode::OK);
let current = server.get(id).await;
assert_eq!(current.model, stored.model);
let bytes = std::fs::read(&disk_path).unwrap();
for precondition in [&etag, &current.etag()] {
assert_eq!(
post(&server, id, "apply", Some(precondition), &plan)
.await
.status(),
StatusCode::PRECONDITION_FAILED
);
}
assert_eq!(std::fs::read(&disk_path).unwrap(), bytes);
server.stop().await;
}
#[tokio::test]
async fn semantic_candidate_constructor_reverse_direction_and_explicit_trace_selection() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let mut model = fixture();
let mut second = model.traces[&key(4)].clone();
second.id = key(5);
second.alias = "second_review".into();
second.kind = TraceKind::Manual;
model.traces.insert(second.id, second);
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let mut req = request(
key(3),
key(2),
vec![
(
FacetSelector::Data(DataFacet::Constructor),
SemanticDecision::TakeSource,
),
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::Candidate,
),
],
);
let ambiguous = preview(&server, &stored, &req).await;
assert!(
!ambiguous["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
req.traces.insert(key(3), key(5));
let mut candidate = model.elements[&key(2)].clone();
*candidate.shared_mut().unwrap() = model.elements[&key(3)].shared().unwrap().clone();
if let SharedBody::DataType { unit, .. } = candidate.shared_mut().unwrap() {
*unit = "cm".into();
}
req.candidates
.push(SemanticRecord::Element(candidate.clone()));
req.references.insert(key(900), key(901)); // Historical correspondence survives transport unchanged.
let envelope = preview(&server, &stored, &req).await;
assert_eq!(
envelope["plan"]["request"],
serde_json::to_value(&req).unwrap()
);
assert_eq!(envelope["plan"]["conflicts"], json!([]));
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
let applied = server.get(id).await;
assert_eq!(applied.model.elements[&key(2)], candidate);
assert!(
applied.model.traces[&key(4)]
.baseline
.as_ref()
.unwrap()
.semantic
.is_none()
);
assert!(
applied.model.traces[&key(5)]
.baseline
.as_ref()
.unwrap()
.semantic
.is_some()
);
assert_pair(&applied);
server.stop().await;
}
#[tokio::test]
async fn semantic_new_mapping_keeps_preview_generated_identifiers() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let mut model = Project::empty();
let source = model
.add_element(Kind::Activity, Layer::Oa, "Inspect", None)
.unwrap();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let req = SemanticTransitionRequest::new(TransitionRequest::new(
TransitionDirection::OaToSa,
vec![source],
));
let envelope = preview(&server, &stored, &req).await;
assert_eq!(envelope["plan"]["conflicts"], json!([]));
let plan: SemanticTransitionPlan = serde_json::from_value(envelope["plan"].clone()).unwrap();
let expected = oa_core::semantic::apply_semantic_transition(&model, &plan).unwrap();
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
assert_eq!(server.get(id).await.model, expected);
server.stop().await;
}
#[tokio::test]
async fn semantic_resets_and_invalid_candidates_preserve_deliberate_review_boundaries() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let model = fixture();
let reviewed = oa_core::semantic::plan_semantic_transition(
&model,
request(
key(2),
key(3),
vec![
(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::KeepTarget,
),
(
FacetSelector::Header(HeaderFacet::Name),
SemanticDecision::KeepTarget,
),
],
),
)
.unwrap();
let model = oa_core::semantic::apply_semantic_transition(&model, &reviewed).unwrap();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let before = std::fs::read(directory.path().join(format!("{id}.json"))).unwrap();
let mut req = request(
key(2),
key(3),
vec![(
FacetSelector::Data(DataFacet::Unit),
SemanticDecision::Candidate,
)],
);
let mut candidate = model.elements[&key(3)].clone();
candidate.name = "Unselected incidental edit".into();
req.candidates.push(SemanticRecord::Element(candidate));
let invalid = preview(&server, &stored, &req).await;
assert!(!invalid["plan"]["conflicts"].as_array().unwrap().is_empty());
assert_eq!(
post(&server, id, "apply", Some(&stored.etag()), &invalid)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
before
);
req = request(key(2), key(3), vec![]);
req.reviews[0]
.reset
.push(FacetSelector::Data(DataFacet::Unit));
let reset = preview(&server, &stored, &req).await;
assert_eq!(
reset["plan"]["request"]["reviews"][0]["reset"],
json!([{"Data":"Unit"}])
);
assert_eq!(reset["plan"]["conflicts"], json!([]));
ok(post(&server, id, "apply", Some(&stored.etag()), &reset).await).await;
let current = server.get(id).await;
let facts = &current.model.traces[&key(4)]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets;
assert_eq!(facts.len(), 1);
assert_eq!(
facts[0].selector(),
FacetSelector::Header(HeaderFacet::Name)
);
assert_eq!(current.model.elements, model.elements);
assert_pair(&current);
server.stop().await;
}
fn cleanup_fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Source record", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "Target record", None)
.unwrap();
let base = p
.add_element(Kind::DataType, Layer::Sa, "Base record", None)
.unwrap();
for (id, supertype) in [(oa, None), (base, None), (sa, Some(base))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
default: None,
unit: String::new(),
};
}
let leaf = p
.add_element(Kind::DataField, Layer::Sa, "Coordinate", Some(base))
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Location", None)
.unwrap();
let field = p
.add_element(Kind::DataField, Layer::Sa, "Payload", Some(item))
.unwrap();
for (id, type_ref) in [(leaf, TypeRef::Real), (field, TypeRef::Named(sa))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
}
let sender = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let receiver = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let exchange = p.relate(
RelationKind::FunctionalExchange,
"Location report",
sender,
receiver,
);
let RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} = &mut p.relations.get_mut(&exchange).unwrap().body
else {
panic!()
};
items.push(item);
for id in [*source_port, *target_port] {
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
} => incoming_items.push(item),
SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
} => outgoing_items.push(item),
_ => panic!(),
}
}
let scenario = p
.add_element(Kind::Scenario, Layer::Sa, "Report location", None)
.unwrap();
let message = p
.add_element(Kind::Occurrence, Layer::Sa, "Report", Some(scenario))
.unwrap();
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![MessageValue {
path: vec![field],
field: leaf,
expression: "10".into(),
}],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(message);
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "record_trace".into(),
name: "Record correspondence".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
(p, oa, sa, message)
}
#[tokio::test]
async fn semantic_cleanup_impacts_require_exact_acceptance_before_atomic_apply() {
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (model, oa, sa, message) = cleanup_fixture();
let (id, _) = server.import(&model).await;
let stored = server.get(id).await;
let path = directory.path().join(format!("{id}.json"));
let before = std::fs::read(&path).unwrap();
let mut req = request(
oa,
sa,
vec![(
FacetSelector::Data(DataFacet::RecordSupertype),
SemanticDecision::TakeSource,
)],
);
let unresolved = preview(&server, &stored, &req).await;
assert!(
!unresolved["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
let impacts: Vec<SemanticImpact> =
serde_json::from_value(unresolved["plan"]["impacts"].clone()).unwrap();
assert_eq!(impacts.len(), 1);
assert_eq!(impacts[0].object, message);
assert_ne!(impacts[0].before, impacts[0].after);
assert_eq!(
post(&server, id, "apply", Some(&stored.etag()), &unresolved)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
let mut inaccurate = impacts.clone();
inaccurate[0].after = inaccurate[0].before.clone();
req.accepted_impacts = inaccurate;
let inaccurate = preview(&server, &stored, &req).await;
assert!(
!inaccurate["plan"]["conflicts"]
.as_array()
.unwrap()
.is_empty()
);
assert_eq!(std::fs::read(&path).unwrap(), before);
req.accepted_impacts = impacts;
let approved = preview(&server, &stored, &req).await;
assert_eq!(approved["plan"]["conflicts"], json!([]));
assert_eq!(
approved["plan"]["request"]["accepted_impacts"],
serde_json::to_value(&req.accepted_impacts).unwrap()
);
ok(post(&server, id, "apply", Some(&stored.etag()), &approved).await).await;
let current = server.get(id).await;
assert_eq!(current.revision, stored.revision + 1);
assert!(
matches!(current.model.elements[&message].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.is_empty())
);
assert_pair(&current);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.model, current.model);
server.stop().await;
}
@@ -6,10 +6,10 @@ fn authored() -> SourceBundle {
let bundle = SourceBundle {
entry: "harbor.arcadia".into(),
files: BTreeMap::from([
("harbor.arcadia".into(), "// Harbor engineering rationale 航海\r\narcadia 4; 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()),
("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\" {}\r\ninterface_item_allocation first_allocation \"First allocation\" { source @contract; target @first // first reference rationale\r\n; order 0; }\r\ninterface_item_allocation second_allocation \"Second allocation\" { source @contract; target @second // second reference rationale\r\n; order 1; }\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()),
]),
};
@@ -1,136 +0,0 @@
"""Validate actual local HTTP responses against served and checked-in OpenAPI.
Requires jsonschema. Usage: python semantic_http_contract.py /path/to/arcadia-server
The supplied binary is launched on loopback with a fresh temporary store.
"""
import copy
import json
import os
import re
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from jsonschema import Draft202012Validator, FormatChecker
ROOT = Path(__file__).resolve().parents[3]
local = json.loads((ROOT / "docs/openapi.json").read_text(encoding="utf-8"))
for schema in local["components"]["schemas"].values():
Draft202012Validator.check_schema(schema)
def validator(spec, name):
return Draft202012Validator({"$ref": "#/components/schemas/" + name, "components": spec["components"]},
format_checker=FormatChecker())
with tempfile.TemporaryDirectory(prefix="arcadia-semantic-http-") as directory:
env = dict(os.environ)
env.pop("ARCADIA_SERVER_TOKEN", None)
process = subprocess.Popen([sys.argv[1], "--bind", "127.0.0.1:0", "--data-dir", directory],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
try:
banner = process.stderr.readline()
address = re.search(r"listening on (127\.0\.0\.1:\d+)", banner)
assert address, banner
base = "http://" + address.group(1)
def call(method, path, body=None, etag=None, expected=200):
headers = {"Content-Type": "application/json"}
if etag:
headers["If-Match"] = etag
request = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(),
headers=headers, method=method)
try:
response = urllib.request.urlopen(request, timeout=15)
except urllib.error.HTTPError as error:
response = error
with response:
data = json.load(response)
assert response.status == expected, (response.status, data)
return data, response.headers.get("ETag")
served, _ = call("GET", "/openapi.json")
assert served == local, "Server binary embeds a different OpenAPI file; rebuild it"
checked = 0
def validate(name, value):
global checked
for spec in (local, served):
validator(spec, name).validate(value)
checked += 1
old = json.loads((ROOT / "crates/oa-core/tests/fixtures/v4-reviewed-prose.json").read_text())
imported, etag = call("POST", "/api/v1/projects/import", old, expected=201)
path = "/api/v1/projects/" + imported["resource_id"]
stored, _ = call("GET", path)
validate("StoredProject", stored)
validate("Project", stored["model"])
trace = next(iter(stored["model"]["traces"]))
oa = stored["model"]["traces"][trace]["oa"]
sa = stored["model"]["traces"][trace]["sa"]
query = {"traces": [trace], "references": {oa: sa}, "revision": stored["revision"]}
validate("SemanticQueryRequest", query)
queried, _ = call("POST", path + "/semantic-transitions/query", query)
validate("SemanticQueryResult", queried)
request = {"transition": {"direction": "OaToSa", "sources": [oa], "scope": "SelectedOnly",
"mappings": [{"source": oa, "target": sa, "target_kind": None}], "fields": [],
"resolutions": [], "destination_diagram": None}, "traces": {oa: trace},
"references": {}, "reviews": [{"source": oa, "resolutions": [
{"facet": {"Header": "Name"}, "decision": "TakeSource"},
{"facet": {"Data": "Unit"}, "decision": "KeepTarget"}], "reset": []}],
"candidates": [], "accepted_impacts": []}
validate("SemanticTransitionRequest", request)
one_sided = copy.deepcopy(request)
one_sided["reviews"][0]["resolutions"] = [{"facet": {"Literal": {"oa": "Only source"}}, "decision": "KeepTarget"}]
validate("SemanticTransitionRequest", one_sided)
admitted, _ = call("POST", path + "/semantic-transitions/preview", one_sided, etag)
validate("SemanticTransitionEnvelope", admitted)
assert admitted["plan"]["conflicts"] # Valid typed input, inapplicable to the integer/real fixture.
preview, _ = call("POST", path + "/semantic-transitions/preview", request, etag)
validate("SemanticTransitionEnvelope", preview)
assert not preview["plan"]["conflicts"], preview
result, updated_etag = call("POST", path + "/semantic-transitions/apply", preview, etag)
validate("MutationResult", result)
stored, _ = call("GET", path)
validate("StoredProject", stored)
validate("Project", stored["model"])
baseline = stored["model"]["traces"][trace]["baseline"]
validate("TraceBaseline", baseline)
validate("SemanticBaseline", baseline["semantic"])
query.pop("revision")
queried, _ = call("POST", path + "/semantic-transitions/query", query)
validate("SemanticQueryResult", queried)
problem, _ = call("POST", path + "/semantic-transitions/apply", preview, etag, expected=412)
validate("Problem", problem)
assert updated_etag != etag
invalid = [
("FacetSelector", {"path": ["definition", "minimum"]}),
("FacetSelector", {"Data": "Unsupported"}),
("FacetReview", {"Data": {"IntegerMinimum": {"oa": {"Value": "wrong"}, "sa": {"Value": None}}}}),
("FacetReview", {"Data": {"Unit": {"oa": {"Value": 42}, "sa": "NotApplicable"}}}),
("FacetSelector", {"Literal": {"oa": "literal", "sa": None, "index": 0}}),
("SemanticQueryRequest", {"traces": [trace, trace]}),
("SemanticQueryRequest", {"traces": []}),
("SemanticTransitionEnvelope", {**preview, "unknown": True}),
]
obsolete = copy.deepcopy(stored["model"])
obsolete["schema_version"] = 4
invalid.append(("Project", obsolete))
for name, value in invalid:
assert not validator(local, name).is_valid(value), (name, value)
assert not validator(served, name).is_valid(value), (name, value)
# NotApplicable is distinct from Value(null); each leaf fixes the optionality.
validate("FacetReview", {"Data": {"IntegerMinimum": {"oa": "NotApplicable", "sa": {"Value": None}}}})
validate("FacetSelector", {"Annotation": {"oa": {"path": [oa], "field": sa}, "sa": None}})
validate("FacetSelector", {"Annotation": {"oa": {"path": [oa], "field": sa}}})
validate("SemanticImpact", {"object": oa, "after": {"Element": stored["model"]["elements"][oa]}})
print(f"{len(local['components']['schemas'])} valid schemas; {checked} actual/typed values validated against served+local contracts; {len(invalid)} invalid shapes rejected")
finally:
process.terminate()
process.wait(timeout=15)
-205
View File
@@ -1,205 +0,0 @@
"""Refresh the closed semantic OpenAPI catalog from the frozen Rust leaf table.
Run from any directory with Python 3: semantic_schema.py [--check].
This generates documentation only, never runtime selectors or model mutations.
"""
import copy
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
PATH = ROOT / "docs/openapi.json"
spec = json.loads(PATH.read_text(encoding="utf-8"))
schemas = spec["components"]["schemas"]
types = (ROOT / "crates/oa-core/src/semantic/types.rs").read_text(encoding="utf-8")
model = (ROOT / "crates/oa-core/src/model.rs").read_text(encoding="utf-8")
def ref(name):
return {"$ref": f"#/components/schemas/{name}"}
def obj(properties, required=None):
return {"type": "object", "properties": properties,
"required": list(properties) if required is None else required,
"additionalProperties": False}
def array(item, maximum=None):
result = {"type": "array", "items": item}
if maximum is not None:
result["maxItems"] = maximum
return result
def optional(item):
return {"anyOf": [item, {"type": "null"}]}
def enum(names):
return {"type": "string", "enum": names}
def variant(name, value):
return obj({name: value})
def typed(name):
if name.startswith("Option<"):
return optional(typed(name[7:-1]))
if name.startswith("Vec<"):
return array(typed(name[4:-1]))
primitives = {"String": {"type": "string"}, "bool": {"type": "boolean"},
"u32": {"type": "integer", "minimum": 0, "maximum": 4294967295},
"i64": {"type": "integer", "minimum": -9223372036854775808,
"maximum": 9223372036854775807}, "f64": {"type": "number"}}
return primitives[name] if name in primitives else ref(name)
def reviewed(name):
key = "Reviewed" + re.sub(r"[^A-Za-z0-9]", "", name)
observation = {"oneOf": [enum(["NotApplicable"]), variant("Value", typed(name))]}
schemas[key] = obj({"oa": observation, "sa": copy.deepcopy(observation)})
return ref(key)
def keypair(name):
key = "KeyPair" + name
schemas[key] = obj({"oa": optional(typed(name)), "sa": optional(typed(name))}, [])
return ref(key)
for name in ["DataConstructor", "PortConstructor", "OccurrenceConstructor", "EventConstructor",
"MessageConstructor", "ReferenceCollection", "OrderedCollection", "IncidentEnd",
"ArtifactFacet", "ComponentPortKind", "ComponentPortOrientation", "PortDirection",
"FragmentOperator", "StateActionKind", "ArtifactRelation"]:
source = types if f"pub enum {name}" in types else model
body = re.search(r"pub enum " + name + r"\s*\{([^}]+)\}", source).group(1)
body = re.sub(r"//[^\n]*|#\[[^\]]*\]", "", body)
names = [value.strip() for value in body.split(",") if value.strip()]
assert all(re.fullmatch(r"[A-Za-z]+", value) for value in names), (name, names)
schemas[name] = enum(names)
catalog = types.split("macro_rules! scalar_families", 1)[1].split("pub(super) use", 1)[0]
families = re.findall(r"(\w+), (\w+), (\w+) \{([^}]+)\}", catalog)
assert len(families) == 13
selectors, reviews = [], []
leaf_count = 0
for family, selector, review, body in families:
leaves = re.findall(r"(\w+): ([\w<>]+)", body)
leaf_count += len(leaves)
schemas[selector] = enum([leaf for leaf, _ in leaves])
schemas[review] = {"oneOf": [variant(leaf, reviewed(value)) for leaf, value in leaves]}
selectors.append(variant(family, ref(selector)))
reviews.append(variant(family, ref(review)))
assert leaf_count == 84
schemas["AnnotationKey"] = obj({"path": array(ref("Id")), "field": ref("Id")})
id_keys, text_keys, annotation_keys = keypair("Id"), keypair("String"), keypair("AnnotationKey")
artifact_types = {"Presence": "bool", "Name": "String", "Description": "String", "Owner": "Id",
"Relation": "ArtifactRelation", "Uri": "String", "Revision": "Option<String>",
"Fragment": "Option<String>"}
schemas["ArtifactReview"] = {"oneOf": [variant(k, reviewed(v)) for k, v in artifact_types.items()]}
selectors += [variant("Member", obj({"collection": ref("ReferenceCollection"), "keys": id_keys})),
variant("Order", ref("OrderedCollection")), variant("Literal", text_keys),
variant("Annotation", annotation_keys), variant("Binding", id_keys), variant("Child", id_keys),
variant("Incident", obj({"keys": id_keys, "end": ref("IncidentEnd")})),
variant("Artifact", obj({"keys": id_keys, "facet": ref("ArtifactFacet")}))]
reviews += [variant("Member", obj({"collection": ref("ReferenceCollection"), "keys": id_keys, "review": reviewed("bool")})),
variant("Order", obj({"collection": ref("OrderedCollection"), "review": reviewed("Vec<Id>")})),
variant("Literal", obj({"keys": text_keys, "review": reviewed("bool")})),
variant("Annotation", obj({"keys": annotation_keys, "review": reviewed("Option<MessageValue>")})),
variant("Binding", obj({"keys": id_keys, "review": reviewed("Option<LifelineBinding>")})),
variant("Child", obj({"keys": id_keys, "review": reviewed("bool")})),
variant("Incident", obj({"keys": id_keys, "end": ref("IncidentEnd"), "review": reviewed("bool")})),
variant("Artifact", obj({"keys": id_keys, "review": ref("ArtifactReview")}))]
schemas["FacetSelector"] = {"oneOf": selectors}
schemas["FacetReview"] = {"oneOf": reviews}
schemas["SemanticBaseline"] = obj({"facets": array(ref("FacetReview"))})
schemas["TraceBaseline"]["properties"]["semantic"] = {**optional(ref("SemanticBaseline")), "default": None,
"description": "Absent/null means Unreviewed. Historical typed facts never become live graph references."}
schemas["Project"]["properties"]["schema_version"]["const"] = 5
schemas["MigrationReport"]["properties"]["from_schema"]["enum"] = [1, 2, 3, 4]
schemas["MigrationReport"]["properties"]["to_schema"]["const"] = 5
schemas["MigrationReport"]["description"] = schemas["MigrationReport"]["description"].replace("V1/V2/V3 to V4", "V1/V2/V3/V4 to V5")
spec["info"]["description"] = spec["info"]["description"].replace("schema-4", "schema-5")
spec["paths"]["/api/v1/projects/import"]["post"]["description"] = "Schema-5 native JSON is described below. Schema-1/2/3/4 JSON and source are accepted through explicit frozen readers and migration, also on replacement and candidate validation. Prior prose reviews remain intact; absent semantic review stays Unreviewed. Export emits schema 5 / arcadia 5. See docs/language.rst."
schemas["SourceBundle"]["description"] = schemas["SourceBundle"]["description"].replace("source4 workspace (source3 migrates explicitly)", "source5 workspace (source3/4 migrate explicitly)")
schemas["Drift"] = enum(["Unreviewed", "Unchanged", "OaChanged", "SaChanged", "BothChanged"])
schemas["FacetDrift"] = obj({"selector": ref("FacetSelector"), "current": ref("FacetReview"), "drift": ref("Drift")})
schemas["SemanticDrift"] = obj({"own": ref("Drift"), "linked": ref("Drift"),
"own_unreviewed": typed("bool"), "linked_unreviewed": typed("bool")})
schemas["LinkedSemanticImpact"] = obj({"objects": id_keys, "traces": array(ref("Id")),
"drift": ref("Drift"), "unreviewed": typed("bool")})
for name, members in [("SemanticRecord", ["Element", "Relation", "Artifact"]),
("ImpactRecord", ["Element", "Relation", "Artifact", "Diagram", "Trace"])]:
schemas[name] = {"oneOf": [variant(n, ref("ArtifactLink" if n == "Artifact" else n)) for n in members]}
schemas["SemanticImpact"] = obj({"object": ref("Id"), "before": optional(ref("ImpactRecord")), "after": optional(ref("ImpactRecord"))}, ["object"])
schemas["SemanticDecision"] = enum(["KeepTarget", "TakeSource", "Candidate"])
schemas["FacetResolution"] = obj({"facet": ref("FacetSelector"), "decision": ref("SemanticDecision")})
schemas["SemanticReviewRequest"] = obj({"source": ref("Id"), "resolutions": array(ref("FacetResolution"), 5000),
"reset": {**array(ref("FacetSelector"), 5000), "default": []}}, ["source", "resolutions"])
def mappings(maximum):
return {"type": "object", "propertyNames": {"format": "uuid"},
"additionalProperties": ref("Id"), "maxProperties": maximum, "default": {}}
schemas["SemanticTransitionRequest"] = obj({"transition": ref("TransitionRequest"), "traces": mappings(100),
"references": mappings(5000), "reviews": {**array(ref("SemanticReviewRequest"), 100), "default": []},
"candidates": {**array(ref("SemanticRecord"), 200), "default": []},
"accepted_impacts": {**array(ref("SemanticImpact"), 200), "default": []}}, ["transition"])
schemas["SemanticTransitionRequest"]["description"] = "Canonical OA-to-SA references include historical UUIDs. Traces map selected source IDs to trace IDs. Combined resolutions/reset limit: 5,000. Candidates must equal exactly the result of their selected facets; accepted_impacts explicitly acknowledge exact induced changes."
schemas["SemanticReviewPreview"] = obj({"source": ref("Id"), "target": ref("Id"), "facets": array(ref("FacetDrift")),
"acknowledged": array(ref("FacetSelector")), "retired": array(ref("FacetSelector")), "linked_impacts": array(ref("LinkedSemanticImpact"))})
schemas["SemanticTransitionPlan"] = obj({"base_fingerprint": typed("String"), "request": ref("SemanticTransitionRequest"),
"transition": ref("TransitionPlan"), "reviews": array(ref("SemanticReviewPreview")), "impacts": array(ref("SemanticImpact")),
"conflicts": array(typed("String")), "commands": array(ref("Command"), 200), "prepared_fingerprint": optional(typed("String"))})
schemas["SemanticTransitionPlan"]["required"].remove("prepared_fingerprint")
schemas["SemanticTransitionEnvelope"] = obj({"base_revision": ref("Revision"), "plan": ref("SemanticTransitionPlan")})
schemas["SemanticQueryRequest"] = obj({"traces": {**array(ref("Id"), 100), "minItems": 1, "uniqueItems": True},
"references": mappings(5000), "revision": optional(ref("Revision"))}, ["traces"])
schemas["SemanticQueryItem"] = obj({"trace": ref("Id"), "oa": ref("Id"), "sa": ref("Id"),
"facets": array(ref("FacetDrift")), "drift": ref("SemanticDrift"), "linked_impacts": array(ref("LinkedSemanticImpact"))})
schemas["SemanticQueryResult"] = obj({"revision": ref("Revision"), "items": array(ref("SemanticQueryItem"), 100)})
for operation, request, result in [("query", "SemanticQueryRequest", "SemanticQueryResult"),
("preview", "SemanticTransitionRequest", "SemanticTransitionEnvelope"),
("apply", "SemanticTransitionEnvelope", "MutationResult")]:
endpoint = copy.deepcopy(spec["paths"]["/api/v1/projects/{id}/transitions/" + ("apply" if operation == "apply" else "preview")])
post = endpoint["post"]
post["operationId"] = operation + "SemanticTransition"
post["summary"] = {"query": "Query typed trace drift and linked impacts without mutation",
"preview": "Preview and source-validate a typed semantic transition without writing",
"apply": "Conditionally apply a prepared semantic candidate in one model/source revision"}[operation]
post["requestBody"]["content"]["application/json"]["schema"] = ref(request)
post["responses"]["200"]["content"]["application/json"]["schema"] = ref(result)
post["description"] = "Uses the frozen core typed semantic contract. A supplied plan is a client-owned candidate, not a signed receipt; fingerprints check consistency, not original review authorship. Preview validates source writeback when conflict-free. Apply preserves preview-generated IDs and does not replan random identities."
if operation == "query":
post["parameters"] = [p for p in post["parameters"] if p["in"] != "header"]
post["responses"].pop("412", None)
post["responses"].pop("428", None)
post["responses"]["409"] = {"$ref": "#/components/responses/Problem"}
post["description"] = "Read one immutable snapshot. Optional revision mismatch returns409; missing trace returns404; duplicate/empty trace selection returns400. References are canonical OA-to-SA correspondence. No write, baseline refresh or external artifact fetch."
spec["paths"]["/api/v1/projects/{id}/semantic-transitions/" + operation] = endpoint
def check_refs(value):
if isinstance(value, dict):
if "$ref" in value and value["$ref"].startswith("#/components/schemas/"):
assert value["$ref"].split("/")[-1] in schemas, value
for child in value.values():
check_refs(child)
elif isinstance(value, list):
for child in value:
check_refs(child)
check_refs(spec)
rendered = json.dumps(spec, indent=2, ensure_ascii=False) + "\n"
if "--check" in sys.argv:
assert json.loads(PATH.read_text(encoding="utf-8")) == spec, "Run semantic_schema.py to refresh OpenAPI"
else:
PATH.write_text(rendered, encoding="utf-8")
print(f"Closed catalog: {len(families)} families, {leaf_count} scalar leaves; {len(schemas)} schemas")
+8 -140
View File
@@ -763,18 +763,6 @@ bearer token, if any. Non-loopback binding requires a configured token.</p>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/transitions/apply</span></span></p></td>
<td><p>Conditionally apply that client-held plan and base_revision.</p></td>
</tr>
<tr><td><p>POST</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/query</span></span></p></td>
<td><p>Read selected traces' typed facts, own drift and linked impacts.</p></td>
</tr>
<tr><td><p>POST</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/preview</span></span></p></td>
<td><p>Prepare typed semantic decisions and source writeback without writing.</p></td>
</tr>
<tr><td><p>POST</p></td>
<td><p><span class="docutils literal"><span class="pre">/api/v1/projects/{id}/semantic-transitions/apply</span></span></p></td>
<td><p>Apply that prepared candidate under its original shared revision/ETag.</p></td>
</tr>
</tbody>
</table>
<p><span class="docutils literal"><span class="pre">Content-Type:</span> application/json</span> is required for JSON requests.
@@ -796,8 +784,8 @@ candidate validation reports the same diagnostic in its 200 response.
Parsing, validation and query processing use snapshot workers, so this work
does not hold the catalog lock or serialize unrelated model preparation.</p>
<p>Import accepts schema-1 JSON through the shared explicit migration adapter,
as well as schema-2, schema-3, schema-4 and current schema-5 JSON. Source import uses the shared language parser
and its supported migrations. Export uses schema 5 / arcadia 5. Native JSON
as well as schema-2 native JSON. Source import uses the shared language parser
and its supported migrations. Export uses schema 3 / arcadia 3. Native JSON
replacement accepts supported older schemas through the same explicit migration.
Only documented query parameters are accepted. In particular, appending
<span class="docutils literal"><span class="pre">?revision=...</span></span> to model/source export is an error, not historical retrieval.</p>
@@ -808,7 +796,7 @@ Only documented query parameters are accepted. In particular, appending
<pre class="literal-block">{
&quot;entry&quot;: &quot;rescue.arcadia&quot;,
&quot;files&quot;: {
&quot;rescue.arcadia&quot;: &quot;arcadia 5; project rescue \&quot;Rescue\&quot; { include \&quot;oa/work.arcadia\&quot;; }\n&quot;,
&quot;rescue.arcadia&quot;: &quot;arcadia 3; project rescue \&quot;Rescue\&quot; { include \&quot;oa/work.arcadia\&quot;; }\n&quot;,
&quot;oa/work.arcadia&quot;: &quot;// Engineering rationale\noa_activity assess \&quot;Assess assistance\&quot; {}\n&quot;
}
}</pre>
@@ -819,9 +807,8 @@ targets or another hosted resource. All submitted files must be reachable;
missing files, cycles, duplicate keys, case collisions and escaping paths are
rejected. The bundle permits at most 256 files, 32 include edges along a path,
and 1,024 include edges in total. Model and source-byte budgets apply across
the complete bundle, not separately per file. Current bundle syntax is source5; source3/4 bundles migrate explicitly while
preserving their files/includes/comments. V1/V2 single-file migration remains
available through interchange import.</p>
the complete bundle, not separately per file. Bundle syntax is source3;
single-file legacy migration remains available through interchange import.</p>
<p>Compilation inserts UUIDs into headers that omit them. Import returns 201 with
Location/ETag; GET source-project retrieves the stabilized accepted bundle.
PUT returns <span class="docutils literal">revision</span>, <span class="docutils literal">changed</span>, located <span class="docutils literal">issues</span> and that accepted
@@ -978,11 +965,8 @@ does not execute conditions or simulate a process.</p>
store <span class="docutils literal">message_kind</span>, optional correlated-call <span class="docutils literal">reply_to</span> and contextual
<span class="docutils literal">values</span>. Message kinds are Asynchronous, Call, Reply, Create, Delete, or
<span class="docutils literal"><span class="pre">{&quot;Timer&quot;:{&quot;expression&quot;:&quot;after</span> 5 <span class="pre">s&quot;}}</span></span>. Each value record contains a
carried DataField UUID in <span class="docutils literal">field</span>, enclosing field UUID list <span class="docutils literal">path</span> (default
[]) and textual <span class="docutils literal">expression</span>. Identity is (path, field), allowing repeated uses
of a Record. Operation Call/Reply direction checks use the direct parameter;
Reference storage stops nested payload traversal. Expressions do not change the
shared exchange item's type or evaluate server-side.</p>
carried DataField UUID in <span class="docutils literal">field</span> and its textual <span class="docutils literal">expression</span>; expressions
do not change the shared exchange item's type or evaluate server-side.</p>
<p><span class="docutils literal">ScenarioUse</span> contains a referenced <span class="docutils literal">scenario</span> and one-to-one lifeline
<span class="docutils literal">bindings</span> records with <span class="docutils literal">referenced</span> and <span class="docutils literal">local</span> UUIDs. The core validates
viewpoint/layer compatibility, call/reply order and reversal, lifetime order,
@@ -994,26 +978,6 @@ exports make those compatibility defaults explicit. This preserves original V2
models while the release schema is being integrated; it does not silently
reinterpret old mixed lifelines as a strict new viewpoint.</p>
</section>
<section id="data-and-interface-contracts">
<h2>Data and interface contracts</h2>
<p>Schema4 uses typed bounded DataTypeDefinition and DataValue constructors, field
storage/defaults/directions, and single specialization. Exact JSON shapes are in
<span class="docutils literal">openapi.json</span> and semantic rules and runnable source in <span class="docutils literal">language.rst</span> section 4.5.
Interface membership is an identified InterfaceItemAllocation relation with
independent order/transmission/acquisition. DataAssociation relates Record types
with named, navigable, multiplicity-bearing ends; it does not add carried fields.</p>
<p>Use ordinary relation queries, transactions and artifact links, for example
<span class="docutils literal">GET <span class="pre">/api/v1/projects/{id}/relations?kind=InterfaceItemAllocation</span></span>. The service
and desktop use the same inheritance/path/default validation. Invalid bounds,
defaults, directions, cycles or contract-breaking deletions reject atomically,
preserving the accepted source/model, revision and stored bytes. Correct a related
contract in the same transaction when removing a used interface allocation.</p>
<p>Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
add semantic defaults, generated interface allocations and unresolved message
values. Old source3 bundles retain files/includes/comments during upgrade; all
current exports use schema5/source5. Reading an old envelope does not write it or
advance its ETag. Canonical exports omit transient migration reports.</p>
</section>
<section id="validation">
<h2>Validation</h2>
<p>GET returns current-model diagnostics. POST accepts either:</p>
@@ -1021,7 +985,7 @@ advance its ETag. Canonical exports omit transient migration reports.</p>
<p>or:</p>
<pre class="literal-block">{&quot;format&quot;:&quot;arcadia&quot;, &quot;source&quot;:&quot;complete source document&quot;}</pre>
<p>or:</p>
<pre class="literal-block">{&quot;format&quot;:&quot;source-project&quot;, &quot;source_project&quot;:{&quot;entry&quot;:&quot;rescue.arcadia&quot;, &quot;files&quot;:{&quot;rescue.arcadia&quot;:&quot;arcadia 5; project rescue \&quot;Rescue\&quot; {}&quot;}}}</pre>
<pre class="literal-block">{&quot;format&quot;:&quot;source-project&quot;, &quot;source_project&quot;:{&quot;entry&quot;:&quot;rescue.arcadia&quot;, &quot;files&quot;:{&quot;rescue.arcadia&quot;:&quot;arcadia 3; project rescue \&quot;Rescue\&quot; {}&quot;}}}</pre>
<p>A well-formed validation request returns 200 with <span class="docutils literal">valid</span> and <span class="docutils literal">issues</span> even
if its candidate is invalid, including a model record that fails typed decoding.
Malformed request JSON or an invalid format/payload envelope is a 400.
@@ -1105,102 +1069,6 @@ authenticated as an untouched server preview; the same authenticated client
can submit ordinary typed commands. The core fingerprint is a change detector,
not a security digest. There is no plan token, expiration store or receipt.</p>
</section>
<section id="typed-semantic-transition-review">
<h2>Typed semantic transition review</h2>
<p>The sibling <span class="docutils literal"><span class="pre">semantic-transitions</span></span> routes use the <a class="reference external" href="language.html#typed-semantic-review-facts">typed review semantics</a> in <span class="docutils literal">language.rst</span> sections 5.1–5.5.
The original prose transition routes keep
their existing contract. All new routes use JSON and the existing authentication,
capacity, worker and error boundaries.</p>
<p>Query accepts <span class="docutils literal"><span class="pre">{&quot;traces&quot;:[&quot;trace-uuid&quot;],&quot;references&quot;:{},&quot;revision&quot;:1}</span></span>.
<span class="docutils literal">references</span> is an optional map of canonical OA UUIDs to SA UUIDs, including
historical deleted identities. It never guesses correspondences from names.
<span class="docutils literal">revision</span> is optional; a mismatch returns 409. Select 1–100 unique existing
trace IDs; an empty or duplicate selection returns 400 and a missing trace
returns 404. The response includes the current revision and items containing
<span class="docutils literal">trace</span>, <span class="docutils literal">oa</span>, <span class="docutils literal">sa</span>, typed <span class="docutils literal">facets</span>, <span class="docutils literal">drift</span> and <span class="docutils literal">linked_impacts</span>.
Own and linked drift and their unreviewed flags remain distinct. Query reads one
immutable snapshot, does not refresh baselines, and does not fetch artifact URIs.</p>
<p>Preview accepts <span class="docutils literal">SemanticTransitionRequest</span>:</p>
<pre class="literal-block">{
&quot;transition&quot;: {
&quot;direction&quot;: &quot;OaToSa&quot;,
&quot;sources&quot;: [&quot;40000000-0000-4000-8000-000000000002&quot;],
&quot;scope&quot;: &quot;SelectedOnly&quot;,
&quot;mappings&quot;: [{
&quot;source&quot;: &quot;40000000-0000-4000-8000-000000000002&quot;,
&quot;target&quot;: &quot;40000000-0000-4000-8000-000000000003&quot;,
&quot;target_kind&quot;: null
}],
&quot;fields&quot;: [], &quot;resolutions&quot;: [], &quot;destination_diagram&quot;: null
},
&quot;traces&quot;: {}, &quot;references&quot;: {},
&quot;reviews&quot;: [{
&quot;source&quot;: &quot;40000000-0000-4000-8000-000000000002&quot;,
&quot;resolutions&quot;: [
{&quot;facet&quot;:{&quot;Header&quot;:&quot;Name&quot;}, &quot;decision&quot;:&quot;TakeSource&quot;},
{&quot;facet&quot;:{&quot;Data&quot;:&quot;Unit&quot;}, &quot;decision&quot;:&quot;KeepTarget&quot;}
],
&quot;reset&quot;: []
}],
&quot;candidates&quot;: [], &quot;accepted_impacts&quot;: []
}</pre>
<p>The example maps an existing OA data type to its traced SA counterpart, takes
only its name and acknowledges intentionally different units. Other facets stay
unreviewed or retain their earlier drift. Select an explicit trace through the
<span class="docutils literal">traces</span> map (source UUID to trace UUID) when multiple trace kinds connect the
pair. <span class="docutils literal">reset</span> deliberately invalidates exact old selectors after changed
correspondence. The optional collections default to empty. Semantic header
decisions use typed facets; leave legacy <span class="docutils literal">transition.fields</span> and
<span class="docutils literal">transition.resolutions</span> empty.</p>
<p>Facet selectors are closed enums, for example <span class="docutils literal"><span class="pre">{&quot;Data&quot;:&quot;IntegerMinimum&quot;}</span></span>
or <span class="docutils literal"><span class="pre">{&quot;Member&quot;:{&quot;collection&quot;:&quot;SignalItems&quot;,&quot;keys&quot;:{&quot;oa&quot;:&quot;oa-uuid&quot;,&quot;sa&quot;:&quot;sa-uuid&quot;}}}</span></span>.
Annotation keys contain the entire path and field; enum literal keys are exact
strings. <span class="docutils literal">KeepTarget</span>, <span class="docutils literal">TakeSource</span> and <span class="docutils literal">Candidate</span> operate on independently
selected facts. Candidate records use existing Element/Relation/ArtifactLink
types, must preserve identity/alias, and must equal precisely the result of
the selected facets. Constructor review covers its coupled introduced/removed
fields. Membership review does not silently acknowledge order; target-only
members remain independent. OpenAPI defines every selector and typed payload.</p>
<p>Preview requires <span class="docutils literal"><span class="pre">If-Match</span></span> and returns <span class="docutils literal"><span class="pre">{&quot;base_revision&quot;:1,&quot;plan&quot;:{...}}</span></span>.
The plan contains the original typed request, mapping plan, review facts,
acknowledged/retired selectors, linked impacts, exact prepared impacts, conflicts,
commands, and base/prepared fingerprints. A conflict-free preview also prepares
the complete authored source bundle and checks its model equivalence and capacity.
No preview changes the model, source, revision or stored bytes.</p>
<p>If ordinary preparation induces additional cleanup, the plan reports exact typed
before/after impacts and a conflict. Review those impacts, place the accepted
records in <span class="docutils literal">accepted_impacts</span>, and request another preview. Acceptance cannot
override a contradictory selected facet. Missing mappings, ambiguous order,
duplicate decisions, invalid candidates or unresolved impacts cannot be applied.
This is the shared core planner and command preparation, with no server-specific
semantic mutation engine.</p>
<p>Apply accepts the entire preview envelope under the same <span class="docutils literal"><span class="pre">If-Match</span></span>. It checks
<span class="docutils literal">base_revision</span>, the model fingerprint, unresolved conflicts, the prepared
candidate fingerprint, ordinary model validation and coupled source preparation,
then atomically stores one accepted model/source revision. The final store check
also catches a concurrent edit during preparation. Source-only edits invalidate
the preview through the shared ETag even when the model fingerprint is unchanged.
Stale ETag/revision returns 412; semantic rejection returns 422 with code
<span class="docutils literal">semantic_transition_rejected</span>. Exact no-op candidates preserve revision.
Read and preview again after a successful changed apply before reviewing further.</p>
<p>A supplied plan remains a client-owned candidate. Fingerprints detect stale or
inconsistent proposals; they are not tamper-proof signatures, authentication
receipts or proof of original review authorship. Apply preserves preview-generated
identities and does not rerun the planner with fresh random IDs. Authenticated
clients already have ordinary model/source editing privileges. The server does
not persist plans, tokens or review sessions.</p>
<p>Requests allow 100 sources, trace choices and reviews; 200 mappings, candidates
and accepted impacts; 5,000 canonical reference mappings; and 5,000 facet
resolutions plus resets across all reviews. The legacy 400 prose-resolution
limit and 200-command transaction limit still apply. Query has the same 5,000
reference-map limit. Core bounded linked traversal errors remain observable as
semantic rejection; no truncated result silently claims complete review.</p>
<p>Schema/source 5 adds <span class="docutils literal">TraceBaseline.semantic</span>. Absence or null is Unreviewed;
migrating older prose baselines does not fabricate semantic history. The persisted
baseline contains only closed typed <span class="docutils literal">FacetReview</span> facts, not whole candidate
records or a generic JSON value tree. Historical UUIDs remain historical, including
after deletion, and source uses explicit <span class="docutils literal">uuid</span> tokens for them.</p>
</section>
<section id="error-contract">
<h2>Error contract</h2>
<p>All errors have <span class="docutils literal"><span class="pre">Content-Type:</span> application/problem+json</span>. Example:</p>

Some files were not shown because too many files have changed in this diff Show More