572 lines
20 KiB
Rust
572 lines
20 KiB
Rust
//! Deliberate semantic review of one existing trace; drafts never mutate the model.
|
|
use crate::{
|
|
app::{Choice, Message},
|
|
forms::Draft,
|
|
};
|
|
use iced::widget::text_editor;
|
|
use oa_core::{transitions::*, *};
|
|
use serde_json::Value;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
mod scalar;
|
|
mod view;
|
|
pub(crate) use view::summary;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Tab {
|
|
Facts,
|
|
References,
|
|
Impacts,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Trace(Choice),
|
|
Direction(TransitionDirection),
|
|
Select(FacetSelector),
|
|
Decide(FacetSelector, Option<SemanticDecision>),
|
|
Input(String, String),
|
|
Text(String, text_editor::Action),
|
|
Shape(String, Value),
|
|
Variant(String, Value),
|
|
Array(String, Option<usize>, i32),
|
|
Append(String, Value),
|
|
ValuePath(String, crate::data_values::FieldPathKey),
|
|
Tab(Tab),
|
|
ShowUnchanged(bool),
|
|
ReferenceOa(Choice),
|
|
ReferenceSa(Choice),
|
|
ReferenceSearch(String),
|
|
AddReference,
|
|
RemoveReference(Id),
|
|
AcceptImpact(usize, bool),
|
|
Preview,
|
|
}
|
|
|
|
pub struct State {
|
|
pub anchor: Id,
|
|
pub trace: Option<Id>,
|
|
pub direction: TransitionDirection,
|
|
pub facts: Vec<FacetDrift>,
|
|
pub selected: Option<FacetSelector>,
|
|
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
|
|
pub references: BTreeMap<Id, Id>,
|
|
pub draft: Option<Draft>,
|
|
pub plan: Option<SemanticTransitionPlan>,
|
|
pub proposed: Option<Project>,
|
|
pub error: Option<String>,
|
|
pub tab: Tab,
|
|
pub show_unchanged: bool,
|
|
pub reference_oa: Option<Choice>,
|
|
pub reference_sa: Option<Choice>,
|
|
pub reference_search: String,
|
|
accepted_impacts: Vec<SemanticImpact>,
|
|
}
|
|
|
|
impl State {
|
|
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
|
|
let explicit = project.traces.get(&selected);
|
|
let anchor = explicit.map_or(selected, |trace| trace.oa);
|
|
let layer = project
|
|
.layer_of(anchor)
|
|
.ok_or("Select an OA or SA object, relationship or trace to review.")?;
|
|
let choices: Vec<_> = project
|
|
.traces
|
|
.values()
|
|
.filter(|t| t.oa == anchor || t.sa == anchor)
|
|
.collect();
|
|
if choices.is_empty() {
|
|
return Err("This object has no trace. Create a counterpart or link existing counterparts first.".into());
|
|
}
|
|
let trace = explicit
|
|
.map(|t| t.id)
|
|
.or_else(|| (choices.len() == 1).then(|| choices[0].id));
|
|
let mut state = Self {
|
|
anchor,
|
|
trace,
|
|
direction: if layer == Layer::Oa {
|
|
TransitionDirection::OaToSa
|
|
} else {
|
|
TransitionDirection::SaToOa
|
|
},
|
|
facts: vec![],
|
|
selected: None,
|
|
decisions: BTreeMap::new(),
|
|
references: BTreeMap::new(),
|
|
draft: None,
|
|
plan: None,
|
|
proposed: None,
|
|
error: None,
|
|
tab: Tab::Facts,
|
|
show_unchanged: false,
|
|
reference_oa: None,
|
|
reference_sa: None,
|
|
reference_search: String::new(),
|
|
accepted_impacts: vec![],
|
|
};
|
|
state.load_trace(project);
|
|
Ok(state)
|
|
}
|
|
|
|
pub fn target(&self, project: &Project) -> Option<Id> {
|
|
let trace = project.traces.get(&self.trace?)?;
|
|
Some(if self.direction == TransitionDirection::OaToSa {
|
|
trace.sa
|
|
} else {
|
|
trace.oa
|
|
})
|
|
}
|
|
|
|
fn load_trace(&mut self, project: &Project) {
|
|
self.facts.clear();
|
|
self.decisions.clear();
|
|
self.selected = None;
|
|
self.accepted_impacts.clear();
|
|
self.references.clear();
|
|
self.draft = self
|
|
.target(project)
|
|
.and_then(|id| Draft::from_project(project, id));
|
|
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
|
self.references.insert(trace.oa, trace.sa);
|
|
}
|
|
self.refresh(project);
|
|
}
|
|
|
|
pub fn update(&mut self, project: &Project, event: Event) {
|
|
let mut replan = true;
|
|
let result = match event {
|
|
Event::Trace(choice) => {
|
|
if !project
|
|
.traces
|
|
.get(&choice.id)
|
|
.is_some_and(|t| t.oa == self.anchor || t.sa == self.anchor)
|
|
{
|
|
Err("Choose one of this object's existing traces.".into())
|
|
} else {
|
|
self.trace = Some(choice.id);
|
|
self.load_trace(project);
|
|
return;
|
|
}
|
|
}
|
|
Event::Direction(direction) => {
|
|
self.direction = direction;
|
|
self.load_trace(project);
|
|
return;
|
|
}
|
|
Event::Select(facet) => {
|
|
self.selected = Some(facet);
|
|
replan = false;
|
|
Ok(())
|
|
}
|
|
Event::Decide(facet, decision) => {
|
|
if let Some(decision) = decision {
|
|
if !self.editable(project, &facet)
|
|
&& !(decision == SemanticDecision::KeepTarget
|
|
&& self.can_acknowledge_constructor(project, &facet))
|
|
{
|
|
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
|
|
} else {
|
|
self.decisions.insert(facet, decision);
|
|
Ok(())
|
|
}
|
|
} else {
|
|
self.decisions.remove(&facet);
|
|
Ok(())
|
|
}
|
|
}
|
|
Event::Input(path, value) => self.edit(project, &path, |d| {
|
|
d.input(&path, value);
|
|
Ok(())
|
|
}),
|
|
Event::Text(path, action) => self.edit(project, &path, |d| {
|
|
d.edit_text(&path, action);
|
|
Ok(())
|
|
}),
|
|
Event::Shape(path, value) => {
|
|
self.edit(project, &path, |d| d.shape_scoped(&path, value))
|
|
}
|
|
Event::Variant(path, value) => self.edit(project, &path, |d| {
|
|
if d.value
|
|
.pointer(&path)
|
|
.is_some_and(|v| crate::forms::same_variant(v, &value))
|
|
{
|
|
Ok(())
|
|
} else {
|
|
d.shape_scoped(&path, value)
|
|
}
|
|
}),
|
|
Event::Array(path, index, direction) => {
|
|
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
|
|
}
|
|
Event::Append(path, value) => {
|
|
self.edit(project, &path, |d| d.append_scoped(&path, value))
|
|
}
|
|
Event::ValuePath(path, key) => {
|
|
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
|
|
}
|
|
Event::Tab(tab) => {
|
|
self.tab = tab;
|
|
replan = false;
|
|
Ok(())
|
|
}
|
|
Event::ShowUnchanged(show) => {
|
|
self.show_unchanged = show;
|
|
replan = false;
|
|
Ok(())
|
|
}
|
|
Event::ReferenceOa(choice) => {
|
|
self.reference_oa = Some(choice);
|
|
replan = false;
|
|
Ok(())
|
|
}
|
|
Event::ReferenceSa(choice) => {
|
|
self.reference_sa = Some(choice);
|
|
replan = false;
|
|
Ok(())
|
|
}
|
|
Event::ReferenceSearch(value) => {
|
|
self.reference_search = value;
|
|
replan = false;
|
|
Ok(())
|
|
}
|
|
Event::AddReference => match (&self.reference_oa, &self.reference_sa) {
|
|
(Some(oa), Some(sa)) => {
|
|
self.references.insert(oa.id, sa.id);
|
|
Ok(())
|
|
}
|
|
_ => Err("Choose both an Operational and a System reference.".into()),
|
|
},
|
|
Event::RemoveReference(id) => {
|
|
self.references.remove(&id);
|
|
Ok(())
|
|
}
|
|
Event::AcceptImpact(index, accepted) => {
|
|
if let Some(impact) = self
|
|
.plan
|
|
.as_ref()
|
|
.and_then(|p| p.impacts.get(index))
|
|
.cloned()
|
|
{
|
|
self.accepted_impacts.retain(|i| i.object != impact.object);
|
|
if accepted {
|
|
self.accepted_impacts.push(impact);
|
|
}
|
|
}
|
|
self.refresh(project);
|
|
return;
|
|
}
|
|
Event::Preview => Ok(()),
|
|
};
|
|
if let Err(error) = result {
|
|
self.error = Some(error);
|
|
self.plan = None;
|
|
self.proposed = None;
|
|
return;
|
|
}
|
|
if replan {
|
|
self.accepted_impacts.clear();
|
|
self.refresh(project);
|
|
}
|
|
}
|
|
|
|
fn edit(
|
|
&mut self,
|
|
project: &Project,
|
|
path: &str,
|
|
action: impl FnOnce(&mut Draft) -> Result<(), String>,
|
|
) -> Result<(), String> {
|
|
let facet = self.selected.as_ref().ok_or("Choose a property first.")?;
|
|
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
|
|
return Err("Choose Set final value before editing this property.".into());
|
|
}
|
|
let root = self
|
|
.target(project)
|
|
.and_then(|id| scalar::binding(project, id, facet))
|
|
.ok_or("Selected scalar property is unavailable.")?;
|
|
if path != root && !path.starts_with(&format!("{root}/")) {
|
|
return Err("Only the selected scalar property can change in this draft.".into());
|
|
}
|
|
let draft = self.draft.as_mut().ok_or("Choose an existing target.")?;
|
|
// The scoped view sends only descendants of the selected scalar property.
|
|
// Protected identity and top-level body/perspective controls are never exposed.
|
|
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
|
|
return Err("Semantic candidates retain identity, alias and perspective.".into());
|
|
}
|
|
action(draft)
|
|
}
|
|
|
|
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
|
|
self.target(project)
|
|
.and_then(|id| scalar::binding(project, id, facet))
|
|
.is_some()
|
|
&& !scalar::grouped(facet)
|
|
}
|
|
|
|
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
|
|
if !scalar::grouped(facet) {
|
|
return false;
|
|
}
|
|
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
|
|
return false;
|
|
};
|
|
if trace
|
|
.baseline
|
|
.as_ref()
|
|
.and_then(|b| b.semantic.as_ref())
|
|
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == *facet))
|
|
{
|
|
return false;
|
|
}
|
|
self.facts
|
|
.iter()
|
|
.find(|f| f.selector == *facet)
|
|
.is_some_and(|f| {
|
|
scalar::value(&f.current, Layer::Oa).is_some()
|
|
&& scalar::observation(&f.current, Layer::Oa)
|
|
== scalar::observation(&f.current, Layer::Sa)
|
|
})
|
|
}
|
|
|
|
fn request(&self, project: &Project) -> Result<SemanticTransitionRequest, String> {
|
|
let trace = self
|
|
.trace
|
|
.and_then(|id| project.traces.get(&id))
|
|
.ok_or("Choose the trace whose review history you want to update.")?;
|
|
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
|
(trace.oa, trace.sa)
|
|
} else {
|
|
(trace.sa, trace.oa)
|
|
};
|
|
let mut transition = TransitionRequest::new(self.direction, vec![source]);
|
|
transition.mappings.push(TransitionMapping {
|
|
source,
|
|
target: Some(target),
|
|
target_kind: None,
|
|
});
|
|
let mut request = SemanticTransitionRequest::new(transition);
|
|
request.traces.insert(source, trace.id);
|
|
request.references = self.references.clone();
|
|
request.reviews.push(SemanticReviewRequest {
|
|
source,
|
|
resolutions: self
|
|
.decisions
|
|
.iter()
|
|
.map(|(facet, decision)| FacetResolution {
|
|
facet: facet.clone(),
|
|
decision: *decision,
|
|
})
|
|
.collect(),
|
|
reset: vec![],
|
|
});
|
|
request.accepted_impacts = self.accepted_impacts.clone();
|
|
if self
|
|
.decisions
|
|
.values()
|
|
.any(|d| *d == SemanticDecision::Candidate)
|
|
{
|
|
let final_value = self.combined_value(project, false)?;
|
|
let candidate = if project.elements.contains_key(&target) {
|
|
SemanticRecord::Element(
|
|
serde_json::from_value(final_value)
|
|
.map_err(|e| format!("Final target value: {e}"))?,
|
|
)
|
|
} else {
|
|
SemanticRecord::Relation(
|
|
serde_json::from_value(final_value)
|
|
.map_err(|e| format!("Final target value: {e}"))?,
|
|
)
|
|
};
|
|
request.candidates.push(candidate);
|
|
}
|
|
Ok(request)
|
|
}
|
|
|
|
/// Compose selected scalar values for both the candidate and dependent form
|
|
/// controls. Incomplete inputs retain their stored shape only for controls;
|
|
/// request construction always requires every selected input to parse.
|
|
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
|
|
let trace = self
|
|
.trace
|
|
.and_then(|id| project.traces.get(&id))
|
|
.ok_or("Choose a trace.")?;
|
|
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
|
(trace.oa, trace.sa)
|
|
} else {
|
|
(trace.sa, trace.oa)
|
|
};
|
|
let mut final_value = Draft::from_project(project, target)
|
|
.ok_or("Target has no editable record.")?
|
|
.value;
|
|
let draft = self.draft.as_ref().ok_or("Target draft is unavailable.")?;
|
|
let mut map = BTreeMap::new();
|
|
for (oa, sa) in &self.references {
|
|
let (from, to) = if self.direction == TransitionDirection::OaToSa {
|
|
(*oa, *sa)
|
|
} else {
|
|
(*sa, *oa)
|
|
};
|
|
if map.insert(from, to).is_some_and(|old| old != to) {
|
|
return Err("Reverse reference correspondence is ambiguous.".into());
|
|
}
|
|
}
|
|
map.insert(source, target);
|
|
for (facet, decision) in &self.decisions {
|
|
if *decision == SemanticDecision::KeepTarget {
|
|
continue;
|
|
}
|
|
let path = scalar::binding(project, target, facet)
|
|
.ok_or("Selected property is not applicable to the target.")?;
|
|
let value = match decision {
|
|
SemanticDecision::KeepTarget => continue,
|
|
SemanticDecision::Candidate => {
|
|
let value = match draft.materialize_scopes(std::slice::from_ref(&path)) {
|
|
Ok(value) => value,
|
|
Err(_) if form_context => draft.value.clone(),
|
|
Err(error) => return Err(error),
|
|
};
|
|
value
|
|
.pointer(&path)
|
|
.cloned()
|
|
.ok_or("Candidate property is no longer available.")?
|
|
}
|
|
SemanticDecision::TakeSource => {
|
|
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
|
scalar::source_value(&fact, self.direction.source_layer(), &map)?
|
|
}
|
|
};
|
|
*final_value
|
|
.pointer_mut(&path)
|
|
.ok_or("Final target property is unavailable.")? = value;
|
|
}
|
|
Ok(final_value)
|
|
}
|
|
|
|
pub fn refresh(&mut self, project: &Project) {
|
|
self.plan = None;
|
|
self.proposed = None;
|
|
self.error = None;
|
|
let context = self.combined_value(project, true).ok();
|
|
if let Some(draft) = &mut self.draft {
|
|
draft.set_context(context);
|
|
}
|
|
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
|
|
self.error = Some(
|
|
"Several traces are available. Choose the correspondence and review history above."
|
|
.into(),
|
|
);
|
|
return;
|
|
};
|
|
match semantic_facets(project, trace, &self.references) {
|
|
Ok(facts) => self.facts = facts,
|
|
Err(error) => {
|
|
self.error = Some(error);
|
|
return;
|
|
}
|
|
}
|
|
if self
|
|
.selected
|
|
.as_ref()
|
|
.is_none_or(|s| !self.facts.iter().any(|f| &f.selector == s))
|
|
{
|
|
self.selected = self
|
|
.facts
|
|
.iter()
|
|
.find(|f| f.drift != Drift::Unchanged)
|
|
.or(self.facts.first())
|
|
.map(|f| f.selector.clone());
|
|
}
|
|
let request = match self.request(project) {
|
|
Ok(request) => request,
|
|
Err(error) => {
|
|
self.error = Some(error);
|
|
return;
|
|
}
|
|
};
|
|
match plan_semantic_transition(project, request) {
|
|
Ok(plan) => {
|
|
if plan.conflicts.is_empty() {
|
|
match prepare_semantic_transition(project, &plan) {
|
|
Ok(prepared) => self.proposed = Some(prepared.project),
|
|
Err(error) => self.error = Some(error.to_string()),
|
|
}
|
|
}
|
|
self.plan = Some(plan);
|
|
}
|
|
Err(error) => self.error = Some(error.to_string()),
|
|
}
|
|
}
|
|
|
|
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
|
if self.decisions.is_empty() {
|
|
return Err("Choose at least one property to review.".into());
|
|
}
|
|
if let Some(error) = &self.error {
|
|
return Err(error.clone());
|
|
}
|
|
let plan = self
|
|
.plan
|
|
.as_ref()
|
|
.ok_or("Refresh the review preview before applying.")?;
|
|
prepare_semantic_transition(project, plan).map_err(|e| e.to_string())?;
|
|
Ok(Command::Batch(plan.commands.clone()))
|
|
}
|
|
|
|
pub fn can_apply(&self) -> bool {
|
|
!self.decisions.is_empty()
|
|
&& self.error.is_none()
|
|
&& self
|
|
.plan
|
|
.as_ref()
|
|
.is_some_and(|p| p.conflicts.is_empty() && p.prepared_fingerprint.is_some())
|
|
}
|
|
|
|
pub fn all_references(&self, project: &Project, layer: Layer) -> Vec<Choice> {
|
|
let historical: BTreeSet<_> = self
|
|
.facts
|
|
.iter()
|
|
.flat_map(|f| scalar::references(&f.current))
|
|
.chain(
|
|
self.trace
|
|
.and_then(|id| project.traces.get(&id))
|
|
.and_then(|t| t.baseline.as_ref())
|
|
.and_then(|b| b.semantic.as_ref())
|
|
.into_iter()
|
|
.flat_map(|b| b.facets.iter().flat_map(scalar::references)),
|
|
)
|
|
.collect();
|
|
let mut ids: BTreeSet<_> = project
|
|
.elements
|
|
.keys()
|
|
.chain(project.relations.keys())
|
|
.chain(project.artifact_links.keys())
|
|
.filter(|id| project.layer_of(**id) == Some(layer))
|
|
.copied()
|
|
.collect();
|
|
ids.extend(historical.into_iter().filter(|id| !project.contains(*id)));
|
|
let search = self.reference_search.to_lowercase();
|
|
let mut choices: Vec<_> = ids
|
|
.into_iter()
|
|
.map(|id| view::choice(project, id))
|
|
.filter(|c| search.is_empty() || c.label.to_lowercase().contains(&search))
|
|
.collect();
|
|
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
|
choices
|
|
}
|
|
}
|
|
|
|
pub fn form_event(message: Message) -> Message {
|
|
let event = match message {
|
|
Message::Field(path, value) => Event::Input(path, value),
|
|
Message::FieldText(path, action) => Event::Text(path, action),
|
|
Message::FieldShape(path, value) => Event::Shape(path, value),
|
|
Message::FieldVariant(path, value) => Event::Variant(path, value),
|
|
Message::FieldArray(path, index, direction) => Event::Array(path, index, direction),
|
|
Message::FieldAppend(path, value) => Event::Append(path, value),
|
|
Message::FieldValuePath(path, key) => Event::ValuePath(path, key),
|
|
_ => Event::Preview,
|
|
};
|
|
Message::Workflow(crate::workflow::Event::Semantic(event))
|
|
}
|