Integrate release recovery, semantic review, coverage and Windows packaging

This commit is contained in:
Codex
2026-09-06 16:31:55 -07:00
parent c091f80e6e
commit 2b2d2bc18b
174 changed files with 27635 additions and 127 deletions
+1 -1
View File
@@ -17,10 +17,10 @@ serde.workspace = true
serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
tempfile = "3"
[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"] }
+1
View File
@@ -0,0 +1 @@
include!("../../build/windows_manifest.rs");
+139 -9
View File
@@ -1,3 +1,4 @@
mod checkpoints;
mod hosted;
use crate::forms::Draft;
use crate::{
@@ -260,6 +261,7 @@ pub struct RecoverySnapshot {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Page {
Home,
Diagram,
Catalog,
Behavior,
@@ -281,6 +283,8 @@ impl std::fmt::Display for Choice {
}
#[derive(Debug, Clone, Copy)]
pub enum Pending {
Recent,
Recover(Id),
HostedOpen,
New,
Demo,
@@ -301,6 +305,10 @@ pub struct FilePrompt {
#[derive(Debug, Clone)]
pub enum Message {
Home(crate::home::Event),
CheckpointTick,
CheckpointWritten(Id, Result<(), String>),
CheckpointLoaded(Result<Arc<crate::recovery::Snapshot>, String>),
Hosted(crate::hosted::Event),
PortAnchor(crate::port_edit::Event),
Connection(crate::connection_edit::Event),
@@ -413,6 +421,10 @@ pub enum Message {
}
pub struct App {
pub home: crate::home::State,
pub recovery: crate::recovery::State,
recent_target: Option<PathBuf>,
checkpoint_close: Option<window::Id>,
pub hosted: crate::hosted::State,
pub connection: Option<crate::connection_edit::Session>,
pub model_menu: Option<crate::model_menu::Opened>,
@@ -424,6 +436,8 @@ pub struct App {
pub exact_strings: bool,
pub document: Document,
pub page: Page,
/// Home resumes this page; explicit Architecture navigation remains separate.
last_workspace_page: Page,
pub guide: crate::guide::State,
pub behavior: crate::behavior::State,
pub data_state: crate::data_view::State,
@@ -473,7 +487,11 @@ pub struct App {
impl App {
pub fn new() -> Self {
let mut app = Self::with_project(Project::demo());
let mut app = Self::with_project(Project::empty());
app.home = crate::home::State::load_default();
app.recovery = crate::recovery::State::load_default();
app.page = Page::Home;
app.status = "Create a model, open a project or explore the example.".into();
app.fit();
app
}
@@ -502,7 +520,11 @@ impl App {
let draft = Draft::from_project(&project, selected);
let explorer_index = crate::explorer::Index::new(&project, layer);
let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect();
Self {
let mut app = Self {
home: Default::default(),
recovery: Default::default(),
recent_target: None,
checkpoint_close: None,
connection: None,
model_menu: None,
source_tree: Default::default(),
@@ -513,6 +535,7 @@ impl App {
exact_strings: false,
document,
page: Page::Diagram,
last_workspace_page: Page::Diagram,
guide: crate::guide::State::default(),
behavior: Default::default(),
data_state: Default::default(),
@@ -562,7 +585,10 @@ impl App {
explorer_group: None,
explorer_page: 0,
workflow: None,
}
};
app.route_state_diagram_workspace();
app.last_workspace_page = app.page;
app
}
pub fn project(&self) -> &Project {
self.document.project()
@@ -586,6 +612,7 @@ impl App {
}
pub fn dirty(&self) -> bool {
self.document.dirty()
|| self.workflow.is_some()
|| self.draft_dirty()
|| self.source_dirty()
|| self.behavior.has_pending()
@@ -844,6 +871,9 @@ impl App {
}
}
fn reset(&mut self, project: Project, path: Option<PathBuf>) {
self.retire_checkpoint();
let home = std::mem::take(&mut self.home);
let recovery = std::mem::take(&mut self.recovery);
let window_size = self.window_size;
let canvas_size = self.canvas_size;
let source_version = self.source_version + 1;
@@ -852,6 +882,8 @@ impl App {
let mut hosted = std::mem::take(&mut self.hosted);
hosted.leave_document();
*self = Self::with_project(project);
self.home = home;
self.recovery = recovery;
self.hosted = hosted;
self.window_size = window_size;
self.canvas_size = canvas_size;
@@ -923,6 +955,7 @@ impl App {
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
iced::event::listen_with(|event, status, _| {
@@ -992,6 +1025,9 @@ impl App {
&& !matches!(
message,
Message::Menu(_)
| Message::CheckpointWritten(..)
| Message::CheckpointLoaded(..)
| Message::CheckpointTick
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::Lint(_)
@@ -1030,7 +1066,9 @@ impl App {
if self.busy
&& !matches!(
message,
Message::Saved(_)
Message::CheckpointWritten(..)
| Message::CheckpointLoaded(..)
| Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(_, _)
@@ -1073,7 +1111,10 @@ impl App {
}
let modal_event = matches!(
message,
Message::WindowResized(_)
Message::CheckpointWritten(..)
| Message::CheckpointTick
| Message::CheckpointLoaded(..)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport
| Message::Lint(_)
@@ -1145,7 +1186,14 @@ impl App {
let previous_layer = self.layer;
let entering_catalog =
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
if self.page != Page::Home {
self.last_workspace_page = self.page;
}
let mut task = self.handle(message);
self.route_state_diagram_workspace();
if self.page != Page::Home {
self.last_workspace_page = self.page;
}
if let Some(context) = self.topology_context() {
self.behavior
.topology
@@ -1206,6 +1254,16 @@ impl App {
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::Home(event) => return self.home_event(event),
Message::CheckpointTick => return self.checkpoint_tick(),
Message::CheckpointWritten(id, result) => {
self.checkpoint_written(id, result);
if let Some(window) = self.checkpoint_close.take() {
self.retire_checkpoint();
return window::close(window);
}
}
Message::CheckpointLoaded(result) => return self.checkpoint_loaded(result),
Message::Hosted(event) => return self.hosted_event(event),
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
@@ -1600,6 +1658,27 @@ impl App {
}
Message::Page(page) => {
if page != Page::Text || !self.draft_dirty() || self.apply() {
if page == Page::Diagram
&& self.active_diagram.is_some_and(|id| {
self.project()
.diagrams
.get(&id)
.is_some_and(|d| d.projection.state_machine().is_some())
})
{
// Architecture remains reachable from a state workspace.
// Its picker can still open a saved state view explicitly.
self.active_diagram = self
.project()
.diagrams
.values()
.filter(|d| {
d.layer == self.layer && d.projection.state_machine().is_none()
})
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
.map(|d| d.id);
self.fit();
}
if page == Page::Behavior && self.page != Page::Behavior {
let previous_state_view = self.state_view_identity();
if let Some(owner) =
@@ -2545,7 +2624,9 @@ impl App {
self.pending_recovery = None;
}
if receipt.snapshot.bundle.entry != self.document.bundle().entry {
if receipt.revision != self.document.revision() {
if receipt.revision != self.document.revision()
|| self.source_dirty()
{
self.status = format!(
"Snapshot saved to {}. Newer edits retain the current source location.",
receipt.path.display()
@@ -2553,8 +2634,9 @@ impl App {
self.continue_after_save = false;
return Task::none();
}
if let Err(error) =
self.document.apply_bundle(receipt.snapshot.bundle.clone())
if let Err(error) = self
.document
.adopt_saved_entry(&receipt.snapshot, receipt.revision)
{
self.status = error.to_string();
self.continue_after_save = false;
@@ -2568,6 +2650,10 @@ impl App {
self.hosted.review = None;
self.hosted.uncertain = None;
self.document.mark_saved(receipt.snapshot);
self.remember_local();
if !self.dirty() {
self.retire_checkpoint();
}
self.prune_closed_files(!self.document.dirty());
self.status = "All source files saved".into();
} else {
@@ -2608,6 +2694,10 @@ impl App {
match result {
Ok(Some(LoadedSnapshot::Legacy(path, decoded, _bytes))) => {
self.reset(decoded.project, None);
let name = self.project().name.clone();
if let Err(error) = self.home.remember(&path, &name) {
self.home.notice = Some(error);
}
self.migration = decoded.migration;
if self.migration.is_some() {
self.status = "Imported earlier schema · review migration details in Model checks before saving".into();
@@ -2905,6 +2995,11 @@ impl App {
fn proceed(&mut self, pending: Pending) -> Task<Message> {
self.pending = None;
match pending {
Pending::Recent => self
.recent_target
.take()
.map_or_else(Task::none, |path| self.load_from(path)),
Pending::Recover(id) => self.load_checkpoint(id),
Pending::HostedOpen => self.open_hosted(),
Pending::New => {
self.reset(Project::empty(), None);
@@ -2915,7 +3010,16 @@ impl App {
Task::none()
}
Pending::Open => self.choose_file(FileOperation::Open),
Pending::Close(id) => window::close(id),
Pending::Close(id) => {
self.retire_checkpoint();
if self.recovery.writing {
self.checkpoint_close = Some(id);
self.busy = true;
Task::none()
} else {
window::close(id)
}
}
}
}
fn choose_file(&mut self, operation: FileOperation) -> Task<Message> {
@@ -2990,6 +3094,25 @@ impl App {
)
}
fn save_to(&mut self, path: PathBuf) -> Task<Message> {
if !path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
{
let result = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| "Choose a portable .arcadia entry file name.".to_owned())
.and_then(|entry| {
self.document
.check_save_entry(entry)
.map_err(|e| e.to_string())
});
if let Err(error) = result {
self.status = error;
self.continue_after_save = false;
return Task::none();
}
}
self.busy = true;
let snapshot = self.document.snapshot();
let document = self.canvas_document;
@@ -3084,6 +3207,9 @@ impl App {
}
self.pending_recovery = pending_recovery;
self.disk_project = Some(disk);
if self.source_workspace.applied {
self.remember_local();
}
}
fn prune_closed_files(&mut self, prune_expectations: bool) {
@@ -3731,6 +3857,10 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
))
}
#[cfg(test)]
#[path = "app/save_as_tests.rs"]
mod save_as_tests;
#[cfg(test)]
mod source_project_tests {
use super::*;
+479
View File
@@ -0,0 +1,479 @@
//! Home navigation and asynchronous private checkpoints share the normal guards.
use super::*;
use crate::{home, recovery};
impl App {
pub(super) fn remember_local(&mut self) {
if let Some(path) = self.path.clone() {
let name = self.project().name.clone();
if let Err(error) = self.home.remember(&path, &name) {
self.home.notice = Some(error);
}
}
}
pub(super) fn home_event(&mut self, event: home::Event) -> Task<Message> {
match event {
home::Event::New => self.handle(Message::Request(Pending::New)),
home::Event::Open => self.handle(Message::Request(Pending::Open)),
home::Event::Example => self.handle(Message::Request(Pending::Demo)),
home::Event::Guide => self.update(Message::Page(Page::Guide)),
home::Event::Resume => self.update(Message::Page(self.last_workspace_page)),
home::Event::OpenRecent(path) => {
self.recent_target = Some(path);
self.handle(Message::Request(Pending::Recent))
}
home::Event::Recover(id) => self.handle(Message::Request(Pending::Recover(id))),
home::Event::Forget(path) => {
if let Err(error) = self.home.forget(&path) {
self.home.notice = Some(error);
}
Task::none()
}
home::Event::Refresh => {
self.home.refresh();
self.recovery.refresh();
self.recovery.entries.retain(|entry| {
entry.id != self.canvas_document && Some(entry.id) != self.recovery.restored
});
Task::none()
}
}
}
pub(super) fn checkpoint_tick(&mut self) -> Task<Message> {
if self.recovery.writing || self.busy {
return Task::none();
}
if !self.dirty() {
self.retire_checkpoint();
return Task::none();
}
let Some(directory) = self.recovery.directory.clone() else {
return Task::none();
};
let id = self.canvas_document;
let snapshot = match recovery::Snapshot::capture(
id,
&self.document,
&self.source_workspace,
self.draft.as_ref(),
&self.behavior,
self.path.clone(),
self.selected,
)
.and_then(|snapshot| snapshot.with_workflow(self.workflow.as_ref()))
{
Ok(snapshot) => snapshot,
Err(error) => {
self.recovery.warning = Some(error);
return Task::none();
}
};
self.recovery.writing = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || recovery::write(&directory, &snapshot))
.await
.map_err(|e| e.to_string())?
},
move |result| Message::CheckpointWritten(id, result),
)
}
pub(super) fn checkpoint_written(&mut self, id: Id, result: Result<(), String>) {
self.recovery.writing = false;
match result {
Ok(()) if id == self.canvas_document && self.dirty() => {
self.recovery.active = Some(id);
}
Ok(()) => {
if let Some(directory) = &self.recovery.directory
&& let Err(error) = recovery::remove(directory, id)
{
self.recovery.warning = Some(error);
}
}
Err(error) => {
self.recovery.warning = Some(format!(
"Recovery checkpoint failed: {error}. Save your work explicitly."
))
}
}
}
pub(super) fn retire_checkpoint(&mut self) {
let Some(directory) = &self.recovery.directory else {
return;
};
for id in [self.recovery.active.take(), self.recovery.restored.take()]
.into_iter()
.flatten()
{
if let Err(error) = recovery::remove(directory, id) {
self.recovery.warning = Some(error);
} else {
self.recovery.entries.retain(|e| e.id != id);
}
}
}
pub(super) fn load_checkpoint(&mut self, id: Id) -> Task<Message> {
let Some(directory) = self.recovery.directory.clone() else {
return Task::none();
};
self.busy = true;
Task::perform(
async move {
tokio::task::spawn_blocking(move || recovery::read(&directory, id).map(Arc::new))
.await
.map_err(|e| e.to_string())?
},
Message::CheckpointLoaded,
)
}
pub(super) fn checkpoint_loaded(
&mut self,
result: Result<Arc<recovery::Snapshot>, String>,
) -> Task<Message> {
self.busy = false;
let snapshot = match result {
Ok(s) => s,
Err(error) => {
self.status = error;
return Task::none();
}
};
let id = snapshot.id;
let restored = match (*snapshot).clone().restore() {
Ok(r) => r,
Err(error) => {
self.status = error;
return Task::none();
}
};
// Fully prepare recovery before touching the currently accepted document.
self.reset(restored.document.project().clone(), None);
self.hosted.leave_document();
self.document = restored.document;
self.selected = if self.project().layer_of(restored.selected).is_some()
|| restored.selected == self.project().id
{
restored.selected
} else {
self.project().id
};
// Installing the raw workspace must follow all accepted-source synchronization.
self.source_workspace = restored.workspace;
self.show_source_file(false);
self.refresh_draft();
if let Some(draft) = restored.property {
self.selected = draft.original;
self.exact_strings = draft.exact_strings;
self.draft = Some(draft);
}
self.behavior = restored.behavior;
self.workflow = restored.workflow;
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
if let Some(layer) = self.project().layer_of(focus) {
self.layer = layer;
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
self.active_diagram = self.diagram_for(layer, focus);
}
self.recovery.restored = Some(id);
self.recovery.entries.retain(|entry| entry.id != id);
self.page = if self.behavior.has_pending() {
Page::Behavior
} else if self.source_dirty() {
Page::Text
} else {
Page::Diagram
};
self.status="Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into();
self.request_lint()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn home_resume_returns_to_each_actual_workspace_without_model_edits() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
for page in [
Page::Diagram,
Page::Catalog,
Page::Behavior,
Page::Data,
Page::Traces,
Page::Text,
Page::Validation,
Page::Guide,
] {
let _ = app.update(Message::Page(page));
assert_eq!(app.page, page);
let selected = app.selected;
let _ = app.update(Message::Page(Page::Home));
assert_eq!(app.last_workspace_page, page);
// Repeated Home events must not replace the remembered workspace.
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, page);
assert_eq!(app.selected, selected);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn home_resume_keeps_state_view_while_explicit_architecture_remains_reachable() {
let mut project = Project::empty();
let owner = project
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = project
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner))
.unwrap();
let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap();
project = prepare_command(&project, &plan.command).unwrap().project;
let mut app = App::with_project(project);
assert_eq!(app.page, Page::Behavior);
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, plan.diagram);
assert_eq!(app.behavior.active_owner, Some(machine));
let _ = app.update(Message::Page(Page::Diagram));
assert_eq!(app.page, Page::Diagram);
assert!(app.active_diagram.is_none());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Diagram);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn home_resume_retains_invalid_source_and_property_drafts() {
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Text));
let raw = format!("{}\nunfinished draft", app.source_workspace.text());
app.source_workspace
.edit(raw.clone(), app.source_editor.cursor());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Text);
assert_eq!(app.source_workspace.text(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let mut app = App::with_project(Project::demo());
let accepted = app.document.snapshot();
let _ = app.update(Message::Page(Page::Data));
let _ = app.update(Message::Field("/id".into(), "-".into()));
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert!(app.draft_dirty());
let _ = app.update(Message::Page(Page::Home));
let _ = app.update(Message::Home(home::Event::Resume));
assert_eq!(app.page, Page::Data);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
fn retained_snapshot(app: &App) -> recovery::Snapshot {
recovery::Snapshot::capture(
app.canvas_document,
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
app.path.clone(),
app.selected,
)
.unwrap()
}
#[test]
fn saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().join("recovery"));
let id = app.project().id;
app.execute(Command::Rename(id, "Saved milestone".into()));
let path = directory.path().join(&app.document.bundle().entry);
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path,
None,
)
.unwrap();
app.execute(Command::Rename(id, "Later unsaved engineering".into()));
let snapshot = retained_snapshot(&app);
let recovery_directory = app.recovery.directory.clone().unwrap();
recovery::write(&recovery_directory, &snapshot).unwrap();
app.recovery.active = Some(app.canvas_document);
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(app.dirty());
assert_eq!(app.project().name, "Later unsaved engineering");
assert_eq!(app.recovery.active, Some(app.canvas_document));
let restored = recovery::read(&recovery_directory, app.canvas_document)
.unwrap()
.restore()
.unwrap();
assert_eq!(
restored.document.project().name,
"Later unsaved engineering"
);
}
#[test]
fn checkpoint_completion_releases_the_writer_while_a_context_menu_is_open() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
let id = app.project().id;
app.execute(Command::Rename(id, "Unsaved engineering".into()));
let snapshot = retained_snapshot(&app);
recovery::write(directory.path(), &snapshot).unwrap();
app.recovery.writing = true;
let _ = app.update(Message::Menu(crate::model_menu::Event::Open(
crate::model_menu::Request::object(id, iced::Point::ORIGIN),
)));
assert!(app.model_menu.is_some());
let _ = app.update(Message::CheckpointWritten(app.canvas_document, Ok(())));
assert!(
!app.recovery.writing,
"A passive completion cannot be swallowed by an open menu"
);
assert_eq!(app.recovery.active, Some(app.canvas_document));
assert!(
app.model_menu.is_some(),
"Saving a checkpoint does not dismiss the menu"
);
assert_eq!(app.project().name, "Unsaved engineering");
}
#[test]
fn discard_and_close_waits_for_the_late_checkpoint_then_retires_it() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
app.execute(Command::Rename(
app.project().id,
"Discarded on close".into(),
));
let snapshot = retained_snapshot(&app);
let document = app.canvas_document;
app.recovery.writing = true;
let window = window::Id::unique();
let _ = app.update(Message::Request(Pending::Close(window)));
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == window));
let closing = app.update(Message::Discard);
assert!(
iced_runtime::task::into_stream(closing).is_none(),
"The window must remain until its outstanding writer finishes"
);
assert_eq!(app.checkpoint_close, Some(window));
assert!(app.busy);
recovery::write(directory.path(), &snapshot).unwrap();
assert!(recovery::read(directory.path(), document).is_ok());
let close = app.update(Message::CheckpointWritten(document, Ok(())));
assert!(
iced_runtime::task::into_stream(close).is_some(),
"Close is emitted after checkpoint cleanup"
);
assert!(app.checkpoint_close.is_none());
assert!(recovery::read(directory.path(), document).is_err());
assert!(app.recovery.active.is_none());
}
#[test]
fn recent_and_recovery_navigation_respect_unsaved_decision() {
let mut app = App::with_project(Project::demo());
let id = app.project().id;
app.execute(Command::Rename(id, "Retained work".into()));
let _ = app.update(Message::Home(home::Event::OpenRecent(PathBuf::from(
"missing.arcadia",
))));
assert!(matches!(app.pending, Some(Pending::Recent)));
assert!(!app.busy);
assert_eq!(app.project().name, "Retained work");
let _ = app.update(Message::Cancel);
let recovery_id = Id::new_v4();
let _ = app.update(Message::Home(home::Event::Recover(recovery_id)));
assert!(matches!(app.pending,Some(Pending::Recover(id)) if id==recovery_id));
assert!(!app.busy);
}
#[test]
fn restored_copy_keeps_raw_property_and_requires_save_without_local_or_hosted_binding() {
let mut app = App::with_project(Project::demo());
let selected = app.project().id;
app.draft
.as_mut()
.unwrap()
.input("/name", "Not yet applied".into());
let snapshot = recovery::Snapshot::capture(
Id::new_v4(),
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
Some(PathBuf::from("original.arcadia")),
selected,
)
.unwrap();
let checkpoint_id = snapshot.id;
app.recovery.entries.push(recovery::Entry {
id: checkpoint_id,
name: "Retained work".into(),
origin: None,
modified: snapshot.modified,
});
let _ = app.checkpoint_loaded(Ok(Arc::new(snapshot)));
assert!(app.path.is_none());
assert!(app.disk_project.is_none());
assert!(app.hosted.session.is_none());
assert!(app.document.dirty());
assert!(app.draft_dirty());
assert_eq!(app.recovery.restored, Some(checkpoint_id));
assert!(
app.recovery.entries.is_empty(),
"Home must not offer the copy currently being recovered again"
);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Not yet applied"
);
assert_ne!(app.project().name, "Not yet applied");
}
#[test]
fn stale_write_completion_cannot_recover_a_discarded_document() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::demo());
app.recovery.directory = Some(directory.path().into());
let old = app.canvas_document;
let s = recovery::Snapshot::capture(
old,
&app.document,
&app.source_workspace,
None,
&app.behavior,
None,
app.selected,
)
.unwrap();
app.reset(Project::empty(), None);
recovery::write(directory.path(), &s).unwrap();
app.checkpoint_written(old, Ok(()));
assert!(recovery::read(directory.path(), old).is_err());
assert!(app.recovery.active.is_none());
}
#[test]
fn malformed_checkpoint_does_not_replace_the_current_model() {
let mut app = App::with_project(Project::demo());
let original = app.document.bundle().clone();
let _ = app.checkpoint_loaded(Err("Damaged checkpoint".into()));
assert_eq!(app.document.bundle(), &original);
assert!(app.status.contains("Damaged"));
}
}
+114
View File
@@ -0,0 +1,114 @@
use super::*;
fn complete_save(app: &mut App, path: PathBuf) {
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path,
app.disk_project.clone(),
)
.unwrap();
let _ = app.update(Message::Saved(Ok(Some(receipt))));
}
#[test]
fn save_as_receipt_then_one_undo_restores_meaning_and_saving_preserves_redo() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("semantic-accepted.arcadia");
let mut app = App::with_project(Project::empty());
app.document.mark_recovered();
let original = app.document.bundle().files[&app.document.bundle().entry].clone();
app.execute(Command::Rename(
app.project().id,
"Accepted semantic change".into(),
));
let after = app.document.bundle().files[&app.document.bundle().entry].clone();
complete_save(&mut app, path.clone());
assert!(!app.dirty());
assert_eq!(app.path.as_ref(), Some(&path));
assert_eq!(std::fs::read_to_string(&path).unwrap(), after);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle().entry, "semantic-accepted.arcadia");
assert_eq!(
app.document.bundle().files[&app.document.bundle().entry],
original
);
assert!(
!app.document.can_undo(),
"Save as did not insert a history step"
);
assert!(app.document.can_redo());
complete_save(&mut app, path.clone());
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
assert!(
app.document.can_redo(),
"Saving an undone state preserves the future"
);
let _ = app.update(Message::Redo);
assert_eq!(
app.document.bundle().files[&app.document.bundle().entry],
after
);
assert!(app.dirty());
assert_eq!(
source_project::compile_bundle(app.document.bundle(), LIMITS)
.unwrap()
.decoded
.project,
*app.project()
);
}
#[test]
fn save_as_preflight_rejects_historical_collision_before_starting_io() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("taken.arcadia");
let mut app = App::with_project(Project::empty());
let base = app.document.bundle().clone();
let mut included = base.clone();
let text = included.files.get_mut(&included.entry).unwrap();
text.insert_str(text.rfind('}').unwrap(), "\n include \"taken.arcadia\";\n");
included
.files
.insert("taken.arcadia".into(), "// retained\n".into());
app.document.apply_bundle(included).unwrap();
app.document.apply_bundle(base).unwrap();
app.sync_source();
let before = app.document.snapshot();
let task = app.save_to(path.clone());
assert!(iced_runtime::task::into_stream(task).is_none());
assert!(!path.exists());
assert!(!app.busy);
assert!(app.status.contains("Undo/Redo"));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
let json = app.save_to(directory.path().join("taken.json"));
assert!(
iced_runtime::task::into_stream(json).is_some(),
"JSON exports do not relocate source entries"
);
}
#[test]
fn save_as_late_receipt_preserves_newer_raw_source_draft_and_destination() {
let directory = tempfile::tempdir().unwrap();
let mut app = App::with_project(Project::empty());
let entry = app.document.bundle().entry.clone();
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
directory.path().join("accepted.arcadia"),
None,
)
.unwrap();
let raw = format!("{}\nunfinished {{", app.source_workspace.text());
let cursor = app.source_workspace.files[&entry].cursor;
app.source_workspace.edit(raw.clone(), cursor);
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert_eq!(app.source_workspace.text(), raw);
assert_eq!(app.document.bundle().entry, entry);
assert!(app.path.is_none());
assert!(app.dirty());
assert!(app.status.contains("Newer edits"));
}
@@ -6,6 +6,41 @@ use crate::behavior::{
};
impl App {
/// A saved state view has one renderer and tool set: the behavior workspace.
/// Run after navigation/adoption, never as a model or property edit. Keeping
/// selection and its Draft intact also preserves unrelated raw properties.
pub(super) fn route_state_diagram_workspace(&mut self) {
if self.page != Page::Diagram {
return;
}
let Some((diagram, machine, layer)) = self.active_diagram.and_then(|id| {
let view = self.project().diagrams.get(&id)?;
Some((id, view.projection.state_machine()?, view.layer))
}) else {
return;
};
// Existing navigation guards own pending behavior forms. Never replace
// their owner/camera if an exceptional caller leaves one on another page.
if self.behavior.has_pending() {
return;
}
self.layer = layer;
self.page = Page::Behavior;
self.behavior.active_owner = Some(machine);
self.behavior.diagram = behavior::state_view::State {
diagram: Some(diagram),
placement: self.project().diagrams[&diagram]
.nodes
.values()
.find(|node| node.id == self.selected || node.element == self.selected)
.map(|node| node.id),
..Default::default()
};
self.selected_node = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.fit_state_diagram();
}
/// The behavior view resolved independently of the current page. Capture
/// this before selection/layer changes, while it still identifies the view
/// whose camera and tool are retained during navigation.
@@ -89,6 +89,153 @@ fn assert_source_model_agree(app: &App) {
assert!(!app.source_dirty());
}
#[test]
fn state_diagram_routes_constructor_and_source_open_to_the_existing_workspace_without_edits() {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let project = f.app.project().clone();
let app = App::with_project(project.clone());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, diagram);
assert_eq!(behavior::selected_owner(&app), Some(f.machine));
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert_eq!(app.selected, project.id);
assert_eq!(app.project(), &project);
assert!(!app.document.can_undo());
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("state-view.arcadia");
let text = format!(
"// Exact authored source must remain unchanged.\n{}",
language::format(&project)
);
std::fs::write(&path, &text).unwrap();
let mut reopened = App::with_project(Project::empty());
send(
&mut reopened,
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
);
assert_eq!(reopened.page, Page::Behavior);
assert_eq!(reopened.state_context().unwrap().diagram, diagram);
assert_eq!(reopened.project(), &project);
assert_eq!(
reopened.document.bundle().files[&reopened.document.bundle().entry],
text
);
assert_eq!(std::fs::read_to_string(path).unwrap(), text);
assert!(!reopened.document.can_undo());
assert_source_model_agree(&reopened);
}
#[test]
fn state_diagram_routes_picker_and_reveal_but_explicit_architecture_does_not_bounce() {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let second = state_edit::create_view(&project, f.machine).unwrap();
project = prepare_command(&project, &second.command).unwrap().project;
let transition = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
let mut app = App::with_project(project.clone());
let before = app.document.snapshot();
send(&mut app, Message::Page(Page::Diagram));
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, None);
send(
&mut app,
Message::Diagram(Choice {
id: second.diagram,
label: "Second state view".into(),
}),
);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, second.diagram);
send(&mut app, Message::RevealInDiagram(diagram, transition));
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, diagram);
assert_eq!(app.selected, transition);
assert_eq!(app.draft.as_ref().unwrap().original, transition);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_source_model_agree(&app);
let architecture = project.ensure_diagram(Layer::Sa);
let mut app = App::with_project(project);
send(
&mut app,
Message::Diagram(Choice {
id: diagram,
label: "State view".into(),
}),
);
assert_eq!(app.page, Page::Behavior);
send(&mut app, Message::Page(Page::Diagram));
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, Some(architecture));
assert!(app.state_context().is_none());
assert!(!app.document.can_undo());
}
#[test]
fn state_diagram_routes_preserve_selected_raw_properties_and_pending_behavior_guards() {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut app = App::with_project(f.app.project().clone());
send(&mut app, Message::Select(f.a));
send(
&mut app,
Message::Field("/alias".into(), "invalid alias!".into()),
);
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert!(!app.apply());
let before = app.document.snapshot();
// Exercise normalization itself with an existing raw draft, including callers
// that have just adopted a view without a successful property Apply.
app.page = Page::Diagram;
app.route_state_diagram_workspace();
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, f.a);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
send(
&mut app,
Message::Diagram(Choice {
id: diagram,
label: "State view".into(),
}),
);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
let mut app = App::with_project(f.app.project().clone());
form(
&mut app,
states::Event::Start(f.machine, states::Tool::Transition, f.a),
);
form(
&mut app,
states::Event::Name("Pending behavior stays".into()),
);
let pending = app.behavior.recovery_snapshot().unwrap();
let context = app.state_context().unwrap();
send(&mut app, Message::Page(Page::Diagram));
send(
&mut app,
Message::Diagram(Choice {
id: diagram,
label: "State view".into(),
}),
);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.state_context().unwrap().diagram, context.diagram);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), pending);
assert!(!app.document.can_undo());
}
struct NavigationFixture {
app: App,
machine_a: Id,
@@ -789,12 +936,15 @@ fn fallback_owner_first_selection_keeps_the_captured_context_for_its_first_move(
let f = fixture();
let mut project = f.app.project().clone();
// One eligible machine makes the fallback deterministic. Start from a loaded
// project with no selected behavior element and no explicitly chosen owner.
// architecture view with no selected behavior element or chosen owner. A
// state-only project now opens its saved view with an explicit owner.
project
.elements
.retain(|id, element| f.members.contains(id) || element.kind() == Kind::System);
let plan = state_edit::create_view(&project, f.machine).unwrap();
let project = prepare_command(&project, &plan.command).unwrap().project;
let mut project = prepare_command(&project, &plan.command).unwrap().project;
let architecture = project.ensure_diagram(Layer::Sa);
project.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
let mut app = App::with_project(project);
assert_eq!(app.selected, app.project().id);
assert!(app.behavior.active_owner.is_none());
@@ -917,7 +1067,11 @@ fn shared_semantic_states_keep_architecture_and_behavior_placements_independent(
id: architecture,
label: app.project().named(architecture).into(),
};
// The initial UUID-ordered view may be a state view, which correctly opens
// Behavior. Enter Architecture explicitly before testing its selection.
send(&mut app, Message::Page(Page::Diagram));
send(&mut app, Message::Diagram(architecture_choice));
assert_eq!(app.page, Page::Diagram);
send(&mut app, Message::Select(f.b));
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(app.selected_node, Some(arch_b));
+206
View File
@@ -556,7 +556,72 @@ pub struct State {
states: states::State,
}
const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another.";
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RecoveryDraft {
version: u32,
kind: RecoveryKind,
draft: serde_json::Value,
}
#[derive(serde::Serialize, serde::Deserialize)]
enum RecoveryKind {
State,
Scenario,
Process,
}
impl State {
/// Capture only the pending authored form. Pointer gestures, cached choices,
/// viewport geometry and document/revision tokens never cross a restart.
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
let drafts = [
(RecoveryKind::State, self.states.recovery_snapshot()?),
(RecoveryKind::Scenario, self.scenario.recovery_snapshot()?),
(RecoveryKind::Process, self.topology.recovery_snapshot()?),
];
let mut drafts = drafts.into_iter().filter_map(|(kind, draft)| {
draft.map(|draft| RecoveryDraft {
version: 1,
kind,
draft,
})
});
let first = drafts.next();
if drafts.next().is_some() {
return Err(
"Several behavior forms are pending; recovery cannot choose one to discard.".into(),
);
}
first
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
/// Restore raw fields without planning/executing them. Rejected values and
/// incomplete endpoints must remain available for repair after a crash.
pub fn restore_recovery(&mut self, value: serde_json::Value) -> Result<(), String> {
let saved: RecoveryDraft = serde_json::from_value(value).map_err(|e| e.to_string())?;
if saved.version != 1 {
return Err(format!(
"Unsupported behavior recovery version {}",
saved.version
));
}
let mut restored = Self::default();
match saved.kind {
RecoveryKind::State => restored.states = states::State::from_recovery(saved.draft)?,
RecoveryKind::Scenario => {
restored.scenario = scenario::State::from_recovery(saved.draft)?
}
RecoveryKind::Process => {
restored.topology = topology::State::from_recovery(saved.draft)?
}
}
restored.active_owner = restored.pending_owner();
*self = restored;
Ok(())
}
pub fn has_pending(&self) -> bool {
self.pending_owner().is_some()
}
@@ -2352,6 +2417,147 @@ mod direct_canvas_tests {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recovery_retains_each_incomplete_behavior_form_without_executing_it() {
let p = Project::demo();
let before = p.clone();
let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id;
let machine = owner(Kind::StateMachine);
let scenario = owner(Kind::Scenario);
let process = owner(Kind::Process);
let cases = [
(
machine,
vec![
Event::States(states::Event::Start(
machine,
states::Tool::Transition,
machine,
)),
Event::States(states::Event::Name("Unfinished timeout".into())),
Event::States(states::Event::Timed(true)),
Event::States(states::Event::Expression("-".into())),
Event::States(states::Event::Guard("temperature >".into())),
],
),
(
scenario,
vec![
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)),
Event::Scenario(scenario::Event::Name("Unfinished request".into())),
Event::Scenario(scenario::Event::Expression("-".into())),
],
),
(
process,
vec![
Event::Topology(topology::Event::Start(
process,
topology::Tool::Connect,
None,
)),
Event::Topology(topology::Event::Name("Unfinished path".into())),
Event::Topology(topology::Event::Guard("available &&".into())),
],
),
];
for (owner, events) in cases {
let mut original = State::default();
for event in events {
original.update(&p, event).unwrap();
}
assert!(original.prepare_pending(&p).is_err());
let snapshot = original.recovery_snapshot().unwrap().unwrap();
let bytes = serde_json::to_vec(&snapshot).unwrap();
let mut recovered = State::default();
recovered
.restore_recovery(serde_json::from_slice(&bytes).unwrap())
.unwrap();
assert_eq!(recovered.pending_owner(), Some(owner));
assert_eq!(recovered.active_owner, Some(owner));
assert!(
recovered.prepare_pending(&p).is_err(),
"An incomplete recovered form is not applied or silently repaired"
);
assert_eq!(recovered.recovery_snapshot().unwrap(), Some(snapshot));
assert_eq!(p, before);
recovered.acknowledge_commit();
assert!(recovered.recovery_snapshot().unwrap().is_none());
}
}
#[test]
fn recovery_preserves_distinct_qualified_value_paths_and_rejects_invalid_payload_atomically() {
let p = Project::demo();
let scenario = p
.elements
.values()
.find(|e| e.kind() == Kind::Scenario)
.unwrap()
.id;
let field = Id::new_v4();
let paths = [Id::new_v4(), Id::new_v4()];
let mut state = State::default();
state
.update(
&p,
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)),
)
.unwrap();
for (path, value) in paths.into_iter().zip(["-", "3.4e"]) {
state
.update(
&p,
Event::Scenario(scenario::Event::Value(
crate::data_values::FieldPathKey {
path: vec![path],
field,
},
value.into(),
)),
)
.unwrap();
}
let original = state.recovery_snapshot().unwrap().unwrap();
let mut recovered = State::default();
recovered.restore_recovery(original.clone()).unwrap();
assert_eq!(
recovered.recovery_snapshot().unwrap(),
Some(original.clone())
);
let values = original["draft"]["values"].as_array().unwrap();
assert_eq!(values.len(), 2);
assert_ne!(values[0][0], values[1][0]);
assert_eq!(values[0][1], values[1][1]);
for bad in [
{
let mut value = original.clone();
value["version"] = 99.into();
value
},
{
let mut value = original.clone();
value["draft"]["unknown"] = true.into();
value
},
{
let mut value = original.clone();
value["draft"]["values"]
.as_array_mut()
.unwrap()
.push(values[0].clone());
value
},
] {
assert!(recovered.restore_recovery(bad).is_err());
assert_eq!(
recovered.recovery_snapshot().unwrap(),
Some(original.clone()),
"Malformed recovery cannot discard the currently retained form"
);
}
}
use crate::document::{Document, DocumentError};
use oa_core::source_project::{self, ProjectLimits};
+74 -3
View File
@@ -5,7 +5,7 @@ use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Tool {
Lifeline,
Message,
@@ -26,7 +26,7 @@ impl Tool {
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum MessageStyle {
Asynchronous,
Call,
@@ -160,7 +160,8 @@ struct CallChoiceContext {
before: Option<Id>,
endpoints: Option<(Option<Id>, Option<Id>)>,
}
#[derive(Clone)]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
owner: Id,
parent: Id,
@@ -178,8 +179,11 @@ struct Draft {
second_guard: String,
operator: FragmentOperator,
bindings: BTreeMap<Id, Id>,
#[serde(with = "recovery_values")]
values: BTreeMap<crate::data_values::FieldPathKey, String>,
#[serde(with = "recovery_field_key")]
value_focus: Option<crate::data_values::FieldPathKey>,
#[serde(with = "recovery_field_key")]
value_browse: Option<crate::data_values::FieldPathKey>,
value_browse_page: usize,
value_authored_page: usize,
@@ -187,6 +191,22 @@ struct Draft {
choice_page: usize,
}
impl State {
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
Ok(Self {
draft: Some(draft),
..Default::default()
})
}
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
let index = match area {
ScrollArea::Canvas => 0,
@@ -436,6 +456,57 @@ impl State {
Ok(None)
}
}
mod recovery_values {
use super::*;
use serde::{Deserialize, Serialize};
pub fn serialize<S: serde::Serializer>(
values: &BTreeMap<crate::data_values::FieldPathKey, String>,
serializer: S,
) -> Result<S::Ok, S::Error> {
values
.iter()
.map(|(key, value)| (&key.path, key.field, value))
.collect::<Vec<_>>()
.serialize(serializer)
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<crate::data_values::FieldPathKey, String>, D::Error> {
let values = Vec::<(Vec<Id>, Id, String)>::deserialize(deserializer)?;
let mut result = BTreeMap::new();
for (path, field, value) in values {
if result
.insert(crate::data_values::FieldPathKey { path, field }, value)
.is_some()
{
return Err(serde::de::Error::custom(
"Duplicate recovered scenario value path",
));
}
}
Ok(result)
}
}
mod recovery_field_key {
use super::*;
use serde::{Deserialize, Serialize};
pub fn serialize<S: serde::Serializer>(
key: &Option<crate::data_values::FieldPathKey>,
serializer: S,
) -> Result<S::Ok, S::Error> {
key.as_ref()
.map(|key| (&key.path, key.field))
.serialize(serializer)
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<crate::data_values::FieldPathKey>, D::Error> {
Ok(Option::<(Vec<Id>, Id)>::deserialize(deserializer)?
.map(|(path, field)| crate::data_values::FieldPathKey { path, field }))
}
}
fn viewpoint(p: &Project, owner: Id) -> ScenarioViewpoint {
match p.elements.get(&owner).and_then(|e| e.shared()) {
Some(SharedBody::Scenario { viewpoint, .. }) => *viewpoint,
+190 -5
View File
@@ -99,6 +99,7 @@ struct PaintKey {
enum Hit {
Node { placement: Id, element: Id },
Transition(Id),
StateAction(Id),
}
impl State {
@@ -357,6 +358,63 @@ fn internal_rows<'a>(project: &'a Project, node: &ProjectedNode) -> Vec<(&'a Rel
.collect()
}
/// These rows represent maintained StateAction relations, not containment of
/// the referenced work. Their identity remains selectable independently.
fn state_action_rows<'a>(
project: &'a Project,
node: &ProjectedNode,
) -> Vec<(&'a Relation, Rectangle)> {
if node.collapsed
|| !project
.elements
.get(&node.element)
.is_some_and(|e| e.kind().is_stable_state())
{
return vec![];
}
let mut actions: Vec<_> = project.relations.values().filter(|r| matches!(r.body, RelationBody::StateAction { source, .. } if source == node.element)).collect();
actions.sort_by_key(|r| match r.body {
RelationBody::StateAction { phase, .. } => (
match phase {
StateActionKind::Entry => 0,
StateActionKind::Do => 1,
StateActionKind::Exit => 2,
},
r.id,
),
_ => unreachable!(),
});
let internal_height = internal_rows(project, node).len() as f32 * 56.;
let top = node.position.y + projection::state_header_height(project, node.element)
- internal_height
- actions.len() as f32 * 28.;
actions
.into_iter()
.enumerate()
.map(|(index, relation)| {
(
relation,
Rectangle::new(
Point::new(node.position.x + 8., top + index as f32 * 28.),
Size::new((node.size.width - 16.).max(1.), 26.),
),
)
})
.collect()
}
fn state_action_label(project: &Project, relation: &Relation) -> String {
let RelationBody::StateAction { target, phase, .. } = relation.body else {
return String::new();
};
let phase = match phase {
StateActionKind::Entry => "entry",
StateActionKind::Do => "do",
StateActionKind::Exit => "exit",
};
format!("{phase} / {}", project.named(target))
}
impl StateCanvas<'_> {
fn scale(&self) -> f32 {
if self.zoom.is_finite() {
@@ -486,6 +544,12 @@ impl StateCanvas<'_> {
fn hit(&self, scene: &Scene, world: Point) -> Option<Hit> {
let node = self.node_at(scene, world);
if let Some(node) = node {
if let Some((relation, _)) = state_action_rows(self.project, node)
.into_iter()
.find(|(_, r)| r.contains(world))
{
return Some(Hit::StateAction(relation.id));
}
if let Some((relation, _)) = internal_rows(self.project, node)
.into_iter()
.find(|(_, r)| r.contains(world))
@@ -643,9 +707,11 @@ impl StateCanvas<'_> {
},
None => "Draw transition · click a source, then a compatible target, or drag between vertices".into(),
},
_ => if let Some(Hit::Transition(id)) = world.and_then(|p| self.hit(scene, p)) {
self.project.relations.get(&id).map(|r| format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))).unwrap_or_default()
} else { "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into() },
_ => match world.and_then(|p| self.hit(scene, p)) {
Some(Hit::Transition(id)) => self.project.relations.get(&id).map(|r| format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))).unwrap_or_default(),
Some(Hit::StateAction(id)) => self.project.relations.get(&id).map(|r| format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r))).unwrap_or_default(),
_ => "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into(),
},
}
}
}
@@ -754,7 +820,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
state.reset();
let (object, placement) = match self.hit(&scene, world) {
Some(Hit::Node { element, placement }) => (element, Some(placement)),
Some(Hit::Transition(id)) => (id, None),
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
None => (self.diagram, None),
};
return Some(
@@ -799,7 +865,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
.and_capture(),
)
}
Some(Hit::Transition(id)) => {
Some(Hit::Transition(id) | Hit::StateAction(id)) => {
Some(Action::publish(Event::Select(self.context, id)).and_capture())
}
None => {
@@ -1378,6 +1444,22 @@ fn paint_node(
);
}
}
for (relation, row) in state_action_rows(project, node) {
if relation.id == selected {
frame.fill_rectangle(row.position(), row.size(), Color::from_rgb8(224, 247, 242));
}
label_text(
frame,
short(&state_action_label(project, relation), row.width - 12., 11.),
row.position() + Vector::new(6., 5.),
11.,
if relation.id == selected {
style::TEAL
} else {
style::MUTED
},
);
}
for (relation, row) in internal_rows(project, node) {
if relation.id == selected {
frame.fill_rectangle(row.position(), row.size(), Color::from_rgb8(224, 247, 242));
@@ -2012,6 +2094,109 @@ mod tests {
assert_eq!(accepted.relations[&relation].target(), f.c);
}
#[test]
fn typed_state_actions_are_named_selectable_rows_before_internal_transitions_and_regions() {
let mut f = fixture();
let mut action_ids = vec![];
for (phase, name) in [
(StateActionKind::Exit, "Release resources"),
(StateActionKind::Entry, "Restore settings"),
(StateActionKind::Do, "Monitor pressure"),
] {
let work = f
.project
.add_element(Kind::Function, Layer::Sa, name, None)
.unwrap();
let id = f
.project
.relate(RelationKind::StateAction, "Action link", f.a, work);
if let RelationBody::StateAction { phase: value, .. } =
&mut f.project.relations.get_mut(&id).unwrap().body
{
*value = phase;
}
action_ids.push(id);
}
if let Some(SharedBody::State { entry, .. }) =
f.project.elements.get_mut(&f.a).unwrap().shared_mut()
{
*entry = "Authored entry notes".into();
}
let internal = f
.project
.relate(RelationKind::StateTransition, "Update status", f.a, f.a);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&internal).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
let before = f.project.clone();
let canvas = canvas(&f, Tool::Select, 0.75);
let scene = canvas.scene(&State::default()).unwrap();
let a = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
let rows = state_action_rows(&f.project, a);
assert_eq!(
rows.iter()
.map(|(r, _)| state_action_label(&f.project, r))
.collect::<Vec<_>>(),
[
"entry / Restore settings",
"do / Monitor pressure",
"exit / Release resources"
]
);
assert_eq!(
projection::state_header_height(&f.project, f.a),
64. + 28. + 3. * 28. + 56.
);
let internal_top = internal_rows(&f.project, a)[0].1.y;
assert!(rows[0].1.y >= a.position.y + 92.);
assert!(rows.last().unwrap().1.y + 28. <= internal_top);
let child_region = scene
.view
.nodes
.iter()
.find(|n| n.visual_parent == Some(a.id))
.unwrap();
assert!(
child_region.position.y
>= a.position.y + projection::state_header_height(&f.project, f.a)
);
for (relation, row) in rows {
assert_eq!(
canvas.hit(&scene, row.center()),
Some(Hit::StateAction(relation.id))
);
let events = click(&canvas, &mut State::default(), row.center());
assert!(
matches!(events.as_slice(), [Event::Select(context, id)] if *context == canvas.context && *id == relation.id),
"Clicking a typed action row must select its relation in this view: {events:?}"
);
assert!(
!scene
.view
.nodes
.iter()
.any(|n| n.element == relation.target())
);
assert!(!scene.routed.edges.contains_key(&relation.id));
}
let mut collapsed = a.clone();
collapsed.collapsed = true;
assert!(state_action_rows(&f.project, &collapsed).is_empty());
assert_eq!(
f.project, before,
"Reading and selecting action rows cannot mutate work or ownership"
);
let target = f.project.relations[&action_ids[1]].target();
f.project.elements.get_mut(&target).unwrap().name = "Reload settings".into();
assert_eq!(
state_action_label(&f.project, &f.project.relations[&action_ids[1]]),
"entry / Reload settings"
);
}
#[test]
fn internal_rows_and_pseudostate_hits_use_shared_persisted_geometry() {
let mut f = fixture();
+16 -1
View File
@@ -2,7 +2,7 @@
use super::*;
use iced::widget::{checkbox, column, text_input};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Tool {
Vertex,
Region,
@@ -46,6 +46,8 @@ pub enum Event {
pub struct State {
draft: Option<Draft>,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
machine: Id,
tool: Tool,
@@ -184,6 +186,19 @@ impl Draft {
}
}
impl State {
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
Ok(Self { draft: Some(draft) })
}
pub(super) fn pending_owner(&self) -> Option<Id> {
self.draft.as_ref().map(|d| d.machine)
}
+39 -2
View File
@@ -12,7 +12,7 @@ pub(crate) use direct::{
#[cfg(test)]
pub(crate) mod direct_tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Tool {
Work,
Exchange,
@@ -76,12 +76,16 @@ pub struct State {
zoom: Option<f32>,
fit: bool,
}
#[derive(Default)]
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Browser {
search: String,
page: usize,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
#[serde(with = "recovery_insertion")]
insertion: Insertion,
fixed: bool,
browsers: [Browser; 4],
@@ -97,6 +101,22 @@ struct Draft {
control: ControlKind,
}
impl State {
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
Ok(Self {
draft: Some(draft),
..Default::default()
})
}
pub(super) fn pending_owner(&self) -> Option<Id> {
self.draft.as_ref().map(|d| d.owner)
}
@@ -198,6 +218,23 @@ impl State {
.clamp(0.05, 1.)
}
}
mod recovery_insertion {
use super::*;
use serde::{Deserialize, Serialize};
pub fn serialize<S: serde::Serializer>(
value: &Insertion,
serializer: S,
) -> Result<S::Ok, S::Error> {
(value.link, value.before).serialize(serializer)
}
pub fn deserialize<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Insertion, D::Error> {
let (link, before) = <(Option<Id>, Option<Id>)>::deserialize(deserializer)?;
Ok(Insertion { link, before })
}
}
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.parent == Some(owner)
+132 -1
View File
@@ -15,6 +15,12 @@ struct HistoryEntry {
bytes: usize,
}
struct RelocatedHistory {
current: Arc<CompiledSourceProject>,
past: Vec<HistoryEntry>,
future: Vec<HistoryEntry>,
}
fn estimated_bytes(compiled: &CompiledSourceProject) -> usize {
compiled
.bundle
@@ -92,6 +98,7 @@ pub struct Document {
saved: Arc<SourceBundle>,
revision: u64,
limits: ProjectLimits,
recovered_unsaved: bool,
}
impl Document {
@@ -120,6 +127,7 @@ impl Document {
saved: Arc::new(bundle),
revision: 0,
limits,
recovered_unsaved: false,
})
}
@@ -140,7 +148,12 @@ impl Document {
}
pub fn dirty(&self) -> bool {
self.bundle() != self.saved.as_ref()
self.recovered_unsaved || self.bundle() != self.saved.as_ref()
}
/// Recovered work is a detached copy until explicitly saved by its user.
pub fn mark_recovered(&mut self) {
self.recovered_unsaved = true;
}
pub fn can_undo(&self) -> bool {
@@ -161,6 +174,119 @@ impl Document {
/// The I/O adapter owns document identity and save completion ordering.
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
self.saved = Arc::new(snapshot.bundle.clone());
self.recovered_unsaved = false;
}
/// Check a source Save as before writing any files. A retained included file
/// must never be displaced by the new entry, including on a later Undo.
pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> {
if entry == self.bundle().entry {
return Ok(());
}
self.relocated_history(entry).map(|_| ())
}
/// Adopt a successfully saved source entry without recording an engineering
/// edit or clearing Redo. The caller still marks the exact receipt as saved.
pub fn adopt_saved_entry(
&mut self,
saved: &CompiledSourceProject,
revision: u64,
) -> Result<(), DocumentError> {
if revision != self.revision {
return Err(
ModelError("Newer edits retain the current source location.".into()).into(),
);
}
let RelocatedHistory {
current,
past,
future,
} = self.relocated_history(&saved.bundle.entry)?;
if current.bundle != saved.bundle
|| current.decoded.project != saved.decoded.project
|| current.index != saved.index
{
return Err(ModelError(
"The save receipt changes more than the source entry location.".into(),
)
.into());
}
if current.bundle != self.current.bundle {
self.current_bytes = estimated_bytes(&current);
self.current = current;
self.past = past;
self.future = future;
// Source-position readers must reject their old revision even though
// changing the save location does not create an Undo transaction.
self.revision = self.revision.saturating_add(1);
}
Ok(())
}
fn relocated_history(&self, entry: &str) -> Result<RelocatedHistory, DocumentError> {
if entry.contains('/') || source_project::resolve_include("", entry).as_deref() != Ok(entry)
{
return Err(ModelError("Choose a portable .arcadia entry file name.".into()).into());
}
let snapshots = std::iter::once(self.current.as_ref())
.chain(self.past.iter().map(|h| h.snapshot.as_ref()))
.chain(self.future.iter().map(|h| h.snapshot.as_ref()));
let mut uniform_entry = true;
for snapshot in snapshots {
uniform_entry &= snapshot.bundle.entry == self.bundle().entry;
if snapshot.bundle.files.keys().any(|path| {
path != &snapshot.bundle.entry && path.to_lowercase() == entry.to_lowercase()
}) {
return Err(ModelError(format!(
"The entry name '{entry}' is used by an included file in this document or its Undo/Redo history. Choose another entry name."
)).into());
}
}
let relocate = |snapshot: &Arc<CompiledSourceProject>| -> Result<Arc<CompiledSourceProject>, DocumentError> {
if snapshot.bundle.entry == entry {
return Ok(snapshot.clone());
}
let mut bundle = snapshot.bundle.clone();
let text = bundle
.files
.remove(&bundle.entry)
.ok_or_else(|| ModelError("The accepted source entry is missing.".into()))?;
bundle.entry = entry.to_owned();
bundle.files.insert(entry.to_owned(), text);
let compiled = source_project::compile_bundle(&bundle, self.limits)?;
if compiled.bundle != bundle || compiled.decoded.project != snapshot.decoded.project {
return Err(ModelError(
"Changing the entry location would change the model or authored source.".into(),
)
.into());
}
Ok(Arc::new(compiled))
};
let current = relocate(&self.current)?;
let history = |entries: &[HistoryEntry]| {
entries
.iter()
.map(|old| {
// Different entry names in history are authored source changes.
// Keep those snapshots exact instead of collapsing real edits.
let snapshot = if uniform_entry {
relocate(&old.snapshot)?
} else {
old.snapshot.clone()
};
Ok(HistoryEntry {
bytes: estimated_bytes(&snapshot),
snapshot,
})
})
.collect::<Result<Vec<_>, DocumentError>>()
};
Ok(RelocatedHistory {
current,
past: history(&self.past)?,
future: history(&self.future)?,
})
}
pub fn execute(&mut self, command: Command) -> Result<Option<Id>, DocumentError> {
@@ -178,6 +304,7 @@ impl Document {
Ok(candidate.selected)
}
#[cfg(test)]
pub fn apply_bundle(&mut self, bundle: SourceBundle) -> Result<bool, DocumentError> {
let compiled = source_project::compile_bundle(&bundle, self.limits)?;
Ok(self.commit(compiled))
@@ -259,6 +386,10 @@ impl Document {
}
}
#[cfg(test)]
#[path = "document_save_as_tests.rs"]
mod save_as_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,154 @@
use super::*;
fn renamed(bundle: &SourceBundle, entry: &str) -> SourceBundle {
let mut out = bundle.clone();
let text = out.files.remove(&out.entry).unwrap();
out.entry = entry.into();
out.files.insert(entry.into(), text);
out
}
fn saved(document: &Document, entry: &str) -> CompiledSourceProject {
source_project::compile_bundle(&renamed(document.bundle(), entry), document.limits).unwrap()
}
#[test]
fn save_as_rebases_uniform_history_without_an_undo_step_or_lost_redo() {
let mut document = Document::from_project(Project::empty(), ProjectLimits::default()).unwrap();
let original = document.bundle().clone();
let mut comments = original.clone();
comments
.files
.get_mut(&comments.entry)
.unwrap()
.push_str("\r\n// exact rationale\r\n");
document.apply_bundle(comments.clone()).unwrap();
document
.execute(Command::Rename(
document.project().id,
"Engineering choice".into(),
))
.unwrap();
let future = document.bundle().clone();
document.undo();
let counts = (document.past.len(), document.future.len());
let receipt = saved(&document, "accepted.arcadia");
document.check_save_entry("accepted.arcadia").unwrap();
document
.adopt_saved_entry(&receipt, document.revision())
.unwrap();
document.mark_saved(Arc::new(receipt));
assert_eq!((document.past.len(), document.future.len()), counts);
assert!(!document.dirty());
assert!(document.undo());
assert_eq!(document.bundle(), &renamed(&original, "accepted.arcadia"));
assert!(document.redo());
assert_eq!(document.bundle(), &renamed(&comments, "accepted.arcadia"));
assert!(!document.dirty());
assert!(document.redo());
assert_eq!(document.bundle(), &renamed(&future, "accepted.arcadia"));
assert!(document.dirty());
let checked = source_project::compile_bundle(document.bundle(), document.limits).unwrap();
assert_eq!(document.compiled().index, checked.index);
assert_eq!(document.project(), &checked.decoded.project);
}
#[test]
fn save_as_preserves_explicit_source_entry_changes_in_history() {
let mut document = Document::from_project(Project::empty(), ProjectLimits::default()).unwrap();
let original = document.bundle().clone();
let authored = renamed(&original, "authored.arcadia");
document.apply_bundle(authored.clone()).unwrap();
document
.execute(Command::Rename(
document.project().id,
"Later meaning".into(),
))
.unwrap();
let receipt = saved(&document, "accepted.arcadia");
document
.adopt_saved_entry(&receipt, document.revision())
.unwrap();
assert_eq!(document.past.len(), 2);
assert!(document.undo());
assert_eq!(document.bundle(), &authored);
assert!(document.undo());
assert_eq!(document.bundle(), &original);
assert!(document.redo());
assert_eq!(document.bundle(), &authored);
assert!(document.redo());
assert_eq!(document.bundle(), &receipt.bundle);
}
#[test]
fn save_as_rejects_included_filename_collisions_in_past_and_future_without_mutation() {
for collision_in_future in [false, true] {
let base = source_project::format_bundle(&Project::empty());
let mut included = base.clone();
let text = included.files.get_mut(&included.entry).unwrap();
text.insert_str(text.rfind('}').unwrap(), "\n include \"TAKEN.arcadia\";\n");
included.files.insert(
"TAKEN.arcadia".into(),
"// retained engineering rationale\n".into(),
);
let mut document = if collision_in_future {
let mut d = Document::from_bundle(base, ProjectLimits::default()).unwrap();
d.apply_bundle(included).unwrap();
d.undo();
d
} else {
let mut d = Document::from_bundle(included, ProjectLimits::default()).unwrap();
d.apply_bundle(base).unwrap();
d
};
let current = document.snapshot();
let revision = document.revision();
let counts = (document.past.len(), document.future.len());
assert!(
document
.check_save_entry("taken.arcadia")
.unwrap_err()
.to_string()
.contains("Undo/Redo")
);
let receipt = saved(&document, "taken.arcadia");
assert!(document.adopt_saved_entry(&receipt, revision).is_err());
assert!(Arc::ptr_eq(&current, &document.snapshot()));
assert_eq!(document.revision(), revision);
assert_eq!((document.past.len(), document.future.len()), counts);
}
}
#[test]
fn save_as_rejects_stale_or_content_changing_receipt_atomically() {
let mut document = Document::from_project(Project::empty(), ProjectLimits::default()).unwrap();
let mut receipt = saved(&document, "accepted.arcadia");
let revision = document.revision();
document
.execute(Command::Rename(
document.project().id,
"Newer meaning".into(),
))
.unwrap();
let current = document.snapshot();
assert!(document.adopt_saved_entry(&receipt, revision).is_err());
assert!(
document
.adopt_saved_entry(&receipt, document.revision())
.is_err()
);
receipt = saved(&document, "accepted.arcadia");
receipt
.bundle
.files
.get_mut(&receipt.bundle.entry)
.unwrap()
.push_str("// unsaved receipt text\n");
assert!(
document
.adopt_saved_entry(&receipt, document.revision())
.is_err()
);
assert!(Arc::ptr_eq(&current, &document.snapshot()));
assert_eq!(document.past.len(), 1);
}
+80
View File
@@ -52,7 +52,87 @@ pub struct Draft {
pub value_browsers: BTreeMap<String, ValueBrowser>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecoveryDraft {
original: Id,
value: Value,
exact_strings: bool,
inputs: BTreeMap<String, String>,
constructors: Vec<(String, String, Value, BTreeMap<String, String>)>,
ports: BTreeMap<String, Value>,
}
impl Draft {
pub fn recovery_snapshot(&self) -> RecoveryDraft {
let raw = |inputs: &BTreeMap<String, Input>| {
inputs
.iter()
.map(|(path, input)| (path.clone(), input.raw.clone()))
.collect()
};
RecoveryDraft {
original: self.original,
value: self.value.clone(),
exact_strings: self.exact_strings,
inputs: raw(&self.inputs),
constructors: self
.constructor_drafts
.iter()
.map(|((path, kind), (value, inputs))| {
(path.clone(), kind.clone(), value.clone(), raw(inputs))
})
.collect(),
ports: self.port_contract_drafts.clone(),
}
}
pub fn from_recovery(project: &Project, saved: RecoveryDraft) -> Result<Self, String> {
let input_count = saved.inputs.len().saturating_add(
saved
.constructors
.iter()
.map(|(_, _, _, inputs)| inputs.len())
.sum::<usize>(),
);
if input_count > crate::source_workspace::LIMITS.model.max_records
|| saved.constructors.len() > 1024
|| saved.ports.len() > 32
{
return Err("Recovered property buffers exceed the supported limit.".into());
}
let mut draft = Self::from_project(project, saved.original)
.ok_or("Recovered property owner no longer exists.")?;
draft.value = saved.value;
draft.exact_strings = saved.exact_strings;
draft.rebuild();
if !draft.inputs.keys().eq(saved.inputs.keys()) {
return Err("Recovered property fields no longer match their raw buffers.".into());
}
for (path, raw) in saved.inputs {
draft.input(&path, raw);
}
draft.constructor_drafts = saved
.constructors
.into_iter()
.map(|(path, kind, value, inputs)| {
(
(path, kind),
(
value,
inputs
.into_iter()
.map(|(path, raw)| {
let editor = text_editor::Content::with_text(&raw);
(path, Input { raw, editor })
})
.collect(),
),
)
})
.collect();
draft.port_contract_drafts = saved.ports;
Ok(draft)
}
pub fn from_project(project: &Project, selected: Id) -> Option<Self> {
let (kind, value) = if selected == project.id {
(
+32 -11
View File
@@ -575,11 +575,11 @@ fn article(topic: Topic) -> Article {
),
explain(
"Review drift deliberately",
"Traceability marks descriptive links Reviewed or Review needed by comparing both endpoints with their last review baseline. Independent edits may be intentional. Keeping a target value records that reviewed decision; it does not delete the operational need. A manual trace records correspondence without propagating properties.",
"On an existing trace, choose Review semantics… to compare typed content and prior observations. Choose Keep for the destination, Take for the source, or Set final value / Set final kind and payload where offered. Review mapped references and proposed changes, then choose Apply reviewed changes. Independent edits may be intentional; keeping a value records that decision. A manual trace records correspondence without propagating properties.",
),
explain(
"Know what reconciliation changes",
"The current counterpart workflow reviews names and descriptions of existing targets. It can create structural dependencies for new counterparts. Reparenting, reallocating, removing or restructuring an existing counterpart remains an explicit engineering edit; do not assume Refresh preview automatically synchronizes every structural change.",
"Semantic review supports explicit structural and scalar changes, typed constructors and individual collection rows, including members, literals, annotations and bindings. Candidate validation checks the complete proposed model before applying one undoable transaction. Ordered collections, attachment/artifact and multi-source review controls remain unfinished; some structured facts are read-only. Do not assume Refresh preview automatically synchronizes arbitrary structure or every field.",
),
explain(
"Avoid duplicate counterparts",
@@ -677,8 +677,8 @@ fn article(topic: Topic) -> Article {
Topic::Recovery => {
const {
Article {
summary: "Structural errors protect the accepted model. Warnings keep incomplete engineering work visible without preventing you from developing it incrementally.",
keywords: "validation checks error warning invalid alias missing reference undo redo revert recover draft unsaved structural external conflict lint",
summary: "Correct rejected drafts, inspect model checks and recover retained work after an unexpected exit. Recovery copies preserve editing separately from your saved models.",
keywords: "validation checks error warning invalid alias missing reference undo redo revert recover recovery checkpoint autosave crash draft unsaved structural external conflict lint home",
sections: &[
walkthrough(
"Resolve a model issue",
@@ -702,11 +702,23 @@ fn article(topic: Topic) -> Article {
),
explain(
"Recover an interrupted source save",
"If a staged multi-file save was interrupted, Textual model offers Recover interrupted save. Recovery checks the journal and file contents before restoring originals; it does not overwrite unrelated external changes. This is recovery of a save transaction, not periodic autosave of unsaved edits.",
"If a staged multi-file save was interrupted, Textual model offers Recover interrupted save. Recovery checks the journal and file contents before restoring originals; it does not overwrite unrelated external changes. This repairs an interrupted explicit save. Home's recovery copies preserve unsaved editing separately.",
),
walkthrough(
"Recover after an unexpected exit",
&[
"Open Home and look under Recover unsaved work. Each retained session shows its model name, original location when known and age. Choose Recover; the current model still uses the normal Save/Discard/Cancel guard.",
"Inspect the recovered copy. Accepted model/source content and raw source buffers are retained, including invalid and detached drafts. Supported pending property/behavior forms and workflow drafts return with rejected values and semantic-review choices. Correct or revert unfinished input before applying it.",
"Choose Save as and select a destination. Recovery does not reconnect the original local or hosted destination, and never restores credentials. Undo/redo history is not restored across sessions.",
],
),
explain(
"What a checkpoint protects",
"While work is unsaved, a private local checkpoint is attempted every 10 seconds. It does not apply drafts, modify model files or save to the server. Recent edits since the last completed checkpoint may not survive a crash. Home reports unavailable or damaged recovery files without replacing the currently open model. Explicit saves and version control remain your durable milestones.",
),
explain(
"Save work before leaving",
"New, Open, loading the example and Close use the unsaved-work prompt. Save and continue, discard or cancel is your decision. There is no autosave recovery journal in the current desktop; save meaningful milestones and use version control or backups.",
"New, Open, opening a recent model, loading the example, Recover and Close use the unsaved-work prompt. Save and continue, discard or cancel is your decision. Checkpoints are private recovery copies; continue saving meaningful milestones and using version control or backups.",
),
],
related: &[Topic::Source, Topic::Files, Topic::Transitions],
@@ -718,8 +730,12 @@ fn article(topic: Topic) -> Article {
const {
Article {
summary: "Keep editable model files and exported pictures distinct. Save the model to retain its semantics, identities and traceability.",
keywords: "file open save save as keyboard shortcuts ctrl control shift export svg json arcadia backup external changes migration",
keywords: "file home recent missing locate open save save as keyboard shortcuts ctrl control shift export svg json arcadia backup external changes migration",
sections: &[
explain(
"Start from Home",
"Home offers New model, Open model, the rescue example and this guide. Recent models lists local files you successfully opened or saved. A disconnected drive or moved file remains visible; use Locate to choose its new location or Refresh after reconnecting the drive. Remove forgets the recent entry without deleting its model file. Returning to Home retains the current document and uses the same draft navigation guards as other pages.",
),
explain(
"Editable formats",
".arcadia is editable text suitable for Git and can include other source files. .json is the typed model exchange format. Both preserve the persistent model, diagrams and traceability; only a source project preserves its comments and file organization. Export SVG creates a diagram image for reports; it is not an editable model backup.",
@@ -740,7 +756,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 validates a complete candidate before replacing the project. Supported earlier versions migrate to the current schema; Model checks shows migration details such as preserved ports, supplied data defaults, generated interface allocations and unresolved message paths. Old Event items become Information notifications; unknown physical mechanisms remain Unspecified. Source 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.",
),
],
related: &[Topic::Source, Topic::Recovery, Topic::Artifacts],
@@ -884,7 +900,8 @@ fn article_view(topic: Topic) -> Element<'static, Message> {
.width(28),
text(*step).size(14).width(Fill),
]
.spacing(9),
.spacing(9)
.width(Fill),
);
}
if let Some(code) = section.code {
@@ -984,11 +1001,15 @@ pub fn view(state: &State) -> Element<'_, Message> {
(!state.query.is_empty()).then(|| Message::Guide(Event::Search(String::new())))
),
]
.spacing(8),
.spacing(8)
.width(Fill),
text(format!("{} topics", matches.len()))
.size(11)
.color(style::MUTED),
row![topic_list, article].spacing(20).height(Fill),
row![topic_list, article]
.spacing(20)
.width(Fill)
.height(Fill),
]
.spacing(14)
.padding(22)
+457
View File
@@ -0,0 +1,457 @@
//! Start workspace and bounded, user-local recent-file metadata.
//! Events request the application's existing guarded actions; this module never
//! opens, creates, saves or modifies a model document.
use crate::{style, views::short};
use iced::{
Element, Fill,
widget::{button, column, container, row, scrollable, text},
};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, File},
io::{Read, Write},
path::{Path, PathBuf},
};
const MAX_RECENTS: usize = 12;
const MAX_STORE_BYTES: u64 = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
New,
Open,
Example,
Guide,
Resume,
OpenRecent(PathBuf),
Forget(PathBuf),
Refresh,
Recover(oa_core::Id),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Entry {
pub path: PathBuf,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Availability {
Available,
Missing,
Unavailable(String),
}
#[derive(Debug, Clone)]
pub struct Recent {
pub entry: Entry,
pub availability: Availability,
}
#[derive(Debug, Default)]
pub struct State {
pub recent: Vec<Recent>,
pub notice: Option<String>,
store: Option<PathBuf>,
/// Unknown/corrupt metadata is retained on disk until repaired externally.
/// Remember/forget still work for this application session.
read_only: bool,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Stored {
version: u32,
entries: Vec<Entry>,
}
/// User-local application metadata directory, shared with recovery facilities.
/// No current-directory fallback: missing configuration must not write into a
/// model checkout. An explicitly configured relative XDG location is invalid.
pub fn data_directory() -> Option<PathBuf> {
if cfg!(target_os = "windows") {
std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.map(|path| path.join("ArcadiaStudio"))
} else {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.map(|path| path.join(".local/share"))
})
.map(|path| path.join("arcadia-studio"))
}
}
impl State {
pub fn load_default() -> Self {
match data_directory() {
Some(directory) => Self::load(directory.join("recent-models.json")),
None => Self {
notice: Some("Recent models are available for this session; the user data directory is unavailable.".into()),
..Self::default()
},
}
}
pub fn load(store: PathBuf) -> Self {
let mut state = Self {
store: Some(store.clone()),
..Self::default()
};
match read_store(&store) {
Ok(entries) => {
state.recent = entries
.into_iter()
.map(|entry| Recent {
availability: availability(&entry.path),
entry,
})
.collect();
}
Err(error) => {
state.read_only = true;
state.notice = Some(format!(
"Recent model history could not be loaded: {error}. Your model files are unaffected; this session's history will remain in memory."
));
}
}
state
}
/// Call only after the application's existing open/save action succeeds.
/// Canonical local file identities prevent duplicate entries for symlinks
/// and alternate spellings. Unavailable files are never newly remembered.
pub fn remember(&mut self, path: &Path, name: &str) -> Result<(), String> {
let path = fs::canonicalize(path).map_err(|error| error.to_string())?;
if !path.is_file() {
return Err("Recent models must reference a file.".into());
}
self.recent
.retain(|recent| !same_path(&recent.entry.path, &path));
self.recent.insert(
0,
Recent {
entry: Entry {
path,
name: short(name, 160),
},
availability: Availability::Available,
},
);
self.recent.truncate(MAX_RECENTS);
self.persist()
}
/// Removing history only removes metadata; it never deletes a model file.
pub fn forget(&mut self, path: &Path) -> Result<(), String> {
self.recent
.retain(|recent| !same_path(&recent.entry.path, path));
self.persist()
}
pub fn refresh(&mut self) {
for recent in &mut self.recent {
recent.availability = availability(&recent.entry.path);
}
}
fn persist(&mut self) -> Result<(), String> {
let Some(path) = &self.store else {
return Ok(());
};
if self.read_only {
return Err("Recent history is retained in memory because its existing metadata could not be read.".into());
}
let entries = self
.recent
.iter()
.map(|recent| recent.entry.clone())
.collect();
match write_store(path, entries) {
Ok(()) => {
self.notice = None;
Ok(())
}
Err(error) => {
let message = format!(
"Recent model history could not be saved: {error}. It remains available for this session."
);
self.notice = Some(message.clone());
Err(message)
}
}
}
}
fn same_path(a: &Path, b: &Path) -> bool {
#[cfg(windows)]
{
a.to_string_lossy()
.eq_ignore_ascii_case(&b.to_string_lossy())
}
#[cfg(not(windows))]
{
a == b
}
}
fn availability(path: &Path) -> Availability {
match fs::metadata(path) {
Ok(metadata) if metadata.is_file() => Availability::Available,
Ok(_) => Availability::Unavailable("The path is not a file".into()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Availability::Missing,
Err(error) => Availability::Unavailable(error.to_string()),
}
}
fn read_store(path: &Path) -> Result<Vec<Entry>, String> {
let file = match File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
Err(error) => return Err(error.to_string()),
};
let mut bytes = Vec::new();
file.take(MAX_STORE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.len() as u64 > MAX_STORE_BYTES {
return Err("history file exceeds its size limit".into());
}
let stored: Stored = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
if stored.version != 1 {
return Err("unsupported history version".into());
}
if stored.entries.len() > MAX_RECENTS
|| stored
.entries
.iter()
.any(|entry| !entry.path.is_absolute() || entry.name.chars().count() > 160)
{
return Err("history contains invalid entries".into());
}
let mut entries: Vec<Entry> = Vec::new();
for entry in stored.entries {
if !entries
.iter()
.any(|prior| same_path(&prior.path, &entry.path))
{
entries.push(entry);
}
}
Ok(entries)
}
fn write_store(path: &Path, entries: Vec<Entry>) -> Result<(), String> {
let parent = path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.ok_or_else(|| "history path requires a parent directory".to_string())?;
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec_pretty(&Stored {
version: 1,
entries,
})
.map_err(|e| e.to_string())?;
if bytes.len() as u64 > MAX_STORE_BYTES {
return Err("history exceeds its size limit".into());
}
let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
temporary.write_all(&bytes).map_err(|e| e.to_string())?;
temporary.as_file().sync_all().map_err(|e| e.to_string())?;
temporary.persist(path).map_err(|e| e.error.to_string())?;
#[cfg(unix)]
File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|e| e.to_string())?;
Ok(())
}
fn action<'a>(label: &str, event: Event) -> iced::widget::Button<'a, Event> {
button(text(label.to_owned()).size(13))
.padding([10, 14])
.on_press(event)
}
fn recovery_age(modified: u64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let elapsed = now.saturating_sub(modified);
let (count, unit) = match elapsed {
0..60 => return "Retained just now".into(),
60..3600 => (elapsed / 60, "minute"),
3600..86400 => (elapsed / 3600, "hour"),
_ => (elapsed / 86400, "day"),
};
format!(
"Retained {count} {unit}{} ago",
if count == 1 { "" } else { "s" }
)
}
pub fn view<'a>(
state: &'a State,
current: Option<(&str, bool)>,
recovery: &'a crate::recovery::State,
) -> Element<'a, Event> {
let mut content = column![
text("Your systems engineering workspace").size(28),
text("Develop operational needs and system architecture through connected diagrams, source and engineering evidence.").size(14).color(style::MUTED),
row![
action("New model", Event::New),
action("Open model…", Event::Open).style(style::nav),
action("Explore rescue example", Event::Example).style(style::nav),
].spacing(12),
].spacing(18).max_width(940);
if let Some(warning) = &recovery.warning {
content = content.push(text(warning).size(12).color(style::MUTED));
}
if !recovery.entries.is_empty() {
content = content.push(text("Recover unsaved work").size(20));
content = content.push(text("Edits retained from an earlier session. Recovery opens an unsaved copy; choose a destination when saving. Undo history is not restored.").size(12).color(style::MUTED));
for entry in &recovery.entries {
content = content.push(
container(
row![
column![
text(short(&entry.name, 80)).size(15),
text(
entry
.origin
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "Unsaved model".into())
)
.size(11)
.color(style::MUTED),
text(recovery_age(entry.modified))
.size(11)
.color(style::MUTED),
]
.spacing(5)
.width(Fill),
action("Recover", Event::Recover(entry.id)),
]
.spacing(12)
.align_y(iced::Center),
)
.padding(14)
.style(style::card),
);
}
}
if let Some((name, dirty)) = current {
content = content.push(
container(
row![
column![
text(format!("Current model · {}", short(name, 64))).size(16),
text(if dirty {
"You have unsaved edits. They remain available while Home is open."
} else {
"Continue working with the model already open in this window."
})
.size(12)
.color(style::MUTED)
]
.spacing(5)
.width(Fill),
action("Continue editing", Event::Resume),
]
.spacing(12)
.align_y(iced::Center),
)
.padding(16)
.style(style::card),
);
}
content = content.push(
row![
text("Recent models").size(20).width(Fill),
action("Refresh", Event::Refresh).style(style::nav),
]
.align_y(iced::Center),
);
if let Some(notice) = &state.notice {
content = content.push(text(notice).size(12).color(style::MUTED));
}
if state.recent.is_empty() {
content = content.push(container(column![
text("Models you open or save will appear here.").size(14),
text("Open an .arcadia project entry or import a .json model. Included source files load together.").size(12).color(style::MUTED),
].spacing(6)).padding(20).style(style::subtle));
}
for recent in &state.recent {
let entry = &recent.entry;
let name = if entry.name.is_empty() {
entry
.path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
} else {
entry.name.clone()
};
let mut description = column![
text(short(&name, 85)).size(15),
text(entry.path.display().to_string())
.size(11)
.color(style::MUTED),
]
.spacing(5)
.width(Fill);
let open = match &recent.availability {
Availability::Available => action("Open", Event::OpenRecent(entry.path.clone())),
availability => {
description = description.push(
text(match availability {
Availability::Missing => {
"File not found. Locate it after moving or reconnecting its drive."
.to_string()
}
Availability::Unavailable(reason) => {
format!("Unavailable: {}", short(reason, 120))
}
Availability::Available => unreachable!(),
})
.size(12)
.color(style::MUTED),
);
action("Locate…", Event::Open)
}
};
content = content.push(
container(
row![
description,
open,
action("Remove", Event::Forget(entry.path.clone())).style(style::nav)
]
.spacing(10)
.align_y(iced::Center),
)
.padding(14)
.style(style::card),
);
}
content = content.push(container(column![
text("Build a model you can explain").size(18),
text("Start with operational actors and activities, trace the needs into system functions, then describe behavior and interfaces. The engineering guide includes concepts, examples and editing workflows.").size(13),
action("Open engineering guide", Event::Guide).style(style::nav),
].spacing(10)).padding(20).style(style::subtle));
scrollable(container(content).padding(28).width(Fill).center_x(Fill))
.height(Fill)
.into()
}
#[cfg(test)]
mod tests;
+177
View File
@@ -0,0 +1,177 @@
use super::*;
fn model(directory: &Path, name: &str) -> PathBuf {
let path = directory.join(name);
fs::write(&path, b"model contents must remain untouched").unwrap();
path
}
#[test]
fn loading_missing_history_does_not_create_files_or_directories() {
let temp = tempfile::tempdir().unwrap();
let store = temp.path().join("not-created/recent-models.json");
let state = State::load(store.clone());
assert!(state.recent.is_empty());
assert!(state.notice.is_none());
assert!(!store.parent().unwrap().exists());
}
#[test]
fn successful_local_history_is_bounded_ordered_deduplicated_and_durable() {
let temp = tempfile::tempdir().unwrap();
let store = temp.path().join("settings/recent-models.json");
let mut state = State::load(store.clone());
let files: Vec<_> = (0..15)
.map(|i| model(temp.path(), &format!("model-{i}.arcadia")))
.collect();
for (i, path) in files.iter().enumerate() {
state.remember(path, &format!("Model {i}")).unwrap();
}
assert_eq!(state.recent.len(), MAX_RECENTS);
assert_eq!(state.recent[0].entry.name, "Model 14");
assert_eq!(state.recent[11].entry.name, "Model 3");
state.remember(&files[8], "Renamed model").unwrap();
assert_eq!(state.recent.len(), MAX_RECENTS);
assert_eq!(state.recent[0].entry.name, "Renamed model");
let loaded = State::load(store);
assert!(loaded.notice.is_none());
assert_eq!(
loaded.recent.iter().map(|r| &r.entry).collect::<Vec<_>>(),
state.recent.iter().map(|r| &r.entry).collect::<Vec<_>>()
);
assert!(
loaded
.recent
.iter()
.all(|r| r.availability == Availability::Available)
);
assert_eq!(
fs::read(&files[8]).unwrap(),
b"model contents must remain untouched"
);
}
#[test]
fn forgotten_and_missing_files_never_change_the_model_or_other_entries() {
let temp = tempfile::tempdir().unwrap();
let store = temp.path().join("recent.json");
let a = model(temp.path(), "a.arcadia");
let b = model(temp.path(), "b.json");
let mut state = State::load(store.clone());
state.remember(&a, "A").unwrap();
state.remember(&b, "B").unwrap();
fs::remove_file(&b).unwrap();
state.refresh();
assert_eq!(state.recent[0].availability, Availability::Missing);
assert_eq!(state.recent.len(), 2);
assert_eq!(
State::load(store.clone()).recent[0].availability,
Availability::Missing
);
let old = fs::read(&store).unwrap();
assert!(state.remember(&b, "Missing file").is_err());
assert_eq!(fs::read(&store).unwrap(), old);
state.forget(&a.canonicalize().unwrap()).unwrap();
assert!(a.exists());
assert_eq!(state.recent.len(), 1);
assert_eq!(State::load(store).recent.len(), 1);
}
#[test]
fn unavailable_location_can_reappear_on_refresh_without_losing_identity() {
let temp = tempfile::tempdir().unwrap();
let path = model(temp.path(), "removable.arcadia");
let mut state = State::default();
state.remember(&path, "Portable system").unwrap();
fs::remove_file(&path).unwrap();
fs::create_dir(&path).unwrap();
state.refresh();
assert!(matches!(
state.recent[0].availability,
Availability::Unavailable(_)
));
fs::remove_dir(&path).unwrap();
fs::write(&path, b"restored").unwrap();
state.refresh();
assert_eq!(state.recent[0].availability, Availability::Available);
assert_eq!(state.recent[0].entry.name, "Portable system");
}
#[test]
fn corrupt_future_and_oversized_history_are_preserved_with_usable_session_history() {
let temp = tempfile::tempdir().unwrap();
let model = model(temp.path(), "model.arcadia");
let store = temp.path().join("recent.json");
for bytes in [
b"not json".to_vec(),
br#"{"version":99,"entries":[]}"#.to_vec(),
vec![b' '; MAX_STORE_BYTES as usize + 1],
] {
fs::write(&store, &bytes).unwrap();
let mut state = State::load(store.clone());
assert!(state.notice.is_some());
assert!(state.remember(&model, "Session model").is_err());
assert_eq!(state.recent.len(), 1);
assert_eq!(fs::read(&store).unwrap(), bytes);
}
}
#[test]
fn failed_metadata_replace_keeps_previous_history_and_reports_memory_only_entry() {
let temp = tempfile::tempdir().unwrap();
let a = model(temp.path(), "a.arcadia");
let b = model(temp.path(), "b.arcadia");
let parent = temp.path().join("settings");
let store = parent.join("recent.json");
let mut state = State::load(store.clone());
state.remember(&a, "A").unwrap();
let original = fs::read(&store).unwrap();
let preserved = temp.path().join("preserved");
fs::rename(&parent, &preserved).unwrap();
fs::write(&parent, b"directory unavailable").unwrap();
assert!(state.remember(&b, "B").is_err());
assert!(state.notice.is_some());
assert_eq!(state.recent[0].entry.name, "B");
assert_eq!(fs::read(preserved.join("recent.json")).unwrap(), original);
fs::remove_file(&parent).unwrap();
fs::rename(&preserved, &parent).unwrap();
state.remember(&b, "B").unwrap();
assert!(state.notice.is_none());
assert_eq!(State::load(store).recent[0].entry.name, "B");
assert_eq!(
fs::read_dir(parent).unwrap().count(),
1,
"no abandoned temp files"
);
}
#[test]
fn malformed_entries_and_unknown_fields_never_become_open_requests() {
let temp = tempfile::tempdir().unwrap();
let store = temp.path().join("recent.json");
for value in [
serde_json::json!({"version":1,"entries":[{"path":"relative.arcadia","name":"Bad"}]}),
serde_json::json!({"version":1,"entries":[],"credential":"not accepted"}),
serde_json::json!({"version":1,"entries":[{"path":temp.path().join("absolute.arcadia"),"name":"x".repeat(161)}]}),
] {
fs::write(&store, serde_json::to_vec(&value).unwrap()).unwrap();
let state = State::load(store.clone());
assert!(state.notice.is_some());
assert!(state.recent.is_empty());
}
}
#[test]
fn first_and_replaced_metadata_are_parseable_and_contain_no_model_bytes() {
let temp = tempfile::tempdir().unwrap();
let path = model(temp.path(), "a.arcadia");
let store = temp.path().join("recent.json");
let mut state = State::load(store.clone());
for name in ["First", "Updated"] {
state.remember(&path, name).unwrap();
let bytes = fs::read(&store).unwrap();
let parsed: Stored = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed.entries[0].name, name);
assert!(!String::from_utf8(bytes).unwrap().contains("model contents"));
}
}
+14 -3
View File
@@ -12,7 +12,7 @@ use std::{
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::oneshot,
sync::{oneshot, watch},
task::JoinHandle,
};
@@ -500,13 +500,19 @@ async fn hosted_response_limits_cover_declared_and_chunked_bodies() {
async fn hosted_timeout_and_disconnect_leave_mutation_outcomes_explicit() {
let received = Arc::new(AtomicUsize::new(0));
let calls = Arc::clone(&received);
let (release, held) = watch::channel(false);
let server = TestServer::fixture(Router::new().route(
"/api/v1/projects/import-source-project",
post(move || {
let calls = Arc::clone(&calls);
let mut held = held.clone();
async move {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(100)).await;
// Never race a fixed response delay against the client deadline.
// A retained watch value also releases a handler admitted late.
while !*held.borrow_and_update() {
held.changed().await.unwrap();
}
StatusCode::CREATED
}
}),
@@ -518,9 +524,14 @@ async fn hosted_timeout_and_disconnect_leave_mutation_outcomes_explicit() {
let failure = client.publish(authored()).await.unwrap_err();
assert_eq!(failure.code, "timeout");
assert!(failure.uncertain);
assert_eq!(received.load(Ordering::SeqCst), 1);
release.send(true).unwrap();
let base = server.base.clone();
server.finish().await;
// Under executor/CPU contention, the deadline can expire before admission.
// Both zero and one admission have an uncertain mutation outcome; neither
// permits a retry. The lost-response test above independently reads a full
// admitted request and requires exactly one publication.
assert!(received.load(Ordering::SeqCst) <= 1);
let failure = Connection::with_timeout(&base, Secret::default(), Duration::from_secs(2))
.unwrap()
.catalog("", None)
+2
View File
@@ -13,12 +13,14 @@ mod explorer_view;
mod forms;
mod guide;
mod highlight;
mod home;
mod hosted;
mod model_menu;
mod navigation;
mod port_edit;
mod port_preview;
mod port_workflow;
mod recovery;
mod route_edit;
mod semantic_review;
mod source_navigation;
+4 -4
View File
@@ -684,7 +684,7 @@ mod tests {
#[test]
fn right_click_targets_actor_without_changing_selection_until_action() {
let mut app = App::new();
let mut app = App::with_project(Project::demo());
let actor = app
.project()
.elements
@@ -717,7 +717,7 @@ mod tests {
}
#[test]
fn menu_preserves_pending_properties_and_rejects_delayed_mutations() {
let mut app = App::new();
let mut app = App::with_project(Project::demo());
let target = app.project().elements.keys().next().copied().unwrap();
let selected = app.selected;
let model = app.project().clone();
@@ -747,7 +747,7 @@ mod tests {
}
#[test]
fn late_menu_actions_do_not_dismiss_new_menu_or_cross_view_context() {
let mut app = App::new();
let mut app = App::with_project(Project::demo());
let id = app.selected;
send(&mut app, open(id, Point::ORIGIN));
let old = choose(&app, "Open source");
@@ -763,7 +763,7 @@ mod tests {
}
#[test]
fn pending_source_allows_file_navigation_but_disables_model_actions() {
let mut app = App::new();
let mut app = App::with_project(Project::demo());
send(&mut app, Message::Page(Page::Text));
send(
&mut app,
+99 -2
View File
@@ -10,7 +10,7 @@ use iced::{
use oa_core::*;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Action {
Component,
Input,
@@ -83,7 +83,7 @@ pub fn actions(p: &Project, id: Id) -> Vec<Action> {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ContractKind {
Input,
Output,
@@ -128,7 +128,104 @@ pub struct State {
primary_input: bool,
preview: Result<Vec<Command>, String>,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RecoveryState {
version: u32,
base: String,
action: Action,
subject: Id,
id: Id,
name: String,
kind: ContractKind,
orientation: ComponentPortOrientation,
oriented: bool,
boundary: Option<Id>,
source_port: PortChoice,
target_port: PortChoice,
incoming: BTreeSet<Id>,
outgoing: BTreeSet<Id>,
primary_input: bool,
}
#[cfg(test)]
mod recovery_tests {
use super::*;
#[test]
fn port_recovery_retains_authored_contract_and_identity_but_rebuilds_preview() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Function, Layer::Sa, "Receiver", None)
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Payload", None)
.unwrap();
let mut state = State::new(&p, owner, Action::Input).unwrap();
state.name = " exact pending port ".into();
state.incoming.insert(item);
state.primary_input = false;
state.preview = Err("An old transient preview must not be restored".into());
let saved = state.recovery_snapshot().unwrap();
assert!(saved.get("preview").is_none());
let restored = State::from_recovery(&p, saved.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
assert_eq!(restored.id, state.id);
assert!(restored.preview.is_ok());
p.name.push('!');
assert!(State::from_recovery(&p, saved).is_err());
}
}
impl State {
pub fn recovery_snapshot(&self) -> Result<serde_json::Value, String> {
serde_json::to_value(RecoveryState {
version: 1,
base: transitions::project_fingerprint(&self.baseline),
action: self.action,
subject: self.subject,
id: self.id,
name: self.name.clone(),
kind: self.kind,
orientation: self.orientation,
oriented: self.oriented,
boundary: self.boundary.as_ref().map(|c| c.id),
source_port: self.source_port.choice,
target_port: self.target_port.choice,
incoming: self.incoming.clone(),
outgoing: self.outgoing.clone(),
primary_input: self.primary_input,
})
.map_err(|e| e.to_string())
}
pub fn from_recovery(p: &Project, value: serde_json::Value) -> Result<Self, String> {
let saved: RecoveryState = serde_json::from_value(value).map_err(|e| e.to_string())?;
if saved.version != 1 || saved.base != transitions::project_fingerprint(p) {
return Err(
"Port workflow recovery does not match the accepted model identity and revision."
.into(),
);
}
if saved.incoming.len().saturating_add(saved.outgoing.len())
> crate::source_workspace::LIMITS.model.max_records
{
return Err("Recovered port draft exceeds item limits.".into());
}
let mut state = Self::new(p, saved.subject, saved.action)?;
state.id = saved.id;
state.name = saved.name;
state.kind = saved.kind;
state.orientation = saved.orientation;
state.oriented = saved.oriented;
state.boundary = saved.boundary.map(|id| Choice {
id,
label: p.named(id).into(),
});
state.source_port = workflow::recovery::endpoint(p, saved.source_port);
state.target_port = workflow::recovery::endpoint(p, saved.target_port);
state.incoming = saved.incoming;
state.outgoing = saved.outgoing;
state.primary_input = saved.primary_input;
state.preview = state.plan(p);
Ok(state)
}
pub fn new(p: &Project, subject: Id, action: Action) -> Result<Self, String> {
if !actions(p, subject).contains(&action) {
return Err("This port action is unavailable for the selected element.".into());
+419
View File
@@ -0,0 +1,419 @@
//! Private crash checkpoints, separate from model files and hosted destinations.
use crate::{
document::Document,
forms::{Draft, RecoveryDraft},
source_workspace::{LIMITS, RecoveryWorkspace, Workspace},
};
use oa_core::{Id, source_project::SourceBundle};
use serde::{Deserialize, Serialize};
use std::{
fs,
io::{Read, Write},
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
const MAX_BYTES: u64 = 256 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Snapshot {
version: u32,
pub id: Id,
pub name: String,
pub origin: Option<PathBuf>,
pub modified: u64,
accepted: SourceBundle,
workspace: RecoveryWorkspace,
property: Option<RecoveryDraft>,
behavior: Option<serde_json::Value>,
#[serde(default)]
workflow: Option<serde_json::Value>,
selected: Id,
}
pub struct Restored {
pub workflow: Option<crate::workflow::Workflow>,
pub document: Document,
pub workspace: Workspace,
pub property: Option<Draft>,
pub behavior: crate::behavior::State,
pub selected: Id,
}
#[derive(Debug, Clone)]
pub struct Entry {
pub id: Id,
pub name: String,
pub origin: Option<PathBuf>,
pub modified: u64,
}
#[derive(Default)]
pub struct State {
pub directory: Option<PathBuf>,
pub entries: Vec<Entry>,
pub warning: Option<String>,
pub writing: bool,
pub active: Option<Id>,
pub restored: Option<Id>,
}
impl State {
pub fn load_default() -> Self {
let Some(directory) = crate::home::data_directory().map(|p| p.join("recovery")) else {
return Self {
warning: Some(
"Local recovery is unavailable: no application data directory.".into(),
),
..Default::default()
};
};
let mut state = Self {
directory: Some(directory),
..Default::default()
};
state.refresh();
state
}
pub fn refresh(&mut self) {
let Some(directory) = &self.directory else {
return;
};
match list(directory) {
Ok((entries, warning)) => {
self.entries = entries;
self.warning = warning;
}
Err(error) => self.warning = Some(error),
}
}
}
impl Snapshot {
pub fn capture(
id: Id,
document: &Document,
workspace: &Workspace,
property: Option<&Draft>,
behavior: &crate::behavior::State,
origin: Option<PathBuf>,
selected: Id,
) -> Result<Self, String> {
Ok(Self {
version: 1,
id,
name: document.project().name.clone(),
origin,
modified: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
accepted: document.bundle().clone(),
workspace: workspace.recovery_snapshot(),
property: property.filter(|d| d.dirty()).map(Draft::recovery_snapshot),
behavior: behavior.recovery_snapshot()?,
workflow: None,
selected,
})
}
pub fn with_workflow(
mut self,
workflow: Option<&crate::workflow::Workflow>,
) -> Result<Self, String> {
self.workflow = workflow
.map(crate::workflow::Workflow::recovery_snapshot)
.transpose()?;
Ok(self)
}
pub fn restore(self) -> Result<Restored, String> {
if self.version != 1 {
return Err("This recovery checkpoint uses an unsupported version.".into());
}
let mut document =
Document::from_bundle(self.accepted.clone(), LIMITS).map_err(|e| e.to_string())?;
if document.bundle() != &self.accepted {
return Err("Checkpoint accepted source requires migration or identity insertion; it cannot be recovered exactly.".into());
}
let workspace = Workspace::from_recovery(self.workspace)?;
let property = self
.property
.map(|s| Draft::from_recovery(document.project(), s))
.transpose()?;
let workflow = self
.workflow
.map(|value| crate::workflow::Workflow::from_recovery(document.project(), value))
.transpose()?;
let mut behavior = crate::behavior::State::default();
if let Some(saved) = self.behavior {
behavior.restore_recovery(saved)?;
}
document.mark_recovered();
Ok(Restored {
workflow,
document,
workspace,
property,
behavior,
selected: self.selected,
})
}
}
fn path(directory: &Path, id: Id) -> PathBuf {
directory.join(format!("{id}.json"))
}
pub fn write(directory: &Path, snapshot: &Snapshot) -> Result<(), String> {
// Rejected source remains editable, but must not replace the last checkpoint
// with buffers that the recovery reader cannot restore.
snapshot.workspace.validate()?;
fs::create_dir_all(directory)
.map_err(|e| format!("Cannot create local recovery directory: {e}"))?;
let bytes = serde_json::to_vec(snapshot).map_err(|e| e.to_string())?;
if bytes.len() as u64 > MAX_BYTES {
return Err(
"Checkpoint exceeds the 256 MiB recovery limit; save your work explicitly.".into(),
);
}
let mut temp = tempfile::NamedTempFile::new_in(directory).map_err(|e| e.to_string())?;
temp.write_all(&bytes)
.and_then(|()| temp.as_file().sync_all())
.map_err(|e| e.to_string())?;
temp.persist(path(directory, snapshot.id))
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn read(directory: &Path, id: Id) -> Result<Snapshot, String> {
let file =
fs::File::open(path(directory, id)).map_err(|e| format!("Cannot read checkpoint: {e}"))?;
if file.metadata().map_err(|e| e.to_string())?.len() > MAX_BYTES {
return Err("Checkpoint exceeds the recovery size limit.".into());
}
let mut bytes = vec![];
file.take(MAX_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.len() as u64 > MAX_BYTES {
return Err("Checkpoint exceeds the recovery size limit.".into());
}
let snapshot: Snapshot =
serde_json::from_slice(&bytes).map_err(|e| format!("Checkpoint is damaged: {e}"))?;
if snapshot.version != 1 || snapshot.id != id {
return Err("Checkpoint identity or version does not match.".into());
}
Ok(snapshot)
}
pub fn remove(directory: &Path, id: Id) -> Result<(), String> {
match fs::remove_file(path(directory, id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("Cannot retire checkpoint: {e}")),
}
}
fn list(directory: &Path) -> Result<(Vec<Entry>, Option<String>), String> {
let files = match fs::read_dir(directory) {
Ok(files) => files,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], None)),
Err(e) => return Err(format!("Cannot list recovery checkpoints: {e}")),
};
let mut entries = vec![];
let mut rejected = 0;
for file in files {
let file = file.map_err(|e| e.to_string())?;
let file_path = file.path();
if file_path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(id) = file_path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| Id::parse_str(s).ok())
else {
continue;
};
match read(directory, id) {
Ok(s) => entries.push(Entry {
id: s.id,
name: s.name,
origin: s.origin,
modified: s.modified,
}),
Err(_) => rejected += 1,
}
}
entries.sort_by_key(|e| std::cmp::Reverse(e.modified));
Ok((
entries,
(rejected > 0).then(|| {
format!(
"{rejected} damaged or incompatible checkpoint(s) were retained in {}.",
directory.display()
)
}),
))
}
#[cfg(test)]
mod tests {
use super::*;
use oa_core::{Command, Project};
fn snapshot() -> Snapshot {
let mut document = Document::from_project(Project::demo(), LIMITS).unwrap();
document
.execute(Command::Rename(
document.project().id,
"Recovered engineering".into(),
))
.unwrap();
let workspace = Workspace::new(document.bundle(), true);
Snapshot::capture(
Id::new_v4(),
&document,
&workspace,
None,
&Default::default(),
Some(PathBuf::from("original.arcadia")),
document.project().id,
)
.unwrap()
}
#[test]
fn exact_source_and_unfinished_invalid_buffers_restore_without_model_overwrite() {
let directory = tempfile::tempdir().unwrap();
let mut s = snapshot();
let mut workspace = Workspace::new(&s.accepted, true);
let active = workspace.active.clone();
let original = workspace.text().to_owned();
workspace.edit(
format!("{original}\nunfinished {{"),
workspace.files[&active].cursor,
);
s.workspace = workspace.recovery_snapshot();
let id = s.id;
let accepted = s.accepted.clone();
write(directory.path(), &s).unwrap();
let restored = read(directory.path(), id).unwrap().restore().unwrap();
assert_eq!(restored.document.bundle(), &accepted);
assert!(restored.document.dirty());
assert!(restored.workspace.dirty());
assert!(restored.workspace.text().ends_with("unfinished {"));
assert!(!restored.document.can_undo());
assert!(!directory.path().join("original.arcadia").exists());
}
#[test]
fn multiple_sessions_replace_only_their_own_checkpoint_and_corruption_is_retained() {
let directory = tempfile::tempdir().unwrap();
let a = snapshot();
let b = snapshot();
write(directory.path(), &a).unwrap();
write(directory.path(), &b).unwrap();
write(directory.path(), &a).unwrap();
assert_eq!(list(directory.path()).unwrap().0.len(), 2);
fs::write(path(directory.path(), a.id), b"broken").unwrap();
let (entries, warning) = list(directory.path()).unwrap();
assert_eq!(entries.len(), 1);
assert!(warning.is_some());
assert!(path(directory.path(), a.id).exists());
remove(directory.path(), b.id).unwrap();
assert!(path(directory.path(), a.id).exists());
}
#[test]
fn excess_retained_buffers_cannot_replace_the_last_recoverable_checkpoint() {
let directory = tempfile::tempdir().unwrap();
let mut s = snapshot();
write(directory.path(), &s).unwrap();
let before = fs::read(path(directory.path(), s.id)).unwrap();
// Source discovery retains detached buffers even when the accepted
// project itself remains within the file limit.
let mut workspace = Workspace::new(&s.accepted, true);
let detached = SourceBundle {
entry: "detached-0.arcadia".into(),
files: (0..LIMITS.max_files)
.map(|i| (format!("detached-{i}.arcadia"), "unfinished {".into()))
.collect(),
};
workspace.discovered(&detached, workspace.reachable.clone());
assert!(workspace.files.len() > LIMITS.max_files);
s.workspace = workspace.recovery_snapshot();
let error = write(directory.path(), &s).unwrap_err();
assert!(error.contains("file count"));
assert_eq!(fs::read(path(directory.path(), s.id)).unwrap(), before);
let restored = read(directory.path(), s.id).unwrap().restore().unwrap();
assert_eq!(restored.document.project().name, "Recovered engineering");
}
#[test]
fn raw_invalid_property_number_survives_and_stays_rejected_until_repaired() {
let mut s = snapshot();
let document = Document::from_bundle(s.accepted.clone(), LIMITS).unwrap();
let node = document
.project()
.diagrams
.values()
.flat_map(|d| d.nodes.keys())
.next()
.copied()
.unwrap();
let mut draft = Draft::from_project(document.project(), node).unwrap();
draft.input("/position/x", "-".into());
s.property = Some(draft.recovery_snapshot());
let restored = s.restore().unwrap();
let property = restored.property.unwrap();
assert_eq!(property.inputs["/position/x"].raw, "-");
assert!(property.materialize().is_err());
assert!(property.dirty());
}
#[test]
fn invalid_open_retains_distinct_accepted_model_and_repair_workspace() {
let mut s = snapshot();
let invalid = SourceBundle {
entry: "broken.arcadia".into(),
files: [("broken.arcadia".into(), "unparsed draft".into())].into(),
};
s.workspace = Workspace::new(&invalid, false).recovery_snapshot();
let restored = s.restore().unwrap();
assert_eq!(restored.document.project().name, "Recovered engineering");
assert!(!restored.workspace.applied);
assert!(restored.workspace.dirty());
assert_eq!(restored.workspace.text(), "unparsed draft");
}
#[test]
fn missing_includes_remain_reachable_repair_targets_without_fabricated_buffers() {
let mut s = snapshot();
let mut workspace = Workspace::new(&s.accepted, true);
workspace
.reachable
.insert("missing/activities.arcadia".into());
s.workspace = workspace.recovery_snapshot();
let restored = s.restore().unwrap();
assert!(
restored
.workspace
.reachable
.contains("missing/activities.arcadia")
);
assert!(
!restored
.workspace
.files
.contains_key("missing/activities.arcadia")
);
}
#[test]
fn altered_accepted_identity_and_unrecognized_raw_property_fields_reject() {
let s = snapshot();
let document = Document::from_bundle(s.accepted.clone(), LIMITS).unwrap();
let draft = Draft::from_project(document.project(), document.project().id).unwrap();
let mut raw = serde_json::to_value(draft.recovery_snapshot()).unwrap();
raw["inputs"]["/unknown"] = serde_json::json!("must survive");
let mut altered = s.clone();
altered.property = Some(serde_json::from_value(raw).unwrap());
assert!(altered.restore().err().unwrap().contains("raw buffers"));
let mut altered = s;
for text in altered.accepted.files.values_mut() {
*text = text.replace(&format!("id \"{}\";", document.project().id), "");
}
// Identity-free accepted source is not silently upgraded during recovery.
// Use a fully valid new declaration to force compiler-assigned identity.
let entry = altered.accepted.entry.clone();
let text = altered.accepted.files.get_mut(&entry).unwrap();
let end = text.rfind('}').unwrap();
text.insert_str(end, "\n oa_activity unassigned_identity \"Draft\" {}\n");
assert!(altered.restore().is_err());
}
}
+114 -6
View File
@@ -11,17 +11,24 @@ use std::collections::{BTreeMap, BTreeSet};
mod constructors;
mod history;
mod keyed;
mod keyed_rows;
mod message_kind;
mod ordered;
mod recovery;
mod scalar;
mod view;
pub(crate) use view::summary;
#[cfg(test)]
mod constructor_tests;
#[cfg(test)]
mod keyed_rows_tests;
#[cfg(test)]
mod keyed_tests;
#[cfg(test)]
mod message_kind_tests;
#[cfg(test)]
mod ordered_tests;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -65,9 +72,19 @@ pub enum Event {
EditPair(FacetSelector),
StageMember,
DropStaged(FacetSelector),
AnnotationAddress(Layer, Option<crate::data_values::FieldPathKey>),
AnnotationBrowse(Layer, Option<crate::data_values::FieldPathKey>, usize),
#[cfg(test)]
RowExpression(FacetSelector, String),
RowText(FacetSelector, text_editor::Action),
RowLocal(FacetSelector, Choice),
OrderMove(OrderedCollection, usize, i32),
OrderReconcile(OrderedCollection),
}
pub struct State {
base_identity: Id,
base_fingerprint: String,
pub anchor: Id,
pub trace: Option<Id>,
pub direction: TransitionDirection,
@@ -78,6 +95,10 @@ pub struct State {
pub resets: BTreeSet<FacetSelector>,
pub staged: BTreeSet<FacetSelector>,
pub presence: BTreeMap<FacetSelector, bool>,
pub row_drafts: BTreeMap<FacetSelector, keyed_rows::RowDraft>,
pub order_drafts: BTreeMap<OrderedCollection, Vec<Id>>,
pub annotation_oa: keyed_rows::AddressDraft,
pub annotation_sa: keyed_rows::AddressDraft,
pub member_family: keyed::Family,
pub member_oa: Option<Choice>,
pub member_sa: Option<Choice>,
@@ -118,6 +139,8 @@ impl State {
.map(|t| t.id)
.or_else(|| (choices.len() == 1).then(|| choices[0].id));
let mut state = Self {
base_identity: project.id,
base_fingerprint: project_fingerprint(project),
anchor,
trace,
direction: if layer == Layer::Oa {
@@ -132,6 +155,10 @@ impl State {
resets: BTreeSet::new(),
staged: BTreeSet::new(),
presence: BTreeMap::new(),
row_drafts: BTreeMap::new(),
order_drafts: BTreeMap::new(),
annotation_oa: Default::default(),
annotation_sa: Default::default(),
member_family: keyed::Family::Literal,
member_oa: None,
member_sa: None,
@@ -169,6 +196,10 @@ impl State {
self.resets.clear();
self.staged.clear();
self.presence.clear();
self.row_drafts.clear();
self.order_drafts.clear();
self.annotation_oa = Default::default();
self.annotation_sa = Default::default();
self.member_oa = None;
self.member_sa = None;
self.literal.clear();
@@ -227,6 +258,7 @@ impl State {
}
Event::Select(facet) => {
self.selected = Some(facet);
self.tab = Tab::Facts;
replan = false;
Ok(())
}
@@ -238,7 +270,16 @@ impl State {
{
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
} else {
if keyed::supported(&facet) {
if let FacetSelector::Order(which) = facet
&& decision == SemanticDecision::Candidate
{
if let Err(error) = self.initialize_order(project, which) {
self.error = Some(error);
return;
}
} else if keyed_rows::supported(&facet) {
self.initialize_row(project, &facet);
} else if keyed::supported(&facet) {
let target_presence = self
.effective_trace(project)
.and_then(|t| capture_facet(project, t.oa, t.sa, &facet).ok())
@@ -332,6 +373,8 @@ impl State {
return;
}
Event::Preview => Ok(()),
Event::OrderMove(which, index, delta) => self.move_order(which, index, delta),
Event::OrderReconcile(which) => self.reconcile_order(project, which),
Event::Reset(facet, reset) => self.reset_history(project, facet, reset),
Event::Presence(facet, present) => {
if keyed::supported(&facet)
@@ -383,6 +426,30 @@ impl State {
}
Ok(())
}
Event::AnnotationAddress(layer, key) => {
self.annotation_address_mut(layer).selected = key;
replan = false;
Ok(())
}
Event::AnnotationBrowse(layer, scope, page) => {
let address = self.annotation_address_mut(layer);
address.scope = scope;
address.page = page;
replan = false;
Ok(())
}
#[cfg(test)]
Event::RowExpression(facet, expression) => self.edit_row(&facet, |draft| {
draft.editor = text_editor::Content::with_text(&expression);
draft.expression = expression;
}),
Event::RowText(facet, action) => self.edit_row(&facet, |draft| {
draft.editor.perform(action);
draft.expression = draft.editor.text();
}),
Event::RowLocal(facet, local) => self.edit_row(&facet, |draft| {
draft.local = Some(local);
}),
};
if let Err(error) = result {
self.error = Some(error);
@@ -431,7 +498,7 @@ impl State {
}
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
if keyed::supported(facet) {
if keyed::supported(facet) || matches!(facet, FacetSelector::Order(_)) {
return self
.effective_trace(project)
.is_some_and(|trace| capture_facet(project, trace.oa, trace.sa, facet).is_ok());
@@ -537,6 +604,15 @@ impl State {
/// 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> {
self.combine(project, form_context, false)
}
fn combine(
&self,
project: &Project,
form_context: bool,
skip_orders: bool,
) -> Result<Value, String> {
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
let (source, target) = if self.direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
@@ -564,15 +640,47 @@ impl State {
// overlapping replacement scopes; this order also supports first-equal
// discriminator acknowledgment together with explicit descendant values.
let mut decisions: Vec<_> = self.decisions.iter().collect();
decisions.sort_by_key(|(facet, _)| match facet {
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => 0,
facet if scalar::grouped(facet) => 1,
_ => 2,
decisions.sort_by_key(|(facet, _)| {
let group = match facet {
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => 0,
facet if scalar::grouped(facet) => 1,
FacetSelector::Order(_) => 3,
_ => 2,
};
(
group,
keyed_rows::target_selector(facet, self.direction.target_layer()),
)
});
for (facet, decision) in decisions {
if *decision == SemanticDecision::KeepTarget {
continue;
}
if let FacetSelector::Order(which) = facet {
if !skip_orders {
// Invalid order drafts remain visible while dependent controls use the
// surviving membership; request construction still rejects them.
if let Err(error) =
self.project_order(project, &trace, &mut final_value, *which, *decision)
&& !form_context
{
return Err(error);
}
}
continue;
}
if matches!(facet, FacetSelector::Member { collection, .. } if ordered::ordered(*collection).is_some())
{
self.project_ordered_member(
project,
&trace,
&mut final_value,
facet,
*decision,
form_context,
)?;
continue;
}
if keyed::supported(facet) {
self.keyed_value(project, &trace, &mut final_value, facet, *decision)?;
continue;
+85 -6
View File
@@ -1,10 +1,12 @@
//! Closed presence-only adapter. The core independently validates the full final record.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Family {
Member(ReferenceCollection),
Literal,
Annotation,
Binding,
}
impl std::fmt::Display for Family {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -16,12 +18,17 @@ impl std::fmt::Display for Family {
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
Self::Member(ReferenceCollection::Constrained) => "Constrained identities",
Self::Member(ReferenceCollection::Steps) => "Ordered steps",
Self::Member(ReferenceCollection::FragmentBranches) => "Fragment branches",
Self::Literal => "Enumeration literals",
_ => "Ordered membership (read-only)",
Self::Annotation => "Message values",
Self::Binding => "Scenario lifeline bindings",
})
}
}
pub(super) const FAMILIES: [Family; 8] = [
pub(super) const FAMILIES: [Family; 12] = [
Family::Member(ReferenceCollection::Steps),
Family::Member(ReferenceCollection::FragmentBranches),
Family::Member(ReferenceCollection::FunctionInputItems),
Family::Member(ReferenceCollection::FunctionOutputItems),
Family::Member(ReferenceCollection::SignalItems),
@@ -30,11 +37,15 @@ pub(super) const FAMILIES: [Family; 8] = [
Family::Member(ReferenceCollection::TransitionEffects),
Family::Member(ReferenceCollection::Constrained),
Family::Literal,
Family::Annotation,
Family::Binding,
];
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
let family = match facet {
FacetSelector::Member { collection, .. } => Family::Member(*collection),
FacetSelector::Literal(_) => Family::Literal,
FacetSelector::Annotation(_) => Family::Annotation,
FacetSelector::Binding(_) => Family::Binding,
_ => return None,
};
FAMILIES.contains(&family).then_some(family)
@@ -43,11 +54,27 @@ pub(super) fn supported(facet: &FacetSelector) -> bool {
family(facet).is_some()
}
// Fixed paths for the seven unordered collections and the exact enum literal set.
// Fixed paths for the closed collections and the exact enum literal set.
// They are selected by the typed facet; callers cannot supply arbitrary paths.
pub(super) fn path(value: &Value, family: Family) -> Option<String> {
let prefixes = ["/body/Oa/Shared", "/body/Sa/Shared"];
let tails: &[&str] = match family {
Family::Member(ReferenceCollection::Steps) => {
return [
"/body/Oa/Process/steps",
"/body/Sa/FunctionalChain/steps",
"/body/Oa/Shared/Scenario/steps",
"/body/Sa/Shared/Scenario/steps",
"/body/Oa/Shared/Occurrence/body/Branch/occurrences",
"/body/Sa/Shared/Occurrence/body/Branch/occurrences",
]
.into_iter()
.find(|p| value.pointer(p).is_some_and(Value::is_array))
.map(str::to_owned);
}
Family::Member(ReferenceCollection::FragmentBranches) => {
&["Occurrence/body/Fragment/branches"]
}
Family::Member(ReferenceCollection::FunctionInputItems) => {
&["Port/contract/FunctionInput/incoming_items"]
}
@@ -57,6 +84,8 @@ pub(super) fn path(value: &Value, family: Family) -> Option<String> {
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
Family::Literal => &["DataType/definition/Enumeration/literals"],
Family::Annotation => &["Occurrence/body/Exchange/values"],
Family::Binding => &["Occurrence/body/ScenarioUse/bindings"],
Family::Member(ReferenceCollection::ExchangeItems) => {
return ["Interaction", "FunctionalExchange", "BoundaryExchange"]
.iter()
@@ -75,14 +104,13 @@ pub(super) fn path(value: &Value, family: Family) -> Option<String> {
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/effects".into());
}
_ => return None,
};
prefixes
.iter()
.flat_map(|prefix| tails.iter().map(move |tail| format!("{prefix}/{tail}")))
.find(|p| value.pointer(p).is_some_and(Value::is_array))
}
fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
pub(super) fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
match facet {
FacetSelector::Member { keys, .. } => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Id")),
@@ -92,6 +120,14 @@ fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
keys.sa.as_ref()
}
.map(|v| Value::String(v.clone())),
FacetSelector::Annotation(keys) => if layer == Layer::Oa {
keys.oa.as_ref()
} else {
keys.sa.as_ref()
}
.map(|key| serde_json::to_value(key).expect("Annotation key")),
FacetSelector::Binding(keys) => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Binding key")),
_ => None,
}
}
@@ -137,6 +173,9 @@ impl State {
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
if keyed_rows::supported(facet) {
return self.project_row(project, trace, value, facet, decision);
}
let present = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::TakeSource => {
@@ -170,6 +209,9 @@ impl State {
}
pub(super) fn member_choices(&self, project: &Project, layer: Layer) -> Vec<Choice> {
if self.member_family == Family::Binding {
return self.binding_choices(project, layer, true);
}
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
@@ -210,6 +252,20 @@ impl State {
) -> Result<(), String> {
self.member_family = family(&facet).ok_or("This keyed family remains read-only.")?;
match facet {
FacetSelector::Annotation(keys) => {
self.annotation_oa.selected = keys.oa.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
});
self.annotation_sa.selected = keys.sa.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
});
}
FacetSelector::Binding(keys) => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
}
FacetSelector::Member { keys, .. } => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
@@ -241,9 +297,32 @@ impl State {
oa: self.literal_oa.then(|| self.literal.clone()),
sa: self.literal_sa.then(|| self.literal.clone()),
}),
Family::Annotation => FacetSelector::Annotation(KeyPair {
oa: self
.annotation_oa
.selected
.as_ref()
.map(|key| AnnotationKey {
path: key.path.clone(),
field: key.field,
}),
sa: self
.annotation_sa
.selected
.as_ref()
.map(|key| AnnotationKey {
path: key.path.clone(),
field: key.field,
}),
}),
Family::Binding => FacetSelector::Binding(KeyPair {
oa: self.member_oa.as_ref().map(|c| c.id),
sa: self.member_sa.as_ref().map(|c| c.id),
}),
};
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
capture_facet(project, trace.oa, trace.sa, &facet)?;
self.stage_row_references(&facet)?;
if let FacetSelector::Member {
keys:
KeyPair {
@@ -0,0 +1,345 @@
//! Atomic message-value and lifeline-binding rows with exact, immutable keys.
use super::*;
use crate::data_values::{FieldPathChoice, FieldPathKey};
#[derive(Default)]
pub struct AddressDraft {
pub selected: Option<FieldPathKey>,
pub scope: Option<FieldPathKey>,
pub page: usize,
}
#[derive(Default)]
pub struct RowDraft {
pub expression: String,
pub editor: text_editor::Content,
pub local: Option<Choice>,
}
pub(super) fn supported(facet: &FacetSelector) -> bool {
matches!(
facet,
FacetSelector::Annotation(_) | FacetSelector::Binding(_)
)
}
pub(super) fn target_selector(facet: &FacetSelector, layer: Layer) -> FacetSelector {
match facet {
FacetSelector::Annotation(keys) => FacetSelector::Annotation(KeyPair {
oa: (layer == Layer::Oa).then(|| keys.oa.clone()).flatten(),
sa: (layer == Layer::Sa).then(|| keys.sa.clone()).flatten(),
}),
FacetSelector::Binding(keys) => FacetSelector::Binding(KeyPair {
oa: if layer == Layer::Oa { keys.oa } else { None },
sa: if layer == Layer::Sa { keys.sa } else { None },
}),
_ => facet.clone(),
}
}
impl State {
pub(super) fn annotation_address(&self, layer: Layer) -> &AddressDraft {
if layer == Layer::Oa {
&self.annotation_oa
} else {
&self.annotation_sa
}
}
pub(super) fn annotation_address_mut(&mut self, layer: Layer) -> &mut AddressDraft {
if layer == Layer::Oa {
&mut self.annotation_oa
} else {
&mut self.annotation_sa
}
}
fn row_owner(&self, project: &Project, layer: Layer) -> Option<Element> {
let trace = self.effective_trace(project)?;
let id = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
if layer == self.direction.target_layer()
&& let Ok(value) = self.combined_value(project, true)
&& let Ok(element) = serde_json::from_value(value)
{
return Some(element);
}
project.elements.get(&id).cloned()
}
pub(super) fn annotation_choices(
&self,
project: &Project,
layer: Layer,
) -> Result<Vec<FieldPathChoice>, String> {
let element = self.row_owner(project, layer).ok_or("Choose a trace.")?;
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
message_kind,
..
},
..
}) = element.shared()
else {
return Err(
"Choose an exchange on this message before selecting a carried field.".into(),
);
};
let exchange = project
.relations
.get(exchange)
.ok_or("The message exchange is unavailable.")?;
crate::data_values::at_scope(
project,
exchange,
message_kind,
self.annotation_address(layer).scope.as_ref(),
)
}
pub(super) fn binding_choices(
&self,
project: &Project,
layer: Layer,
referenced: bool,
) -> Vec<Choice> {
let Some(element) = self.row_owner(project, layer) else {
return vec![];
};
let scenario = if referenced {
match element.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { scenario, .. },
..
}) => *scenario,
_ => None,
}
} else {
element
.parent
.and_then(|parent| validation::ancestor_of_kind(project, parent, Kind::Scenario))
};
let mut choices: Vec<_> = project
.elements
.values()
.filter(|e| {
e.kind() == Kind::Lifeline
&& e.layer() == layer
&& scenario.is_some()
&& e.parent == scenario
})
.map(|e| view::choice(project, e.id))
.collect();
choices.sort_by_cached_key(|c| c.label.to_lowercase());
choices
}
pub(super) fn stage_row_references(&mut self, facet: &FacetSelector) -> Result<(), String> {
let pairs: Vec<_> = match facet {
FacetSelector::Annotation(KeyPair {
oa: Some(oa),
sa: Some(sa),
}) => {
if oa.path.len() != sa.path.len() {
return Err("Paired annotation paths must have the same depth; review distinct addresses separately.".into());
}
oa.path
.iter()
.chain(std::iter::once(&oa.field))
.copied()
.zip(sa.path.iter().chain(std::iter::once(&sa.field)).copied())
.collect()
}
FacetSelector::Binding(KeyPair {
oa: Some(oa),
sa: Some(sa),
}) => vec![(*oa, *sa)],
_ => vec![],
};
// A repeated recursive field may occur several times in one address.
// Never let the last occurrence silently replace a conflicting pair.
let mut supplied = BTreeMap::new();
for (oa, sa) in pairs {
if supplied.insert(oa, sa).is_some_and(|prior| prior != sa) {
return Err(
"This address pairs the same Operational field with different System fields."
.into(),
);
}
}
self.references.extend(supplied);
Ok(())
}
pub(super) fn initialize_row(&mut self, project: &Project, facet: &FacetSelector) {
if self.row_drafts.contains_key(facet) {
return;
}
let value = self
.effective_trace(project)
.and_then(|t| capture_facet(project, t.oa, t.sa, facet).ok())
.and_then(|f| scalar::value(&f, self.direction.target_layer()));
let present = value.as_ref().is_some_and(|v| !v.is_null());
let mut draft = RowDraft::default();
if let Some(value) = value {
draft.expression = value["expression"].as_str().unwrap_or_default().into();
draft.editor = text_editor::Content::with_text(&draft.expression);
draft.local = value["local"]
.as_str()
.and_then(|v| v.parse().ok())
.map(|id| view::choice(project, id));
}
self.presence.entry(facet.clone()).or_insert(present);
self.row_drafts.insert(facet.clone(), draft);
}
pub(super) fn edit_row(
&mut self,
facet: &FacetSelector,
action: impl FnOnce(&mut RowDraft),
) -> Result<(), String> {
if !supported(facet)
|| self.selected.as_ref() != Some(facet)
|| self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
{
return Err("Select this row and choose Set final value before editing it.".into());
}
action(
self.row_drafts
.get_mut(facet)
.ok_or("The row draft is unavailable.")?,
);
Ok(())
}
pub(super) fn project_row(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
if decision == SemanticDecision::KeepTarget {
return Ok(());
}
let source_layer = self.direction.source_layer();
let target_layer = self.direction.target_layer();
if decision == SemanticDecision::TakeSource && keyed::key(facet, source_layer).is_none() {
return Ok(());
}
let target_key = keyed::key(facet, target_layer)
.ok_or("Choose the destination row address before setting or taking its value.")?;
let row = if decision == SemanticDecision::Candidate {
if !self.presence.get(facet).copied().unwrap_or(false) {
Value::Null
} else {
let draft = self
.row_drafts
.get(facet)
.ok_or("Set the final row value.")?;
match facet {
FacetSelector::Annotation(_) => {
let key: AnnotationKey = serde_json::from_value(target_key.clone())
.map_err(|e| e.to_string())?;
serde_json::to_value(MessageValue {
path: key.path,
field: key.field,
expression: draft.expression.clone(),
})
.expect("Message value")
}
FacetSelector::Binding(_) => {
serde_json::json!({"referenced": target_key, "local": draft.local.as_ref().ok_or("Choose the destination scenario's local lifeline.")?.id})
}
_ => return Err("Unsupported row.".into()),
}
}
} else {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
let row = scalar::value(&fact, source_layer)
.ok_or("Source row collection is not applicable; review its constructor first.")?;
if row.is_null() {
row
} else {
let map = |id: Id| -> Result<Id, String> {
if source_layer == Layer::Oa {
self.references.get(&id).copied().ok_or_else(|| {
format!("Choose a reference counterpart for {}.", project.named(id))
})
} else {
let matches: Vec<_> = self
.references
.iter()
.filter(|(_, sa)| **sa == id)
.map(|(oa, _)| *oa)
.collect();
if matches.len() == 1 {
Ok(matches[0])
} else {
Err(format!(
"Choose one unambiguous reference counterpart for {}.",
project.named(id)
))
}
}
};
match facet {
FacetSelector::Annotation(_) => {
let mut row: MessageValue =
serde_json::from_value(row).map_err(|e| e.to_string())?;
row.path = row.path.into_iter().map(map).collect::<Result<_, _>>()?;
row.field = map(row.field)?;
if serde_json::to_value(AnnotationKey::from(&row)).expect("key")
!= target_key
{
return Err("Mapped annotation address disagrees with the selected destination address.".into());
}
serde_json::to_value(row).expect("row")
}
FacetSelector::Binding(_) => {
let row: LifelineBinding =
serde_json::from_value(row).map_err(|e| e.to_string())?;
let mapped = LifelineBinding {
referenced: map(row.referenced)?,
local: map(row.local)?,
};
if serde_json::to_value(mapped.referenced).expect("Id") != target_key {
return Err("Mapped binding key disagrees with the selected destination lifeline.".into());
}
serde_json::to_value(mapped).expect("row")
}
_ => return Err("Unsupported row.".into()),
}
}
};
let family = keyed::family(facet).ok_or("Unsupported row.")?;
let path = keyed::path(value, family)
.ok_or("The destination constructor has no collection for this row.")?;
let rows = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Destination rows are unavailable.")?;
let position = rows.iter().position(|row| match facet {
FacetSelector::Annotation(_) => {
serde_json::json!({"path": row["path"], "field": row["field"]}) == target_key
}
FacetSelector::Binding(_) => row["referenced"] == target_key,
_ => false,
});
match (position, row.is_null()) {
(Some(index), false) => rows[index] = row,
(Some(index), true) => {
rows.remove(index);
}
(None, false) => rows.push(row),
(None, true) => {}
}
Ok(())
}
}
@@ -0,0 +1,736 @@
use super::{tests::*, *};
use crate::{app::App, document::Document};
struct Fixture {
p: Project,
owners: [Id; 2],
trace: Id,
facet: FacetSelector,
sibling: FacetSelector,
locals: [Id; 2],
}
fn errors(s: &State) -> String {
s.error
.iter()
.chain(s.plan.iter().flat_map(|p| &p.conflicts))
.cloned()
.collect::<Vec<_>>()
.join("; ")
}
fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option<Id>) -> Id {
p.add_element(kind, layer, kind.label(), parent).unwrap()
}
fn fixture(binding: bool) -> Fixture {
let mut p = Project::empty();
let mut owners = vec![];
let mut keys = vec![];
let mut sibling_keys = vec![];
let mut locals = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let scenario = add(&mut p, Kind::Scenario, layer, None);
let owner = add(&mut p, Kind::Occurrence, layer, Some(scenario));
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(owner);
let body = if binding {
let reused = add(&mut p, Kind::Scenario, layer, None);
let referenced = add(&mut p, Kind::Lifeline, layer, Some(reused));
let sibling = add(&mut p, Kind::Lifeline, layer, Some(reused));
let local = add(&mut p, Kind::Lifeline, layer, Some(scenario));
let sibling_local = add(&mut p, Kind::Lifeline, layer, Some(scenario));
keys.push(AnnotationKey {
path: vec![],
field: referenced,
});
sibling_keys.push(AnnotationKey {
path: vec![],
field: sibling,
});
locals.push(local);
OccurrenceBody::ScenarioUse {
scenario: Some(reused),
bindings: vec![
LifelineBinding { referenced, local },
LifelineBinding {
referenced: sibling,
local: sibling_local,
},
],
}
} else {
let work = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let from = add(&mut p, work, layer, None);
let to = add(&mut p, work, layer, None);
let item = add(&mut p, Kind::ExchangeItem, layer, None);
let record = add(&mut p, Kind::DataType, layer, None);
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
let first = add(&mut p, Kind::DataField, layer, Some(item));
let second = add(&mut p, Kind::DataField, layer, Some(item));
for field in [first, second] {
if let Some(SharedBody::DataField { type_ref, .. }) =
p.elements.get_mut(&field).unwrap().shared_mut()
{
*type_ref = TypeRef::Named(record);
}
}
let leaf = add(&mut p, Kind::DataField, layer, Some(record));
let exchange = p.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"Carrier",
from,
to,
);
let ports = match &mut p.relations.get_mut(&exchange).unwrap().body {
RelationBody::Interaction { items, .. } => {
items.push(item);
None
}
RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} => {
items.push(item);
Some((*source_port, *target_port))
}
_ => unreachable!(),
};
if let Some((output, input)) = ports {
if let Some(SharedBody::Port {
contract: PortContract::FunctionOutput { outgoing_items },
..
}) = p.elements.get_mut(&output).unwrap().shared_mut()
{
outgoing_items.push(item);
}
if let Some(SharedBody::Port {
contract: PortContract::FunctionInput { incoming_items },
..
}) = p.elements.get_mut(&input).unwrap().shared_mut()
{
incoming_items.push(item);
}
}
keys.push(AnnotationKey {
path: vec![first],
field: leaf,
});
sibling_keys.push(AnnotationKey {
path: vec![second],
field: leaf,
});
locals.push(leaf);
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![
MessageValue {
path: vec![first],
field: leaf,
expression: if layer == Layer::Oa {
"operational"
} else {
"system"
}
.into(),
},
MessageValue {
path: vec![second],
field: leaf,
expression: "sibling stays".into(),
},
],
}
};
*p.elements.get_mut(&owner).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: "Retain precondition".into(),
postcondition: "Retain postcondition".into(),
};
owners.push(owner);
}
let trace = add_trace(&mut p, owners[0], owners[1], "Individual row review");
let facet = |keys: &[AnnotationKey]| {
if binding {
FacetSelector::Binding(KeyPair {
oa: Some(keys[0].field),
sa: Some(keys[1].field),
})
} else {
FacetSelector::Annotation(KeyPair {
oa: Some(keys[0].clone()),
sa: Some(keys[1].clone()),
})
}
};
validate_candidate(&p).unwrap();
Fixture {
p,
owners: [owners[0], owners[1]],
trace,
facet: facet(&keys),
sibling: facet(&sibling_keys),
locals: [locals[0], locals[1]],
}
}
fn ready(f: &Fixture, reverse: bool) -> State {
let mut s = State::new(&f.p, f.owners[usize::from(reverse)]).unwrap();
s.stage_row_references(&f.facet).unwrap();
s.references.insert(f.locals[0], f.locals[1]);
s.update(&f.p, Event::Select(f.facet.clone()));
s
}
#[test]
fn independent_rows_keep_take_candidate_match_core_and_preserve_siblings_both_directions() {
for binding in [false, true] {
for reverse in [false, true] {
for decision in [
SemanticDecision::KeepTarget,
SemanticDecision::TakeSource,
SemanticDecision::Candidate,
] {
let f = fixture(binding);
let mut s = ready(&f, reverse);
let layer = s.direction.target_layer();
let before_sibling = scalar::value(
&capture_facet(&f.p, f.owners[0], f.owners[1], &f.sibling).unwrap(),
layer,
);
s.update(&f.p, Event::Decide(f.facet.clone(), Some(decision)));
if decision == SemanticDecision::Candidate {
s.update(&f.p, Event::Presence(f.facet.clone(), !binding));
if !binding {
s.update(
&f.p,
Event::RowExpression(f.facet.clone(), f.locals[0].to_string()),
);
}
}
// Force the GUI to compose the row alongside another candidate,
// so Take/Keep also exercise adapter scope and mapped references.
let header = FacetSelector::Header(HeaderFacet::Description);
s.update(&f.p, Event::Select(header.clone()));
s.update(
&f.p,
Event::Decide(header, Some(SemanticDecision::Candidate)),
);
s.update(
&f.p,
Event::Input("/description".into(), "Reviewed one row".into()),
);
assert!(
s.can_apply(),
"{binding}/{reverse}/{decision:?}: {}",
errors(&s)
);
let proposed = s.proposed.as_ref().unwrap().clone();
assert_eq!(
scalar::value(
&capture_facet(&proposed, f.owners[0], f.owners[1], &f.sibling).unwrap(),
layer
),
before_sibling
);
let current = scalar::value(
&capture_facet(&proposed, f.owners[0], f.owners[1], &f.facet).unwrap(),
layer,
)
.unwrap();
if decision == SemanticDecision::Candidate {
if binding {
assert!(current.is_null());
} else {
assert_eq!(current["expression"], f.locals[0].to_string());
}
}
assert_eq!(
prepare_command(&f.p, &s.command(&f.p).unwrap())
.unwrap()
.project,
proposed
);
let _ = s.view(&f.p);
}
}
}
}
#[test]
fn keyed_row_drafts_retain_expression_local_and_presence_across_decisions_and_staging() {
for binding in [false, true] {
let f = fixture(binding);
let mut s = ready(&f, false);
s.staged.insert(f.facet.clone());
s.update(
&f.p,
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
);
if binding {
s.update(
&f.p,
Event::RowLocal(f.facet.clone(), view::choice(&f.p, f.locals[1])),
);
} else {
s.update(
&f.p,
Event::RowExpression(f.facet.clone(), "unfinished (\n literal UUID text".into()),
);
}
s.update(&f.p, Event::Presence(f.facet.clone(), false));
s.update(
&f.p,
Event::Decide(f.facet.clone(), Some(SemanticDecision::KeepTarget)),
);
s.update(&f.p, Event::DropStaged(f.facet.clone()));
s.update(&f.p, Event::Select(f.facet.clone()));
s.update(
&f.p,
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
);
assert!(!s.presence[&f.facet]);
if binding {
assert_eq!(
s.row_drafts[&f.facet].local.as_ref().unwrap().id,
f.locals[1]
);
} else {
assert_eq!(
s.row_drafts[&f.facet].expression,
"unfinished (\n literal UUID text"
);
}
assert!(s.can_apply(), "{}", errors(&s));
// A forged event for an unselected sibling cannot alter its row draft.
s.update(
&f.p,
Event::RowExpression(f.sibling.clone(), "wrong row".into()),
);
assert!(s.error.as_ref().unwrap().contains("Select this row"));
}
}
#[test]
fn annotation_address_browser_descends_qualified_fields_without_changing_rows() {
let f = fixture(false);
let mut s = ready(&f, false);
s.update(&f.p, Event::MemberFamily(keyed::Family::Annotation));
for layer in [Layer::Oa, Layer::Sa] {
let roots = s.annotation_choices(&f.p, layer).unwrap();
assert_eq!(roots.len(), 2);
let key = roots[0].key.clone();
s.update(&f.p, Event::AnnotationBrowse(layer, Some(key.clone()), 0));
let nested = s.annotation_choices(&f.p, layer).unwrap();
assert_eq!(nested.len(), 1);
assert_eq!(nested[0].key.path, vec![key.field]);
s.update(
&f.p,
Event::AnnotationAddress(layer, Some(nested[0].key.clone())),
);
}
s.update(&f.p, Event::StageMember);
assert!(s.error.is_none(), "{}", errors(&s));
assert_eq!(s.staged.len(), 1);
assert!(s.decisions.is_empty());
let _ = s.view(&f.p);
}
#[test]
fn binding_choices_separate_reused_keys_from_local_lifelines() {
let f = fixture(true);
let mut s = ready(&f, false);
s.update(&f.p, Event::MemberFamily(keyed::Family::Binding));
for (index, layer) in [Layer::Oa, Layer::Sa].into_iter().enumerate() {
assert_eq!(s.member_choices(&f.p, layer).len(), 2);
assert!(
!s.member_choices(&f.p, layer)
.iter()
.any(|c| c.id == f.locals[index])
);
let local_choices = s.binding_choices(&f.p, layer, false);
assert_eq!(local_choices.len(), 2);
assert!(local_choices.iter().any(|c| c.id == f.locals[index]));
}
}
#[test]
fn binding_local_picker_follows_a_candidate_parent_before_the_review_is_valid() {
let mut f = fixture(true);
let destination = add(&mut f.p, Kind::Scenario, Layer::Sa, None);
let local = add(&mut f.p, Kind::Lifeline, Layer::Sa, Some(destination));
let mut s = ready(&f, false);
let parent = FacetSelector::Header(HeaderFacet::Parent);
s.update(&f.p, Event::Select(parent.clone()));
s.update(
&f.p,
Event::Decide(parent, Some(SemanticDecision::Candidate)),
);
s.update(
&f.p,
Event::Shape("/parent".into(), serde_json::json!(destination)),
);
assert_eq!(
s.binding_choices(&f.p, Layer::Sa, false)
.iter()
.map(|c| c.id)
.collect::<Vec<_>>(),
vec![local]
);
assert!(
!s.binding_choices(&f.p, Layer::Sa, false)
.iter()
.any(|c| c.id == f.locals[1])
);
}
#[test]
fn row_review_apply_preserves_exact_source_and_semantic_history_through_undo_redo() {
for binding in [false, true] {
let f = fixture(binding);
let mut app = App::with_project(f.p);
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry.clone())
.unwrap()
.insert_str(0, "// Exact row rationale\r\n");
app.document.apply_bundle(bundle).unwrap();
let before = app.document.snapshot();
start(&mut app, f.owners[0]);
send(&mut app, Event::EditPair(f.facet.clone()));
send(&mut app, Event::StageMember);
send(&mut app, Event::Select(f.facet.clone()));
send(
&mut app,
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
);
send(&mut app, Event::Presence(f.facet.clone(), false));
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
let expected = state(&app).proposed.as_ref().unwrap().clone();
apply(&mut app);
assert_eq!(app.project(), &expected);
assert!(
app.project().traces[&f.trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets
.iter()
.any(|r| r.selector() == f.facet)
);
let after = app.document.snapshot();
assert!(
app.document.bundle().files[&app.document.bundle().entry]
.starts_with("// Exact row rationale\r\n")
);
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(), &after.bundle);
assert_eq!(app.project(), &after.decoded.project);
assert_eq!(
Document::from_bundle(
after.bundle.clone(),
source_project::ProjectLimits::default()
)
.unwrap()
.project(),
&expected
);
}
}
#[test]
fn keyed_rows_distinguish_unpaired_source_from_explicit_absence_and_require_exact_reset() {
for binding in [false, true] {
let mut f = fixture(binding);
let (source_only, target_only) = match &f.facet {
FacetSelector::Annotation(keys) => (
FacetSelector::Annotation(KeyPair {
oa: keys.oa.clone(),
sa: None,
}),
FacetSelector::Annotation(KeyPair {
oa: None,
sa: keys.sa.clone(),
}),
),
FacetSelector::Binding(keys) => (
FacetSelector::Binding(KeyPair {
oa: keys.oa,
sa: None,
}),
FacetSelector::Binding(KeyPair {
oa: None,
sa: keys.sa,
}),
),
_ => unreachable!(),
};
assert!(keyed::overlap(&source_only, &f.facet));
assert!(!keyed::overlap(&f.sibling, &f.facet));
let prior = capture_facet(&f.p, f.owners[0], f.owners[1], &source_only).unwrap();
f.p.traces.get_mut(&f.trace).unwrap().baseline = Some(TraceBaseline {
oa: f.p.properties(f.owners[0]).unwrap(),
sa: f.p.properties(f.owners[1]).unwrap(),
semantic: Some(SemanticBaseline {
facets: vec![prior],
}),
});
let mut s = ready(&f, false);
s.update(
&f.p,
Event::Decide(f.facet.clone(), Some(SemanticDecision::KeepTarget)),
);
assert!(
!s.can_apply(),
"An old one-sided address cannot silently acquire a paired history"
);
s.update(&f.p, Event::Reset(source_only.clone(), true));
assert!(s.can_apply(), "{}", errors(&s));
let next = s.proposed.as_ref().unwrap();
let facts = &next.traces[&f.trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets;
assert!(
facts
.iter()
.any(|f| f.selector() == s.selected.clone().unwrap())
);
assert!(!facts.iter().any(|f| f.selector() == source_only));
f.p.traces.get_mut(&f.trace).unwrap().baseline = None;
if let Some(SharedBody::Occurrence { body, .. }) =
f.p.elements.get_mut(&f.owners[0]).unwrap().shared_mut()
{
match body {
OccurrenceBody::Exchange { values, .. } => values.clear(),
OccurrenceBody::ScenarioUse { bindings, .. } => bindings.clear(),
_ => unreachable!(),
}
}
for (facet, expect_absent) in [(target_only, false), (f.facet.clone(), true)] {
let mut s = ready(&f, false);
s.update(
&f.p,
Event::Decide(facet, Some(SemanticDecision::TakeSource)),
);
let header = FacetSelector::Header(HeaderFacet::Description);
s.update(
&f.p,
Event::Decide(header.clone(), Some(SemanticDecision::Candidate)),
);
s.update(&f.p, Event::Select(header));
s.update(
&f.p,
Event::Input("/description".into(), "Compose explicit absence".into()),
);
assert!(s.can_apply(), "{}", errors(&s));
let fact = capture_facet(
s.proposed.as_ref().unwrap(),
f.owners[0],
f.owners[1],
&f.facet,
)
.unwrap();
assert_eq!(
scalar::value(&fact, Layer::Sa).unwrap().is_null(),
expect_absent
);
}
}
}
#[test]
fn binding_take_requires_local_correspondence_and_candidate_rejects_wrong_scenario() {
let f = fixture(true);
let mut s = ready(&f, false);
s.references.remove(&f.locals[0]);
s.update(
&f.p,
Event::Decide(f.facet.clone(), Some(SemanticDecision::TakeSource)),
);
assert!(!s.can_apply());
s.references.insert(f.locals[0], f.locals[1]);
s.update(&f.p, Event::Preview);
assert!(s.can_apply(), "{}", errors(&s));
s.update(
&f.p,
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
);
let wrong = s.binding_choices(&f.p, Layer::Sa, true)[0].clone();
s.update(&f.p, Event::RowLocal(f.facet.clone(), wrong));
assert!(!s.can_apply());
assert!(
s.row_drafts[&f.facet].local.is_some(),
"Invalid typed selection remains reviewable"
);
s.update(
&f.p,
Event::RowLocal(f.facet.clone(), view::choice(&f.p, f.locals[1])),
);
assert!(s.can_apply(), "{}", errors(&s));
}
#[test]
fn annotation_observations_keep_uuid_shaped_expression_text_in_both_sides_and_history() {
let mut f = fixture(false);
for owner in f.owners {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = f.p.elements.get_mut(&owner).unwrap().shared_mut()
{
values[0].expression = values[0].field.to_string();
}
}
let historical = capture_facet(&f.p, f.owners[0], f.owners[1], &f.facet).unwrap();
for layer in [Layer::Oa, Layer::Sa] {
let expression = scalar::value(&historical, layer).unwrap()["expression"]
.as_str()
.unwrap()
.to_owned();
let display = view::observed(&f.p, Some(&historical), layer);
assert!(
display.contains(&format!("Expression: {expression:?}")),
"{display}"
);
}
}
#[test]
fn keyed_recovery_retains_raw_rows_and_staged_pairs_both_directions_without_applying() {
for binding in [false, true] {
for reverse in [false, true] {
let f = fixture(binding);
let mut s = ready(&f, reverse);
s.staged.insert(f.facet.clone());
s.update(
&f.p,
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
);
if !binding {
s.update(
&f.p,
Event::RowExpression(f.facet.clone(), "unfinished (\n exact raw draft".into()),
);
}
s.update(&f.p, Event::Presence(f.facet.clone(), false));
s.update(&f.p, Event::EditPair(f.facet.clone()));
let saved = s.recovery_snapshot().unwrap();
let restored = State::from_recovery(&f.p, saved).unwrap();
assert_eq!(restored.direction, s.direction);
assert_eq!(restored.decisions, s.decisions);
assert_eq!(restored.references, s.references);
assert_eq!(restored.presence, s.presence);
assert_eq!(restored.staged, s.staged);
assert_eq!(
restored.row_drafts[&f.facet].expression,
s.row_drafts[&f.facet].expression
);
assert_eq!(
restored.row_drafts[&f.facet].local,
s.row_drafts[&f.facet].local
);
assert_eq!(restored.proposed, s.proposed);
assert_eq!(restored.annotation_oa.selected, s.annotation_oa.selected);
assert_eq!(restored.annotation_sa.selected, s.annotation_sa.selected);
assert!(restored.can_apply(), "{}", errors(&restored));
}
}
}
#[test]
fn multiple_new_rows_compose_in_empty_collections_using_destination_key_order() {
for binding in [false, true] {
for reverse in [false, true] {
let mut f = fixture(binding);
let target_layer = if reverse { Layer::Oa } else { Layer::Sa };
let target = f.owners[usize::from(!reverse)];
let facets = [f.facet.clone(), f.sibling.clone()];
let original: Vec<_> = facets
.iter()
.map(|facet| {
scalar::value(
&capture_facet(&f.p, f.owners[0], f.owners[1], facet).unwrap(),
target_layer,
)
.unwrap()
})
.collect();
if let Some(SharedBody::Occurrence { body, .. }) =
f.p.elements.get_mut(&target).unwrap().shared_mut()
{
match body {
OccurrenceBody::Exchange { values, .. } => values.clear(),
OccurrenceBody::ScenarioUse { bindings, .. } => bindings.clear(),
_ => unreachable!(),
}
}
let object_count = f.p.elements.len();
let mut s = ready(&f, reverse);
for (facet, row) in facets.iter().zip(&original).rev() {
s.update(&f.p, Event::EditPair(facet.clone()));
s.update(&f.p, Event::StageMember);
s.update(
&f.p,
Event::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
);
s.update(&f.p, Event::Presence(facet.clone(), true));
if binding {
let local = row["local"].as_str().unwrap().parse().unwrap();
s.update(
&f.p,
Event::RowLocal(facet.clone(), view::choice(&f.p, local)),
);
} else {
s.update(
&f.p,
Event::RowExpression(facet.clone(), "new independent value".into()),
);
}
}
assert!(s.can_apply(), "{binding}/{reverse}: {}", errors(&s));
let proposed = s.proposed.as_ref().unwrap();
assert_eq!(proposed.elements.len(), object_count);
for facet in facets {
assert!(
!scalar::value(
&capture_facet(proposed, f.owners[0], f.owners[1], &facet).unwrap(),
target_layer
)
.unwrap()
.is_null()
);
}
}
}
}
@@ -0,0 +1,254 @@
//! Independent membership and complete final order; the core validates every proposal.
use super::*;
pub(super) fn collection(which: OrderedCollection) -> ReferenceCollection {
match which {
OrderedCollection::Steps => ReferenceCollection::Steps,
OrderedCollection::FragmentBranches => ReferenceCollection::FragmentBranches,
}
}
pub(super) fn ordered(which: ReferenceCollection) -> Option<OrderedCollection> {
match which {
ReferenceCollection::Steps => Some(OrderedCollection::Steps),
ReferenceCollection::FragmentBranches => Some(OrderedCollection::FragmentBranches),
_ => None,
}
}
pub(super) fn read(value: &Value, which: OrderedCollection) -> Result<Vec<Id>, String> {
let path = keyed::path(value, keyed::Family::Member(collection(which)))
.ok_or("This ordered collection is unavailable on the destination.")?;
serde_json::from_value(value.pointer(&path).unwrap().clone()).map_err(|e| e.to_string())
}
fn write(value: &mut Value, which: OrderedCollection, ids: Vec<Id>) -> Result<(), String> {
let path = keyed::path(value, keyed::Family::Member(collection(which)))
.ok_or("This ordered collection is unavailable on the destination.")?;
*value.pointer_mut(&path).unwrap() = serde_json::to_value(ids).expect("Ordered identities");
Ok(())
}
impl State {
pub(super) fn ordered_members(
&self,
project: &Project,
which: OrderedCollection,
) -> Result<Vec<Id>, String> {
read(&self.combine(project, true, true)?, which)
}
pub(super) fn initialize_order(
&mut self,
project: &Project,
which: OrderedCollection,
) -> Result<(), String> {
if !self.order_drafts.contains_key(&which) {
let ids = self.ordered_members(project, which)?;
self.order_drafts.insert(which, ids);
}
Ok(())
}
pub(super) fn reconcile_order(
&mut self,
project: &Project,
which: OrderedCollection,
) -> Result<(), String> {
self.require_order_candidate(which)?;
let members = self.ordered_members(project, which)?;
let draft = self
.order_drafts
.get_mut(&which)
.ok_or("Choose a final order first.")?;
// This is an explicit user action. Ordinary refresh never discards an authored order.
let mut seen = BTreeSet::new();
draft.retain(|id| members.contains(id) && seen.insert(*id));
for id in members {
if seen.insert(id) {
draft.push(id);
}
}
Ok(())
}
fn require_order_candidate(&self, which: OrderedCollection) -> Result<(), String> {
let facet = FacetSelector::Order(which);
if self.selected.as_ref() != Some(&facet)
|| self.decisions.get(&facet) != Some(&SemanticDecision::Candidate)
{
return Err("Select Set final value for this order before changing it.".into());
}
Ok(())
}
pub(super) fn move_order(
&mut self,
which: OrderedCollection,
index: usize,
delta: i32,
) -> Result<(), String> {
self.require_order_candidate(which)?;
let ids = self
.order_drafts
.get_mut(&which)
.ok_or("Choose a final order first.")?;
let destination = match delta {
-1 => index.checked_sub(1),
1 => index.checked_add(1),
_ => None,
};
let destination = destination
.filter(|i| index < ids.len() && *i < ids.len())
.ok_or("Choose an existing adjacent position in the final order.")?;
ids.swap(index, destination);
Ok(())
}
pub(super) fn project_order(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
which: OrderedCollection,
decision: SemanticDecision,
) -> Result<(), String> {
let current = read(value, which)?;
let supplied = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::Candidate => self
.order_drafts
.get(&which)
.cloned()
.ok_or("Choose a final order.")?,
SemanticDecision::TakeSource => {
let fact =
capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
let source: Vec<Id> = serde_json::from_value(
scalar::value(&fact, self.direction.source_layer())
.ok_or("The source has no applicable order.")?,
)
.map_err(|e| e.to_string())?;
let map = self.order_reference_map()?;
source
.into_iter()
.map(|id| {
map.get(&id).copied().ok_or_else(|| {
format!(
"Pair source order member {} explicitly in Reference pairs.",
project.named(id)
)
})
})
.collect::<Result<Vec<_>, _>>()?
}
};
let actual: BTreeSet<_> = current.iter().copied().collect();
let desired: BTreeSet<_> = supplied.iter().copied().collect();
if desired.len() != supplied.len() {
return Err(
"The final order contains a duplicate identity; reconcile its members explicitly."
.into(),
);
}
if desired != actual {
if decision == SemanticDecision::TakeSource
&& desired.is_subset(&actual)
&& current
.iter()
.filter(|id| desired.contains(id))
.copied()
.collect::<Vec<_>>()
== supplied
{
return Ok(());
}
return Err("Final order must contain every surviving destination member exactly once. Reconcile members, then choose their positions; membership is reviewed separately.".into());
}
write(value, which, supplied)
}
fn order_reference_map(&self) -> Result<BTreeMap<Id, Id>, String> {
let mut map = BTreeMap::new();
for (oa, sa) in &self.references {
let (a, b) = if self.direction == TransitionDirection::OaToSa {
(*oa, *sa)
} else {
(*sa, *oa)
};
if map.insert(a, b).is_some_and(|old| old != b) {
return Err("Reverse reference correspondence is ambiguous.".into());
}
}
Ok(map)
}
pub(super) fn project_ordered_member(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
form_context: bool,
) -> Result<(), String> {
let FacetSelector::Member {
collection: member_collection,
keys,
} = facet
else {
unreachable!()
};
let which = ordered(*member_collection).expect("Ordered member");
let before = read(value, which)?;
self.keyed_value(project, trace, value, facet, decision)?;
let after = read(value, which)?;
let explicit = self
.decisions
.get(&FacetSelector::Order(which))
.is_some_and(|d| *d != SemanticDecision::KeepTarget);
if decision != SemanticDecision::TakeSource || explicit || after.len() <= before.len() {
return Ok(());
}
let (source_key, target_key) = if self.direction == TransitionDirection::OaToSa {
(keys.oa, keys.sa)
} else {
(keys.sa, keys.oa)
};
let (Some(source_key), Some(target_key)) = (source_key, target_key) else {
return Ok(());
};
let fact = capture_facet(project, trace.oa, trace.sa, &FacetSelector::Order(which))?;
let source: Vec<Id> = serde_json::from_value(
scalar::value(&fact, self.direction.source_layer())
.ok_or("Source order is unavailable.")?,
)
.map_err(|e| e.to_string())?;
let position = source
.iter()
.position(|id| *id == source_key)
.ok_or("Source member is absent.")?;
let map = self.order_reference_map()?;
let anchor = |id: &Id| {
map.get(id)
.and_then(|mapped| before.iter().position(|id| id == mapped))
};
let left = source[..position].iter().rev().find_map(anchor);
let right = source[position + 1..].iter().find_map(anchor);
let at = match (left, right) {
(Some(a), Some(b)) if a + 1 == b => Some(b),
(Some(a), None) if a + 1 == before.len() => Some(a + 1),
(None, Some(0)) => Some(0),
(None, None) if before.is_empty() => Some(0),
_ => None,
};
if let Some(at) = at {
let mut ordered = before;
ordered.insert(at, target_key);
write(value, which, ordered)
} else if form_context {
// An uncommitted membership list lets the user author a final order.
// The actual request still rejects ambiguous placement until explicitly decided.
Ok(())
} else {
Err("Ordered member insertion has no unique unchanged anchor; select an explicit final Order in Properties.".into())
}
}
}
@@ -0,0 +1,567 @@
use super::{tests::*, *};
use crate::{app::App, document::Document};
struct Fixture {
p: Project,
owners: [Id; 2],
rows: [[Id; 4]; 2],
trace: Id,
which: OrderedCollection,
}
fn fixture(shape: u8, reverse: bool, local: bool) -> Fixture {
let mut p = Project::empty();
let mut owners = vec![];
let mut rows = vec![];
let which = if shape == 3 {
OrderedCollection::FragmentBranches
} else {
OrderedCollection::Steps
};
for layer in [Layer::Oa, Layer::Sa] {
let owner = if shape == 0 {
p.add_element(
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
"Startup sequence",
None,
)
.unwrap()
} else {
let scenario = p
.add_element(Kind::Scenario, layer, "Startup scenario", None)
.unwrap();
if shape == 1 {
scenario
} else {
let fragment = p
.add_element(Kind::Occurrence, layer, "Alternatives", Some(scenario))
.unwrap();
*p.elements.get_mut(&fragment).unwrap().shared_mut().unwrap() =
SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(fragment);
if shape == 3 {
fragment
} else {
let branch = p
.add_element(Kind::Occurrence, layer, "Start permitted", Some(fragment))
.unwrap();
*p.elements.get_mut(&branch).unwrap().shared_mut().unwrap() =
SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: "permitted".into(),
occurrences: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
branch
}
}
};
let ids = [
"Request start",
"Energize",
"Verify interlock",
"Local self-test",
]
.map(|name| {
let id = p
.add_element(Kind::Occurrence, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: if shape == 3 {
OccurrenceBody::Branch {
guard: name.into(),
occurrences: vec![],
}
} else if shape == 0 {
OccurrenceBody::Control {
kind: ControlKind::Choice,
}
} else {
OccurrenceBody::Action {
action: None,
lifeline: None,
}
},
precondition: String::new(),
postcondition: String::new(),
};
id
});
owners.push(owner);
rows.push(ids);
}
let source = usize::from(reverse);
let target = 1 - source;
set_order(
&mut p,
owners[source],
which,
vec![rows[source][0], rows[source][2], rows[source][1]],
);
set_order(
&mut p,
owners[target],
which,
if local {
vec![rows[target][0], rows[target][3], rows[target][1]]
} else {
vec![rows[target][0], rows[target][1]]
},
);
let trace = add_trace(&mut p, owners[0], owners[1], "Startup ordered review");
validate_candidate(&p).unwrap();
Fixture {
p,
owners: [owners[0], owners[1]],
rows: [rows[0], rows[1]],
trace,
which,
}
}
fn set_order(p: &mut Project, id: Id, which: OrderedCollection, rows: Vec<Id>) {
if which == OrderedCollection::Steps {
*p.elements.get_mut(&id).unwrap().steps_mut().unwrap() = rows;
} else if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&id).unwrap().shared_mut()
{
*branches = rows;
}
}
fn member(f: &Fixture, index: usize) -> FacetSelector {
FacetSelector::Member {
collection: ordered::collection(f.which),
keys: KeyPair {
oa: Some(f.rows[0][index]),
sa: Some(f.rows[1][index]),
},
}
}
fn ready(f: &Fixture, reverse: bool) -> State {
let mut state = State::new(&f.p, f.owners[usize::from(reverse)]).unwrap();
for i in 0..3 {
state.references.insert(f.rows[0][i], f.rows[1][i]);
}
state.refresh(&f.p);
state
}
fn decide(state: &mut State, p: &Project, facet: FacetSelector, decision: SemanticDecision) {
state.update(p, Event::Select(facet.clone()));
state.update(p, Event::Decide(facet, Some(decision)));
}
fn errors(s: &State) -> String {
s.error
.iter()
.chain(s.plan.iter().flat_map(|p| &p.conflicts))
.cloned()
.collect::<Vec<_>>()
.join("; ")
}
fn oracle(f: &Fixture, s: &State, expected: Option<Element>) -> Project {
let reverse = s.direction == TransitionDirection::SaToOa;
let source = f.owners[usize::from(reverse)];
let target = f.owners[1 - usize::from(reverse)];
let mut transition = TransitionRequest::new(s.direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.traces.insert(source, f.trace);
request.references = s.references.clone();
request.reviews.push(SemanticReviewRequest {
source,
resolutions: s
.decisions
.iter()
.map(|(facet, decision)| FacetResolution {
facet: facet.clone(),
decision: *decision,
})
.collect(),
reset: s.resets.iter().cloned().collect(),
});
if let Some(expected) = expected {
request.candidates.push(SemanticRecord::Element(expected));
}
let plan = plan_semantic_transition(&f.p, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let result = apply_semantic_transition(&f.p, &plan).unwrap();
assert!(s.can_apply(), "{}", errors(s));
assert_eq!(s.proposed.as_ref().unwrap(), &result);
assert_eq!(s.plan.as_ref().unwrap().reviews, plan.reviews);
result
}
#[test]
fn ordered_take_uses_unique_anchors_and_matches_core_with_unrelated_candidate_both_directions() {
for shape in 0..4 {
for reverse in [false, true] {
let f = fixture(shape, reverse, false);
let mut s = ready(&f, reverse);
decide(&mut s, &f.p, member(&f, 2), SemanticDecision::TakeSource);
let header = FacetSelector::Header(HeaderFacet::Description);
decide(&mut s, &f.p, header, SemanticDecision::Candidate);
s.update(
&f.p,
Event::Input("/description".into(), "Reviewed startup insertion".into()),
);
let ti = 1 - usize::from(reverse);
let mut expected = f.p.clone();
set_order(
&mut expected,
f.owners[ti],
f.which,
vec![f.rows[ti][0], f.rows[ti][2], f.rows[ti][1]],
);
expected
.elements
.get_mut(&f.owners[ti])
.unwrap()
.description = "Reviewed startup insertion".into();
oracle(&f, &s, Some(expected.elements[&f.owners[ti]].clone()));
}
}
}
#[test]
fn ambiguous_order_preserves_local_member_and_requires_explicit_candidate_positions() {
for shape in 0..4 {
for reverse in [false, true] {
let f = fixture(shape, reverse, true);
let mut s = ready(&f, reverse);
decide(&mut s, &f.p, member(&f, 2), SemanticDecision::TakeSource);
assert!(!s.can_apply());
assert!(errors(&s).contains("anchor"), "{}", errors(&s));
let order = FacetSelector::Order(f.which);
decide(&mut s, &f.p, order.clone(), SemanticDecision::Candidate);
let ti = 1 - usize::from(reverse);
assert_eq!(
s.order_drafts[&f.which],
vec![f.rows[ti][0], f.rows[ti][3], f.rows[ti][1], f.rows[ti][2]]
);
s.update(&f.p, Event::OrderMove(f.which, 3, -1));
let mut expected = f.p.clone();
set_order(
&mut expected,
f.owners[ti],
f.which,
vec![f.rows[ti][0], f.rows[ti][3], f.rows[ti][2], f.rows[ti][1]],
);
let result = oracle(&f, &s, Some(expected.elements[&f.owners[ti]].clone()));
let baseline = result.traces[&f.trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
assert_eq!(baseline.facets.len(), 2);
assert!(baseline.facets.iter().any(|v| v.selector() == order));
}
}
}
#[test]
fn ordered_membership_choices_stage_exact_pairs_in_all_owner_shapes() {
for shape in 0..4 {
let f = fixture(shape, false, true);
let mut s = ready(&f, false);
s.update(
&f.p,
Event::MemberFamily(keyed::Family::Member(ordered::collection(f.which))),
);
for (i, layer) in [(0, Layer::Oa), (1, Layer::Sa)] {
let choices = s.member_choices(&f.p, layer);
for id in f.rows[i] {
assert!(
choices.iter().any(|c| c.id == id),
"shape {shape}: {choices:?}"
);
}
assert!(choices.iter().all(|c| !f.rows[1 - i].contains(&c.id)));
}
s.update(
&f.p,
Event::MemberOa(Some(view::choice(&f.p, f.rows[0][2]))),
);
s.update(
&f.p,
Event::MemberSa(Some(view::choice(&f.p, f.rows[1][2]))),
);
s.update(&f.p, Event::StageMember);
assert!(s.staged.contains(&member(&f, 2)));
assert_eq!(s.references[&f.rows[0][2]], f.rows[1][2]);
}
}
#[test]
fn raw_order_survives_membership_changes_decisions_and_recovery_until_explicit_reconcile() {
for reverse in [false, true] {
let f = fixture(0, reverse, true);
let mut s = ready(&f, reverse);
let order = FacetSelector::Order(f.which);
decide(&mut s, &f.p, order.clone(), SemanticDecision::Candidate);
s.update(&f.p, Event::OrderMove(f.which, 1, -1));
let authored = s.order_drafts[&f.which].clone();
decide(&mut s, &f.p, member(&f, 2), SemanticDecision::Candidate);
s.update(&f.p, Event::Presence(member(&f, 2), true));
assert!(!s.can_apply());
assert_eq!(s.order_drafts[&f.which], authored);
s.update(&f.p, Event::Decide(order.clone(), None));
decide(&mut s, &f.p, order.clone(), SemanticDecision::Candidate);
let saved = s.recovery_snapshot().unwrap();
let mut restored = State::from_recovery(&f.p, saved.clone()).unwrap();
assert_eq!(restored.order_drafts[&f.which], authored);
assert!(!restored.can_apply());
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
restored.update(&f.p, Event::OrderReconcile(f.which));
let ti = 1 - usize::from(reverse);
let mut expected = f.p.clone();
let mut final_order = authored;
final_order.push(f.rows[ti][2]);
set_order(&mut expected, f.owners[ti], f.which, final_order);
oracle(
&f,
&restored,
Some(expected.elements[&f.owners[ti]].clone()),
);
let mut stale = f.p.clone();
stale.name.push_str(" changed");
assert!(State::from_recovery(&stale, saved).is_err());
}
}
#[test]
fn order_take_preserves_destination_only_positions_and_order_alone_cannot_add_members() {
for reverse in [false, true] {
let mut f = fixture(0, reverse, true);
let si = usize::from(reverse);
let ti = 1 - si;
set_order(
&mut f.p,
f.owners[si],
f.which,
vec![f.rows[si][0], f.rows[si][1]],
);
let mut s = ready(&f, reverse);
let order = FacetSelector::Order(f.which);
decide(&mut s, &f.p, order.clone(), SemanticDecision::TakeSource);
let result = oracle(&f, &s, None);
assert_eq!(result.elements[&f.owners[ti]], f.p.elements[&f.owners[ti]]);
decide(&mut s, &f.p, order.clone(), SemanticDecision::Candidate);
s.order_drafts
.get_mut(&f.which)
.unwrap()
.push(f.rows[ti][2]);
s.refresh(&f.p);
assert!(!s.can_apply());
assert!(errors(&s).contains("surviving"), "{}", errors(&s));
s.update(&f.p, Event::OrderReconcile(f.which));
assert!(s.can_apply(), "{}", errors(&s));
}
}
#[test]
fn ordered_review_apply_is_exact_source_history_transaction_with_reopen_and_undo_redo() {
let f = fixture(0, false, true);
let mut app = App::with_project(f.p.clone());
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry.clone())
.unwrap()
.insert_str(0, "// Keep startup explanation verbatim\r\n");
app.document.apply_bundle(bundle).unwrap();
let before = app.document.snapshot();
start(&mut app, f.owners[0]);
for i in 0..3 {
send(
&mut app,
Event::ReferenceOa(view::choice(&f.p, f.rows[0][i])),
);
send(
&mut app,
Event::ReferenceSa(view::choice(&f.p, f.rows[1][i])),
);
send(&mut app, Event::AddReference);
}
send(&mut app, Event::Select(member(&f, 2)));
send(
&mut app,
Event::Decide(member(&f, 2), Some(SemanticDecision::TakeSource)),
);
send(&mut app, Event::Select(FacetSelector::Order(f.which)));
send(
&mut app,
Event::Decide(
FacetSelector::Order(f.which),
Some(SemanticDecision::Candidate),
),
);
send(&mut app, Event::OrderMove(f.which, 3, -1));
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
let expected = state(&app).proposed.clone().unwrap();
apply(&mut app);
assert_eq!(app.project(), &expected);
let after = app.document.snapshot();
assert!(
app.document.bundle().files[&app.document.bundle().entry]
.starts_with("// Keep startup explanation verbatim\r\n")
);
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(), &after.bundle);
assert_eq!(app.project(), &expected);
assert_eq!(
Document::from_bundle(
after.bundle.clone(),
source_project::ProjectLimits::default()
)
.unwrap()
.project(),
&expected
);
}
#[test]
fn several_incoming_members_follow_source_order_despite_independent_identity_sorting() {
for shape in 0..4 {
for reverse in [false, true] {
let mut f = fixture(shape, reverse, false);
let si = usize::from(reverse);
let ti = 1 - si;
set_order(
&mut f.p,
f.owners[si],
f.which,
vec![f.rows[si][0], f.rows[si][2], f.rows[si][3], f.rows[si][1]],
);
let mut s = ready(&f, reverse);
s.references.insert(f.rows[0][3], f.rows[1][3]);
decide(&mut s, &f.p, member(&f, 3), SemanticDecision::TakeSource);
decide(&mut s, &f.p, member(&f, 2), SemanticDecision::TakeSource);
decide(
&mut s,
&f.p,
FacetSelector::Header(HeaderFacet::Description),
SemanticDecision::Candidate,
);
s.update(
&f.p,
Event::Input("/description".into(), "Multiple incoming steps".into()),
);
let mut expected = f.p.clone();
set_order(
&mut expected,
f.owners[ti],
f.which,
vec![f.rows[ti][0], f.rows[ti][2], f.rows[ti][3], f.rows[ti][1]],
);
expected
.elements
.get_mut(&f.owners[ti])
.unwrap()
.description = "Multiple incoming steps".into();
oracle(&f, &s, Some(expected.elements[&f.owners[ti]].clone()));
}
}
}
#[test]
fn ordered_presence_keep_take_candidate_remove_only_selected_entry_and_order_take_reorders_only() {
for reverse in [false, true] {
for decision in [
SemanticDecision::KeepTarget,
SemanticDecision::TakeSource,
SemanticDecision::Candidate,
] {
let mut f = fixture(0, reverse, true);
let si = usize::from(reverse);
let ti = 1 - si;
set_order(&mut f.p, f.owners[si], f.which, vec![f.rows[si][1]]);
let mut s = ready(&f, reverse);
decide(&mut s, &f.p, member(&f, 0), decision);
if decision == SemanticDecision::Candidate {
s.update(&f.p, Event::Presence(member(&f, 0), false));
}
decide(
&mut s,
&f.p,
FacetSelector::Header(HeaderFacet::Description),
SemanticDecision::Candidate,
);
s.update(
&f.p,
Event::Input("/description".into(), "Only selected membership".into()),
);
let mut expected = f.p.clone();
if decision != SemanticDecision::KeepTarget {
set_order(
&mut expected,
f.owners[ti],
f.which,
vec![f.rows[ti][3], f.rows[ti][1]],
);
}
expected
.elements
.get_mut(&f.owners[ti])
.unwrap()
.description = "Only selected membership".into();
oracle(&f, &s, Some(expected.elements[&f.owners[ti]].clone()));
}
let mut f = fixture(0, reverse, false);
let si = usize::from(reverse);
let ti = 1 - si;
set_order(
&mut f.p,
f.owners[si],
f.which,
vec![f.rows[si][1], f.rows[si][0]],
);
for decision in [SemanticDecision::KeepTarget, SemanticDecision::TakeSource] {
let mut s = ready(&f, reverse);
decide(&mut s, &f.p, FacetSelector::Order(f.which), decision);
let result = oracle(&f, &s, None);
assert_eq!(
result.elements[&f.owners[ti]].steps().unwrap(),
&if decision == SemanticDecision::KeepTarget {
vec![f.rows[ti][0], f.rows[ti][1]]
} else {
vec![f.rows[ti][1], f.rows[ti][0]]
}
);
}
}
}
@@ -0,0 +1,347 @@
//! Recover authored intent against the exact accepted model; recompute every plan.
use super::*;
use crate::forms::RecoveryDraft;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Saved {
version: u32,
base_identity: Id,
base_fingerprint: String,
anchor: Id,
trace: Option<Id>,
direction: TransitionDirection,
selected: Option<FacetSelector>,
decisions: Vec<(FacetSelector, SemanticDecision)>,
references: BTreeMap<Id, Id>,
resets: BTreeSet<FacetSelector>,
staged: BTreeSet<FacetSelector>,
presence: Vec<(FacetSelector, bool)>,
member_family: keyed::Family,
member_oa: Option<Id>,
member_sa: Option<Id>,
literal: String,
literal_oa: bool,
literal_sa: bool,
annotation_oa: Option<AnnotationKey>,
annotation_sa: Option<AnnotationKey>,
draft: Option<RecoveryDraft>,
group_drafts: Vec<(FacetSelector, RecoveryDraft)>,
rows: Vec<(FacetSelector, String, Option<Id>)>,
#[serde(default)]
orders: Vec<(OrderedCollection, Vec<Id>)>,
reference_oa: Option<Id>,
reference_sa: Option<Id>,
reference_search: String,
accepted_impacts: Vec<SemanticImpact>,
}
fn unique<K: Ord, V>(values: Vec<(K, V)>) -> Result<BTreeMap<K, V>, String> {
let mut map = BTreeMap::new();
for (key, value) in values {
if map.insert(key, value).is_some() {
return Err("Recovery contains a duplicate semantic draft address.".into());
}
}
Ok(map)
}
impl State {
pub fn recovery_snapshot(&self) -> Result<Value, String> {
let key = |address: &keyed_rows::AddressDraft| {
address.selected.as_ref().map(|k| AnnotationKey {
path: k.path.clone(),
field: k.field,
})
};
serde_json::to_value(Saved {
version: 1,
base_identity: self.base_identity,
base_fingerprint: self.base_fingerprint.clone(),
anchor: self.anchor,
trace: self.trace,
direction: self.direction,
selected: self.selected.clone(),
decisions: self
.decisions
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect(),
references: self.references.clone(),
resets: self.resets.clone(),
staged: self.staged.clone(),
presence: self
.presence
.iter()
.map(|(key, value)| (key.clone(), *value))
.collect(),
member_family: self.member_family,
member_oa: self.member_oa.as_ref().map(|c| c.id),
member_sa: self.member_sa.as_ref().map(|c| c.id),
literal: self.literal.clone(),
literal_oa: self.literal_oa,
literal_sa: self.literal_sa,
annotation_oa: key(&self.annotation_oa),
annotation_sa: key(&self.annotation_sa),
draft: self.draft.as_ref().map(Draft::recovery_snapshot),
group_drafts: self
.group_drafts
.iter()
.map(|(key, draft)| (key.clone(), draft.recovery_snapshot()))
.collect(),
rows: self
.row_drafts
.iter()
.map(|(key, draft)| {
(
key.clone(),
draft.expression.clone(),
draft.local.as_ref().map(|c| c.id),
)
})
.collect(),
reference_oa: self.reference_oa.as_ref().map(|c| c.id),
orders: self
.order_drafts
.iter()
.map(|(key, value)| (*key, value.clone()))
.collect(),
reference_sa: self.reference_sa.as_ref().map(|c| c.id),
reference_search: self.reference_search.clone(),
accepted_impacts: self.accepted_impacts.clone(),
})
.map_err(|e| e.to_string())
}
pub fn from_recovery(project: &Project, value: Value) -> Result<Self, String> {
let saved: Saved = serde_json::from_value(value).map_err(|e| e.to_string())?;
if saved.version != 1
|| saved.base_identity != project.id
|| saved.base_fingerprint != project_fingerprint(project)
{
return Err(
"Semantic review recovery does not match the accepted model identity and revision."
.into(),
);
}
let count = [
saved.decisions.len(),
saved.references.len(),
saved.resets.len(),
saved.staged.len(),
saved.presence.len(),
saved.rows.len(),
saved
.orders
.iter()
.fold(0usize, |total, (_, rows)| total.saturating_add(rows.len())),
saved.accepted_impacts.len(),
]
.into_iter()
.fold(0usize, usize::saturating_add);
if count > crate::source_workspace::LIMITS.model.max_records
|| saved.group_drafts.len() > constructors::selectors().len()
{
return Err("Semantic recovery exceeds the supported draft limits.".into());
}
let mut state = Self::new(project, saved.anchor)?;
if saved.trace.is_some_and(|id| {
!project
.traces
.get(&id)
.is_some_and(|trace| trace.oa == saved.anchor || trace.sa == saved.anchor)
}) {
return Err("Semantic recovery trace and selected object disagree.".into());
}
state.trace = saved.trace;
state.direction = saved.direction;
state.load_trace(project);
state.selected = saved.selected;
state.decisions = unique(saved.decisions)?;
state.references = saved.references;
state.resets = saved.resets;
state.staged = saved.staged;
state.presence = unique(saved.presence)?;
state.member_family = saved.member_family;
state.member_oa = saved.member_oa.map(|id| view::choice(project, id));
state.member_sa = saved.member_sa.map(|id| view::choice(project, id));
state.literal = saved.literal;
state.literal_oa = saved.literal_oa;
state.literal_sa = saved.literal_sa;
let address = |key: Option<AnnotationKey>| keyed_rows::AddressDraft {
selected: key.map(|key| crate::data_values::FieldPathKey {
path: key.path,
field: key.field,
}),
..Default::default()
};
state.annotation_oa = address(saved.annotation_oa);
state.annotation_sa = address(saved.annotation_sa);
let target = state.target(project);
state.draft = saved
.draft
.map(|draft| Draft::from_recovery(project, draft))
.transpose()?;
if state
.draft
.as_ref()
.is_some_and(|draft| Some(draft.original) != target)
{
return Err("Recovered semantic draft belongs to another target.".into());
}
state.group_drafts = unique(
saved
.group_drafts
.into_iter()
.map(|(facet, draft)| {
if !constructors::selectors().contains(&facet) {
return Err("Recovered constructor draft has an unknown scope.".to_owned());
}
let mut draft = Draft::from_recovery(project, draft)?;
if Some(draft.original) != target {
return Err("Recovered constructor draft belongs to another target.".into());
}
draft.semantic_groups = true;
Ok((facet, draft))
})
.collect::<Result<Vec<_>, String>>()?,
)?;
state.row_drafts = unique(
saved
.rows
.into_iter()
.map(|(facet, expression, local)| {
if !keyed_rows::supported(&facet) {
return Err("Recovered row draft has an unknown scope.".to_owned());
}
Ok((
facet,
keyed_rows::RowDraft {
editor: text_editor::Content::with_text(&expression),
expression,
local: local.map(|id| view::choice(project, id)),
},
))
})
.collect::<Result<Vec<_>, String>>()?,
)?;
state.reference_oa = saved.reference_oa.map(|id| view::choice(project, id));
state.order_drafts = unique(saved.orders)?;
state.reference_sa = saved.reference_sa.map(|id| view::choice(project, id));
state.reference_search = saved.reference_search;
state.accepted_impacts = saved.accepted_impacts;
// Invalid numbers and incomplete mappings are recoverable authored input.
// Refresh reports their errors instead of dropping them or applying a plan.
state.refresh(project);
Ok(state)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_one_without_orders_restores_raw_intent_and_replans_without_execution() {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Operational", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "System", None)
.unwrap();
for id in [oa, sa] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(0),
maximum: Some(100),
},
default: None,
unit: String::new(),
};
}
super::super::tests::add_trace(&mut p, oa, sa, "Version one review");
let accepted = p.clone();
let facet = FacetSelector::Data(DataFacet::IntegerMinimum);
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
for (raw, valid) in [("7", true), ("-", false)] {
let mut state = State::new(&p, oa).unwrap();
state.update(&p, Event::Select(facet.clone()));
state.update(
&p,
Event::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
);
state.update(&p, Event::Input(path.into(), raw.into()));
assert_eq!(state.can_apply(), valid);
let current = state.recovery_snapshot().unwrap();
let mut old = current.clone();
assert_eq!(old["version"], 1);
assert_eq!(
old.as_object_mut().unwrap().remove("orders"),
Some(serde_json::json!([]))
);
let restored = State::from_recovery(&p, old.clone()).unwrap();
assert!(restored.order_drafts.is_empty());
assert_eq!(restored.draft.as_ref().unwrap().inputs[path].raw, raw);
assert_eq!(restored.can_apply(), valid);
assert_eq!(restored.recovery_snapshot().unwrap(), current);
assert_eq!(
p, accepted,
"Recovery prepares a preview, never an accepted edit"
);
let mut changed = p.clone();
changed.name.push_str(" changed accepted model");
assert!(State::from_recovery(&changed, old).is_err());
}
}
#[test]
fn raw_scalar_and_closed_constructor_drafts_recover_against_exact_model_only() {
let mut p = Project::empty();
let oa = p
.add_element(Kind::DataType, Layer::Oa, "Operational", None)
.unwrap();
let sa = p
.add_element(Kind::DataType, Layer::Sa, "System", None)
.unwrap();
for id in [oa, sa] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(0),
maximum: Some(100),
},
default: None,
unit: String::new(),
};
}
super::super::tests::add_trace(&mut p, oa, sa, "Recover review");
let mut state = State::new(&p, oa).unwrap();
let facet = FacetSelector::Data(DataFacet::IntegerMinimum);
state.update(&p, Event::Select(facet.clone()));
state.update(
&p,
Event::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
);
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
state.update(&p, Event::Input(path.into(), "-".into()));
assert!(!state.can_apply());
let saved = state.recovery_snapshot().unwrap();
let restored = State::from_recovery(&p, saved.clone()).unwrap();
assert_eq!(restored.draft.as_ref().unwrap().inputs[path].raw, "-");
assert_eq!(restored.decisions, state.decisions);
assert!(!restored.can_apply());
assert!(restored.plan.is_none());
assert!(
restored
.group_drafts
.values()
.all(|draft| draft.semantic_groups)
);
p.name.push_str(" changed");
assert!(
State::from_recovery(&p, saved)
.err()
.unwrap()
.contains("identity and revision")
);
}
}
+242 -12
View File
@@ -208,14 +208,24 @@ fn formatted(project: &Project, value: &Value, references: &BTreeSet<Id>) -> Str
.join("\n"),
}
}
fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> String {
pub(super) 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 if keyed::supported(&fact.selector()) {
} else if matches!(fact, FacetReview::Annotation { .. }) {
if let Ok(row) = serde_json::from_value::<MessageValue>(value["Value"].clone()) {
format!(
"Field: {}\nExpression: {:?}",
annotation_title(project, &AnnotationKey::from(&row)),
row.expression
)
} else {
"Absent".into()
}
} else if keyed::supported(&fact.selector()) && !keyed_rows::supported(&fact.selector()) {
if value["Value"].as_bool() == Some(true) {
"Present".into()
} else {
@@ -259,7 +269,7 @@ impl State {
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("Members / literals", Event::Tab(Tab::Members)), action(format!("History ({})", self.resets.len()), Event::Tab(Tab::History)), 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).wrap(),
row![action("Properties", Event::Tab(Tab::Facts)), action("Members / rows", Event::Tab(Tab::Members)), action(format!("History ({})", self.resets.len()), Event::Tab(Tab::History)), 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).wrap(),
].spacing(8);
let body = match self.tab {
Tab::Facts => self.facts_view(project),
@@ -290,7 +300,7 @@ impl State {
.iter()
.filter(|f| f.drift == Drift::Unreviewed)
.count();
content = content.push(text(format!("{} decisions · {} exact resets · {unreviewed} unreviewed facts · Ordered, annotation, binding and attached-record decisions remain pending.", self.decisions.len(), self.resets.len())).size(10).color(style::MUTED));
content = content.push(text(format!("{} decisions · {} exact resets · {unreviewed} unreviewed facts · Attached-record and multi-source decisions remain pending.", self.decisions.len(), self.resets.len())).size(10).color(style::MUTED));
if let Some(trace) = trace {
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
}
@@ -460,7 +470,7 @@ impl State {
);
}
if keyed::supported(&fact.selector) {
detail = detail.push(text("Presence changes only this collection entry; referenced objects stay intact. Take preserves a row with no source identity. An explicit source identity that is absent can remove its mapped destination entry.").size(11))
detail = detail.push(text("Review changes only this collection entry; referenced objects stay intact. Take preserves a row with no source identity. An explicit source identity that is absent can remove its mapped destination entry. Changing a row key requires separate removal and addition.").size(11))
.push(action("Choose / change exact row identities…", Event::EditPair(fact.selector.clone())));
if let Some(baseline) = trace
.and_then(|t| t.baseline.as_ref())
@@ -482,6 +492,12 @@ impl State {
}
}
}
if let FacetSelector::Order(which) = fact.selector {
detail = detail.push(text("Order changes positions only. Review member presence separately; the final order must include every surviving destination identity exactly once. Take preserves destination-only members when their surrounding order already agrees.").size(11));
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate) {
detail = detail.push(self.order_view(project, which));
}
}
if let Some(proposed) = &self.proposed
&& let Some(trace) = trace
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
@@ -569,6 +585,44 @@ impl State {
.text_size(12)
.on_toggle(move |v| event(Event::Presence(facet.clone(), v))),
);
if keyed_rows::supported(&fact.selector)
&& let Some(draft) = self.row_drafts.get(&fact.selector)
{
let facet = fact.selector.clone();
detail = detail.push(text("The exact destination key stays fixed; its row value is independent of sibling rows.").size(11));
match &fact.selector {
FacetSelector::Annotation(_) => {
detail = detail.push(
iced::widget::text_editor(&draft.editor)
.placeholder("Expression")
.on_action(move |action| {
event(Event::RowText(facet.clone(), action))
})
.size(12)
.height(90)
.padding(7),
);
}
FacetSelector::Binding(_) => {
detail = detail.push(
pick_list(
self.binding_choices(
project,
self.direction.target_layer(),
false,
),
draft.local.clone(),
move |value| event(Event::RowLocal(facet.clone(), value)),
)
.placeholder("Local lifeline in destination scenario")
.text_size(12)
.padding(7)
.width(Fill),
);
}
_ => {}
}
}
}
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
&& let Some(draft) = self.candidate_draft(&fact.selector)
@@ -634,6 +688,7 @@ impl State {
}
if (is_group
|| keyed::supported(&fact.selector)
|| matches!(fact.selector, FacetSelector::Order(_))
|| constructors::selectors().iter().any(|group| {
self.decisions.contains_key(group) && constructors::contains(group, &fact.selector)
}))
@@ -676,12 +731,22 @@ impl State {
let families = self.member_families(project);
let available = families.contains(&self.member_family);
let mut content = column![
text("Review an individual member or literal").size(16),
text("Choose an address, then review its presence in Properties. This also stages a new row in an empty collection. No model object is created.").size(12),
text("Review an individual member or row").size(16),
text("Choose an exact address, then review its value and presence in Properties. This also stages a new row in an empty collection. No model object is created.").size(12),
pick_list(families, Some(self.member_family), |v| event(Event::MemberFamily(v))).text_size(12).padding(7),
].spacing(9);
if let keyed::Family::Member(collection) = self.member_family
&& let Some(which) = ordered::ordered(collection)
{
content = content.push(text("Membership and order are separate decisions. An incoming member with ambiguous placement requires an explicit final order; referenced objects are not created or reparented here.").size(11))
.push(action("Review final order…", Event::Select(FacetSelector::Order(which))));
}
if !available {
content = content.push(text("This collection is not available on the current counterparts. Choose an available collection above; existing staged addresses remain removable below.").size(12));
} else if self.member_family == keyed::Family::Annotation {
content = content.push(text("Pair the complete carried-field paths explicitly. Each path component establishes a reference pair. Repeated fields under different paths remain independent rows.").size(11))
.push(self.annotation_address_view(project, Layer::Oa))
.push(self.annotation_address_view(project, Layer::Sa));
} else if self.member_family == keyed::Family::Literal {
content = content.push(text("Literal identity is exact, including case and spaces. Both sides use the same string; a rename requires separate old/new rows.").size(11))
.push(text_input("Exact literal", &self.literal).on_input(|v| event(Event::Literal(v))).size(12).padding(7))
@@ -691,7 +756,7 @@ impl State {
.push(row![pick_list(self.member_choices(project, Layer::Oa), self.member_oa.clone(), |v| event(Event::MemberOa(Some(v)))).placeholder("Operational identity").text_size(12).padding(7).width(Fill), action("No OA identity", Event::MemberOa(None))].spacing(7))
.push(row![pick_list(self.member_choices(project, Layer::Sa), self.member_sa.clone(), |v| event(Event::MemberSa(Some(v)))).placeholder("System identity").text_size(12).padding(7).width(Fill), action("No SA identity", Event::MemberSa(None))].spacing(7));
}
content = content.push(button(text("Use these identities and review presence").size(12)).padding([7,9]).style(style::nav).on_press_maybe(available.then_some(event(Event::StageMember))))
content = content.push(button(text("Use these addresses and review row").size(12)).padding([7,9]).style(style::nav).on_press_maybe(available.then_some(event(Event::StageMember))))
.push(text("Changing a row address retains its earlier decisions and drafts. Reset conflicting historical observations and leave overlapping decisions unselected explicitly.").size(11).color(style::MUTED));
if !self.staged.is_empty() {
content = content.push(text("Staged addresses in this draft").size(13));
@@ -711,6 +776,170 @@ impl State {
scrollable(content).height(305).into()
}
fn order_view<'a>(
&'a self,
project: &'a Project,
which: OrderedCollection,
) -> Element<'a, Message> {
let mut content = column![text("Final destination order").size(13),
text("Up/Down changes only positions. Reconcile members explicitly after changing presence: retain surviving positions, remove excluded entries and append newly included entries.").size(11),
action("Reconcile with selected membership", Event::OrderReconcile(which))].spacing(6);
if let Some(ids) = self.order_drafts.get(&which) {
if ids.is_empty() {
content = content.push(text("Empty order").size(12));
}
for (index, id) in ids.iter().enumerate() {
let up = button(text("Up").size(11)).padding(5).on_press_maybe(
(index > 0).then_some(event(Event::OrderMove(which, index, -1))),
);
let down = button(text("Down").size(11)).padding(5).on_press_maybe(
(index + 1 < ids.len()).then_some(event(Event::OrderMove(which, index, 1))),
);
content = content.push(
row![
text(format!("{}. {}", index + 1, choice(project, *id).label))
.size(12)
.width(Fill),
up,
down
]
.spacing(5),
);
}
match self.ordered_members(project, which) {
Ok(members) => {
let missing: Vec<_> = members
.iter()
.filter(|id| !ids.contains(id))
.map(|id| choice(project, *id).label)
.collect();
let excluded: Vec<_> = ids
.iter()
.filter(|id| !members.contains(id))
.map(|id| choice(project, *id).label)
.collect();
if !missing.is_empty() {
content = content.push(
text(format!("Missing from this draft: {}", missing.join(", ")))
.size(11),
);
}
if !excluded.is_empty() {
content = content.push(
text(format!("No longer included: {}", excluded.join(", "))).size(11),
);
}
}
Err(error) => content = content.push(text(error).size(11)),
}
}
content.into()
}
fn annotation_address_view<'a>(
&'a self,
project: &'a Project,
layer: Layer,
) -> Element<'a, Message> {
let address = self.annotation_address(layer);
let side = if layer == Layer::Oa {
"Operational"
} else {
"System"
};
let mut content =
column![text(format!("{side} carried-field address")).size(13)].spacing(6);
if let Some(key) = &address.selected {
content = content.push(
text(annotation_title(
project,
&AnnotationKey {
path: key.path.clone(),
field: key.field,
},
))
.size(11),
);
} else {
content = content.push(text("No identity on this side").size(11));
}
content = content.push(
row![
action("Root fields", Event::AnnotationBrowse(layer, None, 0)),
action("No identity", Event::AnnotationAddress(layer, None)),
]
.spacing(6),
);
if let Some(scope) = &address.scope {
content = content
.push(
text(format!(
"Browsing: {}",
annotation_title(
project,
&AnnotationKey {
path: scope.path.clone(),
field: scope.field
}
)
))
.size(11),
)
.push(action(
"Up",
Event::AnnotationBrowse(layer, crate::data_values::parent(scope), 0),
));
}
match self.annotation_choices(project, layer) {
Err(error) => content = content.push(text(error).size(11).color(style::MUTED)),
Ok(choices) => {
const PAGE: usize = 24;
let pages = choices.len().div_ceil(PAGE).max(1);
let page = address.page.min(pages - 1);
let visible = choices
.into_iter()
.skip(page * PAGE)
.take(PAGE)
.collect::<Vec<_>>();
let selected = visible
.iter()
.find(|choice| Some(&choice.key) == address.selected.as_ref())
.cloned();
content = content.push(
pick_list(visible, selected, move |choice| {
event(Event::AnnotationAddress(layer, Some(choice.key)))
})
.placeholder("Choose a field")
.text_size(12)
.padding(7)
.width(Fill),
);
if let Some(selected) = &address.selected {
content = content.push(action(
"Browse fields within selected record",
Event::AnnotationBrowse(layer, Some(selected.clone()), 0),
));
}
let mut navigation =
row![text(format!("Page {} of {pages}", page + 1)).size(11)].spacing(7);
if page > 0 {
navigation = navigation.push(action(
"Previous",
Event::AnnotationBrowse(layer, address.scope.clone(), page - 1),
));
}
if page + 1 < pages {
navigation = navigation.push(action(
"Next",
Event::AnnotationBrowse(layer, address.scope.clone(), page + 1),
));
}
content = content.push(navigation);
}
}
content.into()
}
fn history_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut content = column![text("Exact previous observations").size(16), text("Check only the observations to forget. Resets run before this review's decisions; reset alone changes history, not model values. Uncheck to restore the pending observation. Prose history remains separate.").size(12)].spacing(9);
let baseline = self
@@ -777,8 +1006,8 @@ impl State {
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);
].spacing(7).width(Fill),
].spacing(9).width(Fill);
for (oa, sa) in &self.references {
content = content.push(
row![
@@ -791,7 +1020,8 @@ impl State {
.width(Fill),
action("Remove pair", Event::RemoveReference(*oa))
]
.spacing(6),
.spacing(6)
.width(Fill),
);
}
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
@@ -828,7 +1058,7 @@ impl State {
Err(error) => content = content.push(text(error).size(12)),
}
}
scrollable(content).height(305).into()
scrollable(content).width(Fill).height(305).into()
}
fn impacts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
+79
View File
@@ -20,6 +20,50 @@ pub const LIMITS: ProjectLimits = ProjectLimits {
const DRAFT_HISTORY_BYTES: usize = 32 * 1024 * 1024;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecoveryWorkspace {
entry: String,
active: String,
files: BTreeMap<String, (String, String)>,
reachable: BTreeSet<String>,
applied: bool,
new_path: String,
}
impl RecoveryWorkspace {
/// Check the same bounds before replacing a checkpoint and before restoring it.
pub(crate) fn validate(&self) -> Result<(), String> {
if self.files.len() > LIMITS.max_files
|| !self.files.contains_key(&self.entry)
|| !self.files.contains_key(&self.active)
|| !self.reachable.contains(&self.entry)
|| self.reachable.len() > LIMITS.max_include_edges + 1
{
return Err("Recovery has an invalid file count, entry or active file.".into());
}
if self.files.values().fold(0usize, |bytes, (text, base)| {
bytes.saturating_add(text.len()).saturating_add(base.len())
}) > LIMITS.model.max_source_bytes.saturating_mul(2)
{
return Err("Recovery source buffers exceed the size limit.".into());
}
for path in self.files.keys().chain(self.reachable.iter()) {
// A recovery buffer is never a filesystem path to open implicitly.
if path.starts_with('/')
|| path.contains('\\')
|| path.contains(':')
|| path
.split('/')
.any(|part| part == ".." || part == "." || part.is_empty())
{
return Err("Recovery contains an unsafe source path.".into());
}
}
Ok(())
}
}
pub struct Buffer {
pub text: String,
baseline: String,
@@ -57,6 +101,41 @@ pub struct Workspace {
}
impl Workspace {
pub fn recovery_snapshot(&self) -> RecoveryWorkspace {
RecoveryWorkspace {
entry: self.entry.clone(),
active: self.active.clone(),
files: self
.files
.iter()
.map(|(path, buffer)| {
(path.clone(), (buffer.text.clone(), buffer.baseline.clone()))
})
.collect(),
reachable: self.reachable.clone(),
applied: self.applied,
new_path: self.new_path.clone(),
}
}
pub fn from_recovery(saved: RecoveryWorkspace) -> Result<Self, String> {
saved.validate()?;
Ok(Self {
entry: saved.entry,
active: saved.active,
files: saved
.files
.into_iter()
.map(|(path, (text, baseline))| {
let mut buffer = Buffer::new(text);
buffer.baseline = baseline;
(path, buffer)
})
.collect(),
reachable: saved.reachable,
applied: saved.applied,
new_path: saved.new_path,
})
}
pub fn new(bundle: &SourceBundle, applied: bool) -> Self {
Self {
entry: bundle.entry.clone(),
+18 -3
View File
@@ -99,6 +99,7 @@ pub fn view(app: &App) -> View<'_, Message> {
.spacing(8);
let mut task_nav = row![].spacing(2);
for (page, name) in [
(Page::Home, "Home"),
(Page::Diagram, "Architecture"),
(Page::Catalog, "Model explorer"),
(Page::Behavior, "Behavior & scenarios"),
@@ -119,7 +120,7 @@ pub fn view(app: &App) -> View<'_, Message> {
}),
);
}
let task_nav = container(task_nav)
let task_nav = container(task_nav.wrap())
.width(Fill)
.padding([4, 14])
.style(style::panel);
@@ -204,6 +205,16 @@ pub fn view(app: &App) -> View<'_, Message> {
.height(Fill)
.style(style::panel);
let main = match app.page {
Page::Home => crate::home::view(
&app.home,
(app.dirty()
|| !p.elements.is_empty()
|| app.path.is_some()
|| app.hosted.session.is_some())
.then_some((p.name.as_str(), app.dirty())),
&app.recovery,
)
.map(Message::Home),
Page::Diagram => diagram(app),
Page::Catalog => crate::explorer_view::catalog(app),
Page::Data => data_catalog(app),
@@ -213,8 +224,12 @@ pub fn view(app: &App) -> View<'_, Message> {
Page::Validation => checks(app),
Page::Guide => crate::guide::view(&app.guide),
};
let mut body = row![sidebar, container(main).width(Fill).height(Fill)].height(Fill);
if !matches!(app.page, Page::Text | Page::Guide) {
let mut body = if app.page == Page::Home {
row![container(main).width(Fill).height(Fill)].height(Fill)
} else {
row![sidebar, container(main).width(Fill).height(Fill)].height(Fill)
};
if !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
body = body.push(inspector(app));
}
let footer = container(
+7
View File
@@ -12,6 +12,7 @@ use iced::{
};
use oa_core::{transitions::*, *};
use std::collections::BTreeSet;
pub(crate) mod recovery;
#[derive(Debug, Clone)]
pub enum Event {
@@ -38,6 +39,7 @@ pub enum Workflow {
Ports(Box<crate::port_workflow::State>),
Relation(Box<Relationship>),
ManualTrace {
base_fingerprint: String,
name: String,
source: Choice,
target: Option<Choice>,
@@ -45,6 +47,7 @@ pub enum Workflow {
Transition(Box<Transition>),
}
pub struct Relationship {
base_fingerprint: String,
id: Id,
pub name: String,
pub kind: RelationKind,
@@ -254,6 +257,7 @@ impl Workflow {
return Err("Select an OA or SA element or relationship to trace.".into());
}
Ok(Self::ManualTrace {
base_fingerprint: project_fingerprint(p),
name: "Engineering correspondence".into(),
source: choice(p, selected),
target: None,
@@ -292,6 +296,7 @@ impl Workflow {
incoming: bool,
) -> Self {
let mut draft = Relationship {
base_fingerprint: project_fingerprint(p),
id: Id::new_v4(),
name: String::new(),
kind,
@@ -345,6 +350,7 @@ impl Workflow {
name,
source,
target,
..
} => match event {
Event::RelationName(value) => *name = value,
Event::Target(value) => *target = Some(value),
@@ -610,6 +616,7 @@ impl Workflow {
name,
source,
target,
..
} => {
let mut choices = p
.elements
+206
View File
@@ -0,0 +1,206 @@
//! Typed authored workflow inputs; no stored command is executable on recovery.
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
enum Saved {
Semantic(serde_json::Value),
Ports(serde_json::Value),
Relation {
base: String,
id: Id,
name: String,
kind: RelationKind,
source: Option<Id>,
target: Option<Id>,
condition: String,
layer: Layer,
items: Vec<Id>,
source_port: PortChoice,
target_port: PortChoice,
oriented: bool,
},
ManualTrace {
base: String,
name: String,
source: Id,
target: Option<Id>,
},
Transition {
base: String,
request: TransitionRequest,
},
}
pub(crate) fn endpoint(p: &Project, port: PortChoice) -> EndpointChoice {
let label = match port {
PortChoice::Auto => "Automatic · reuse only if unique".into(),
PortChoice::Create => "Create a new port".into(),
PortChoice::Reuse(id) => format!("Reuse {}", choice(p, id).label),
};
EndpointChoice {
choice: port,
label,
}
}
fn check(p: &Project, base: &str) -> Result<(), String> {
if project_fingerprint(p) != base {
return Err(
"Workflow recovery does not match the accepted model identity and revision.".into(),
);
}
Ok(())
}
impl Workflow {
pub fn recovery_snapshot(&self) -> Result<serde_json::Value, String> {
let saved = match self {
Self::Semantic(state) => Saved::Semantic(state.recovery_snapshot()?),
Self::Ports(state) => Saved::Ports(state.recovery_snapshot()?),
Self::Relation(d) => Saved::Relation {
base: d.base_fingerprint.clone(),
id: d.id,
name: d.name.clone(),
kind: d.kind,
source: d.source.as_ref().map(|c| c.id),
target: d.target.as_ref().map(|c| c.id),
condition: d.condition.text(),
layer: d.layer,
items: d.items.clone(),
source_port: d.source_port.choice,
target_port: d.target_port.choice,
oriented: d.oriented,
},
Self::ManualTrace {
base_fingerprint,
name,
source,
target,
} => Saved::ManualTrace {
base: base_fingerprint.clone(),
name: name.clone(),
source: source.id,
target: target.as_ref().map(|c| c.id),
},
Self::Transition(d) => Saved::Transition {
base: d.plan.base_fingerprint.clone(),
request: d.request.clone(),
},
};
serde_json::to_value(saved).map_err(|e| e.to_string())
}
pub fn from_recovery(p: &Project, value: serde_json::Value) -> Result<Self, String> {
match serde_json::from_value::<Saved>(value).map_err(|e| e.to_string())? {
Saved::Semantic(value) => crate::semantic_review::State::from_recovery(p, value)
.map(|state| Self::Semantic(Box::new(state))),
Saved::Ports(value) => crate::port_workflow::State::from_recovery(p, value)
.map(|state| Self::Ports(Box::new(state))),
Saved::Relation {
base,
id,
name,
kind,
source,
target,
condition,
layer,
items,
source_port,
target_port,
oriented,
} => {
check(p, &base)?;
if items.len() > crate::source_workspace::LIMITS.model.max_records {
return Err("Recovered relationship exceeds item limits.".into());
}
let mut d = Relationship {
base_fingerprint: base,
id,
name,
kind,
source: source.map(|id| choice(p, id)),
target: target.map(|id| choice(p, id)),
condition: text_editor::Content::with_text(&condition),
layer,
items,
source_port: endpoint(p, source_port),
target_port: endpoint(p, target_port),
oriented,
sources: vec![],
targets: vec![],
};
d.refresh_choices(p);
Ok(Self::Relation(Box::new(d)))
}
Saved::ManualTrace {
base,
name,
source,
target,
} => {
check(p, &base)?;
let mut workflow = Self::manual_trace(p, source)?;
if let Self::ManualTrace {
name: n, target: t, ..
} = &mut workflow
{
*n = name;
*t = target.map(|id| choice(p, id));
}
Ok(workflow)
}
Saved::Transition { base, request } => {
check(p, &base)?;
let plan = plan_transition(p, &request).map_err(|e| e.to_string())?;
Ok(Self::Transition(Box::new(Transition { request, plan })))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn incomplete_relationship_and_trace_recover_raw_fields_and_reject_changed_model() {
let mut p = Project::empty();
let a = p
.add_element(Kind::Activity, Layer::Oa, "Source", None)
.unwrap();
let mut relation = Workflow::relationship(&p, a, Layer::Oa);
if let Workflow::Relation(d) = &mut relation {
d.name = " unfinished name ".into();
d.condition = text_editor::Content::with_text("raw (\n unfinished");
d.target = None;
}
let saved = relation.recovery_snapshot().unwrap();
let restored = Workflow::from_recovery(&p, saved.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
let trace = Workflow::manual_trace(&p, a).unwrap();
let saved_trace = trace.recovery_snapshot().unwrap();
assert_eq!(
Workflow::from_recovery(&p, saved_trace.clone())
.unwrap()
.recovery_snapshot()
.unwrap(),
saved_trace
);
p.name.push('!');
assert!(Workflow::from_recovery(&p, saved).is_err());
assert!(Workflow::from_recovery(&p, saved_trace).is_err());
}
#[test]
fn transition_recovery_replans_typed_request_without_persisted_commands() {
let mut p = Project::empty();
let a = p
.add_element(Kind::Activity, Layer::Oa, "Source", None)
.unwrap();
let workflow = Workflow::transition(&p, a).unwrap();
let saved = workflow.recovery_snapshot().unwrap();
assert!(!serde_json::to_string(&saved).unwrap().contains("commands"));
let Workflow::Transition(restored) = Workflow::from_recovery(&p, saved).unwrap() else {
panic!()
};
assert_eq!(restored.plan.base_fingerprint, project_fingerprint(&p));
assert_eq!(restored.request.sources, vec![a]);
}
}