//! Typed editing of stored observations, independently of the live counterparts. //! The checked-in, contract-tested OpenAPI descriptor supplies only field shapes. //! Serde and core historical validation remain the admission authority. use super::*; use iced::widget::column; use std::sync::OnceLock; pub(super) const TOGGLE: &str = "/$stored_history"; pub(super) const PAGE: &str = "/$stored_history_page"; pub(super) const VARIANT: &str = "/$stored_history_variant"; const ROOT: &str = "/baseline"; const FACETS: &str = "/baseline/semantic/facets"; const PAGE_SIZE: usize = 12; const MAX_DEPTH: usize = 32; #[derive(Default)] pub(super) struct State { pub open: bool, pub pages: BTreeMap, } pub(super) fn is_path(path: &str) -> bool { path == ROOT || path.starts_with("/baseline/") } fn api() -> &'static Value { static API: OnceLock = OnceLock::new(); API.get_or_init(|| { serde_json::from_str(include_str!("../../../../docs/openapi.json")) .expect("The bundled OpenAPI descriptor is valid JSON") }) } fn resolve(mut schema: &'static Value) -> Result<&'static Value, &'static str> { for _ in 0..MAX_DEPTH { let Some(reference) = schema.get("$ref").and_then(Value::as_str) else { return Ok(schema); }; schema = reference .strip_prefix('#') .and_then(|path| api().pointer(path)) .ok_or("Stored-history descriptor has an unresolved local reference.")?; } Err("Stored-history descriptor reference depth exceeded.") } fn named(name: &str) -> Result<&'static Value, &'static str> { api()["components"]["schemas"] .get(name) .ok_or("Stored-history descriptor is missing a required type.") } fn alternatives(schema: &'static Value) -> Option<&'static Vec> { schema .get("oneOf") .or_else(|| schema.get("anyOf")) .and_then(Value::as_array) } fn at_schema(schema: &'static Value, parts: &[&str], depth: usize) -> Option<&'static Value> { if depth > MAX_DEPTH { return None; } let schema = resolve(schema).ok()?; let Some((part, rest)) = parts.split_first() else { return Some(schema); }; if let Some(options) = alternatives(schema) { return options.iter().find_map(|s| at_schema(s, parts, depth + 1)); } match schema.get("type").and_then(Value::as_str) { Some("object") => at_schema(schema.get("properties")?.get(*part)?, rest, depth + 1), Some("array") if part.parse::().is_ok() => { at_schema(schema.get("items")?, rest, depth + 1) } _ => None, } } fn schema_at(path: &str) -> Option<&'static Value> { let tail = path.strip_prefix(ROOT)?; let parts: Vec<_> = tail .trim_start_matches('/') .split('/') .filter(|s| !s.is_empty()) .collect(); at_schema(named("TraceBaseline").ok()?, &parts, 0) } pub(super) fn has_field(path: &str) -> bool { schema_at(path).is_some() } pub(super) fn free_string(path: &str) -> bool { fn text_shape(schema: &'static Value, depth: usize) -> bool { if depth > MAX_DEPTH { return false; } let Ok(schema) = resolve(schema) else { return false; }; if let Some(options) = alternatives(schema) { return options.iter().any(|s| text_shape(s, depth + 1)); } schema.get("type").and_then(Value::as_str) == Some("string") && schema.get("enum").is_none() && schema.get("format").and_then(Value::as_str) != Some("uuid") } schema_at(path).is_some_and(|s| text_shape(s, 0)) } /// A shape seed is a draft, never an acknowledged historical fact. UUIDs start /// empty. Observed starts NA on both sides, which core rejects. Typed defaults /// inside Value are introduced only after the engineer chooses that branch. fn seed(schema: &'static Value, depth: usize) -> Result { if depth > MAX_DEPTH { return Err("Stored-history constructor depth exceeded."); } let schema = resolve(schema)?; if let Some(options) = alternatives(schema) { if options .iter() .filter_map(|s| resolve(s).ok()) .filter_map(|s| s.get("enum").and_then(Value::as_array)) .any(|values| values.contains(&json!("NotApplicable"))) { return Ok(json!("NotApplicable")); } if options.iter().any(|s| matches_value(s, &Value::Null, 0)) { return Ok(Value::Null); } return seed( options .first() .ok_or("Stored-history union has no constructors.")?, depth + 1, ); } if let Some(values) = schema.get("enum").and_then(Value::as_array) { return values .first() .cloned() .ok_or("Stored-history enumeration has no values."); } Ok(match schema.get("type").and_then(Value::as_str) { Some("object") => { let fields = schema .get("properties") .and_then(Value::as_object) .ok_or("Stored-history object has no closed field descriptor.")?; let mut result = serde_json::Map::new(); for (name, schema) in fields { result.insert(name.clone(), seed(schema, depth + 1)?); } Value::Object(result) } Some("array") => json!([]), Some("boolean") => json!(false), Some("integer") => json!(0), Some("number") => json!(0.0), Some("string") => json!(""), Some("null") => Value::Null, _ => return Err("Unsupported stored-history field descriptor."), }) } /// Shape matching selects the displayed constructor; it is not validation. /// Required values may be incomplete while their chosen branch stays editable. fn matches_value(schema: &'static Value, value: &Value, depth: usize) -> bool { if depth > MAX_DEPTH { return false; } let Ok(schema) = resolve(schema) else { return false; }; if let Some(options) = alternatives(schema) { return options.iter().any(|s| matches_value(s, value, depth + 1)); } if let Some(choices) = schema.get("enum").and_then(Value::as_array) { return choices.contains(value); } match schema.get("type").and_then(Value::as_str) { Some("object") => value.as_object().is_some_and(|fields| { schema .get("properties") .and_then(Value::as_object) .is_some_and(|properties| { fields.keys().all(|key| properties.contains_key(key)) && schema .get("required") .and_then(Value::as_array) .is_none_or(|required| { required .iter() .filter_map(Value::as_str) .all(|key| fields.contains_key(key)) }) }) }), Some("array") => value.is_array(), Some("boolean") => value.is_boolean(), Some("integer" | "number") => value.is_number(), Some("string") => value.is_string(), Some("null") => value.is_null(), _ => false, } } fn label(name: &str) -> String { match name { "oa" => return "Operational observation".into(), "sa" => return "System observation".into(), "NotApplicable" => return "Not applicable".into(), "Value" => return "Observed value".into(), "keys" => return "Exact historical keys".into(), "review" => return "Stored observations".into(), _ => {} } let mut out = String::new(); let mut previous_lower = false; for c in name.chars() { if c == '_' { out.push(' '); } else { if c.is_uppercase() && previous_lower { out.push(' '); } out.push(c); } previous_lower = c.is_lowercase(); } let mut chars = out.chars(); chars .next() .map(|c| c.to_uppercase().collect::() + chars.as_str()) .unwrap_or_default() } fn branch_label(schema: &'static Value) -> String { let Ok(schema) = resolve(schema) else { return "Unavailable type".into(); }; if let Some(word) = schema .get("enum") .and_then(Value::as_array) .filter(|v| v.len() == 1) .and_then(|v| v[0].as_str()) { return label(word); } if schema.get("enum").is_some() { return "Enumeration · choose below".into(); } if let Some(fields) = schema.get("properties").and_then(Value::as_object) && fields.len() == 1 { return label(fields.keys().next().unwrap()); } match schema.get("type").and_then(Value::as_str) { Some("null") => "Absent (none)".into(), Some("object") => "Set record".into(), Some("array") => "Set list".into(), Some("string") if schema.get("format").and_then(Value::as_str) == Some("uuid") => { "Set historical UUID".into() } Some("string") => "Set text".into(), Some("integer" | "number") => "Set number".into(), _ => "Set value / choose constructor".into(), } } #[derive(Clone, PartialEq, Eq)] struct FactChoice { label: String, value: Value, } impl std::fmt::Display for FactChoice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.label) } } /// Flatten only the closed FacetReview selector levels, not observation values. /// Collection and incident-end choices are selectors too, so none is guessed. fn fact_choices() -> Result<&'static Vec, &'static str> { static CHOICES: OnceLock, &'static str>> = OnceLock::new(); CHOICES .get_or_init(|| { let root = resolve(named("FacetReview")?)?; let mut result = vec![]; for branch in alternatives(root).ok_or("FacetReview must be a closed union.")? { let outer = resolve(branch)?; let (family, payload) = outer["properties"] .as_object() .and_then(|v| v.iter().next()) .ok_or("Facet family is missing.")?; let payload = resolve(payload)?; if let Some(leaves) = alternatives(payload) { for leaf in leaves { let mut value = serde_json::Map::new(); value.insert(family.clone(), seed(leaf, 0)?); result.push(FactChoice { label: format!("{} / {}", label(family), branch_label(leaf)), value: Value::Object(value), }); } } else if family == "Artifact" { let leaves = alternatives(resolve(&payload["properties"]["review"])?) .ok_or("Artifact facts must be a closed union.")?; for leaf in leaves { let mut value = seed(branch, 0)?; value[family]["review"] = seed(leaf, 0)?; result.push(FactChoice { label: format!("Artifact / {}", branch_label(leaf)), value, }); } } else if matches!(family.as_str(), "Member" | "Order" | "Incident") { let field = if family == "Incident" { "end" } else { "collection" }; let choices = resolve(&payload["properties"][field])?["enum"] .as_array() .ok_or("Historical selector choices are missing.")?; for choice in choices { let mut value = seed(branch, 0)?; value[family][field] = choice.clone(); result.push(FactChoice { label: format!( "{} / {}", label(family), label(choice.as_str().unwrap_or("")) ), value, }); } } else { result.push(FactChoice { label: label(family), value: seed(branch, 0)?, }); } } Ok(result) }) .as_ref() .map_err(|error| *error) } fn variant_message(path: &str, value: Value, present: bool) -> Message { Message::FieldShape( if present { format!("{VARIANT}{path}") } else { path.into() }, value, ) } fn branch_seed(schema: &'static Value) -> Result { let schema = resolve(schema)?; if let Some(values) = schema .get("enum") .and_then(Value::as_array) .filter(|v| v.len() == 1) { Ok(values[0].clone()) } else { seed(schema, 0) } } fn branch_has_fields(schema: &'static Value) -> bool { let Ok(schema) = resolve(schema) else { return false; }; schema.get("type").and_then(Value::as_str) != Some("null") && schema .get("enum") .and_then(Value::as_array) .is_none_or(|values| values.len() > 1) } /// Constructor identity is independent of a primitive's edited content. In /// particular, absent, an empty UUID/text, a number and a list are different /// branches. String enum tags and externally tagged objects retain their variant. pub(super) fn constructor_tag(path: &str, value: &Value) -> String { fn enum_value(schema: &'static Value, value: &Value, depth: usize) -> bool { if depth > MAX_DEPTH { return false; } let Ok(schema) = resolve(schema) else { return false; }; if let Some(options) = alternatives(schema) { return options .iter() .any(|option| enum_value(option, value, depth + 1)); } schema .get("enum") .and_then(Value::as_array) .is_some_and(|values| values.contains(value)) } match value { Value::Null => "null".into(), Value::Bool(_) => "boolean".into(), Value::Number(_) => "number".into(), Value::Array(_) => "array".into(), Value::String(word) if schema_at(path).is_some_and(|schema| enum_value(schema, value, 0)) => { format!("enum:{word}") } Value::String(_) => "string".into(), Value::Object(fields) => format!( "object:{}", fields.keys().cloned().collect::>().join("/") ), } } fn paging<'a>(draft: &'a Draft, path: &str, count: usize) -> (usize, View<'a, Message>) { let last = count.saturating_sub(1) / PAGE_SIZE; let page = draft .history .pages .get(path) .copied() .unwrap_or(0) .min(last); let event = |page| Message::FieldShape(format!("{PAGE}{path}"), json!(page)); let view = row![ button("Previous").on_press_maybe((page > 0).then(|| event(page - 1))), text(format!( "{}–{} of {count}", if count == 0 { 0 } else { page * PAGE_SIZE + 1 }, ((page + 1) * PAGE_SIZE).min(count) )) .size(11), button("Next").on_press_maybe((page < last).then(|| event(page + 1))) ] .spacing(6) .align_y(iced::Center) .into(); (page, view) } fn render<'a>( draft: &'a Draft, project: &'a Project, schema: &'static Value, value: Option<&'a Value>, path: &str, title: &str, depth: usize, ) -> View<'a, Message> { if depth > MAX_DEPTH { return text("Stored-history field nesting exceeds the supported editor depth.") .size(11) .into(); } let schema = match resolve(schema) { Ok(s) => s, Err(e) => return text(e).size(11).into(), }; let present = value.is_some(); let value = value.unwrap_or(&Value::Null); if let Some(options) = alternatives(schema) { let initial: Vec<_> = match options.iter().map(branch_seed).collect::>() { Ok(values) => values, Err(error) => { return text(format!( "Stored-history constructor is unavailable: {error}" )) .size(11) .into(); } }; let labels: Vec<_> = options.iter().map(branch_label).collect(); let selected = options.iter().position(|s| matches_value(s, value, 0)); let path_owned = path.to_string(); let labels_owned = labels.clone(); let mut out = column![ text(title.to_owned()).size(11).color(style::MUTED), pick_list( labels, selected.map(|i| branch_label(&options[i])), move |choice| { let index = labels_owned.iter().position(|v| v == &choice).unwrap(); variant_message(&path_owned, initial[index].clone(), present) } ) .placeholder("Choose stored value / constructor…") .text_size(12) .padding(8) .width(Fill) ] .spacing(6); if let Some(index) = selected { let branch = resolve(&options[index]).unwrap_or(&options[index]); if branch_has_fields(branch) { out = out.push(render( draft, project, branch, Some(value), path, "", depth + 1, )); } } return out.into(); } if let Some(words) = schema.get("enum").and_then(Value::as_array) { let choices: Vec<_> = words .iter() .filter_map(Value::as_str) .map(str::to_owned) .collect(); let selected = value .as_str() .map(str::to_owned) .filter(|v| choices.contains(v)); let path = path.to_string(); return column![ text(title.to_owned()).size(11).color(style::MUTED), pick_list(choices, selected, move |word| Message::FieldShape( path.clone(), json!(word) )) .placeholder("Choose an explicit value…") .text_size(12) .padding(8) .width(Fill) ] .spacing(6) .into(); } match schema.get("type").and_then(Value::as_str) { Some("object") => { let Some(fields) = schema.get("properties").and_then(Value::as_object) else { return text("Unsupported stored-history object shape.").size(11).into(); }; let mut out = column![].spacing(8); if !title.is_empty() { out = out.push(text(title.to_owned()).size(12).color(style::INK)); } for (key, child_schema) in fields { out = out.push(render(draft, project, child_schema, value.get(key), &format!("{path}/{key}"), &label(key), depth + 1)); } out.into() } Some("array") => { let Some(items) = value.as_array() else { return button("Initialize empty list").on_press(Message::FieldShape(path.into(), json!([]))).into(); }; let Some(item_schema) = schema.get("items") else { return text("Missing history list item type.").into(); }; let mut out = column![text(title.to_owned()).size(12)].spacing(8); if path == FACETS { match fact_choices() { Ok(choices) => { let path = path.to_owned(); out = out.push(pick_list(choices.clone(), None::, move |choice| Message::FieldAppend(path.clone(), choice.value)) .placeholder("Add: choose family and fact…").text_size(12).padding(8).width(Fill)); } Err(error) => { out = out.push(text(error).size(11)); } } } else { match seed(item_schema, 0) { Ok(initial) => out = out.push(button("Add list value").on_press(Message::FieldAppend(path.into(), initial))), Err(error) => out = out.push(text(error).size(11)), } } let (page, pager) = paging(draft, path, items.len()); if items.len() > PAGE_SIZE { out = out.push(pager); } for (index, item) in items.iter().enumerate().skip(page * PAGE_SIZE).take(PAGE_SIZE) { let item_path = format!("{path}/{index}"); let mut card = column![render(draft, project, item_schema, Some(item), &item_path, &format!("{} {}", title, index + 1), depth + 1)].spacing(8); if path == FACETS && let Some(current) = current_observation(draft, project, &item_path) { card = card.push(button("Use current counterpart observations") .on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav)); } card = card.push(row![ button("↑").on_press_maybe((index > 0).then(|| Message::FieldArray(path.into(), Some(index), -1))), button("↓").on_press_maybe((index + 1 < items.len()).then(|| Message::FieldArray(path.into(), Some(index), 1))), button("Remove").on_press(Message::FieldArray(path.into(), Some(index), 0)) ].spacing(6)); out = out.push(container(card).padding(10).style(style::subtle)); } if items.is_empty() { out = out.push(text("Empty list · no stored entries").size(11).color(style::MUTED)); } out.into() } Some("boolean") => { let path = path.to_owned(); checkbox(value.as_bool().unwrap_or(false)).label(title.to_owned()) .on_toggle(move |v| Message::FieldShape(path.clone(), json!(v))).text_size(12).into() } Some("integer" | "number" | "string") => { let is_uuid = schema.get("format").and_then(Value::as_str) == Some("uuid"); let numeric = schema.get("type").and_then(Value::as_str) != Some("string"); let mut out = column![text(title.to_owned()).size(11).color(style::MUTED)].spacing(5); if let Some(input) = draft.inputs.get(path) { let owned = path.to_owned(); if !numeric && !is_uuid && !draft.exact_strings { out = out.push(text_editor(&input.editor).on_action(move |a| Message::FieldText(owned.clone(), a)).size(12).height(72).padding(8)); } else { out = out.push(text_input(if is_uuid { "Historical UUID, including deleted identities" } else { "Stored value" }, &input.raw) .on_input(move |raw| Message::Field(owned.clone(), raw)).size(12).padding(8)); } } else { match seed(schema, 0) { Ok(value) => out = out.push(button("Set stored value").on_press(Message::FieldShape(path.into(), value))), Err(error) => out = out.push(text(error).size(11)), } } if is_uuid { out = out.push(text("Historical identity · existence in the current model is not required.").size(10).color(style::MUTED)); } out.into() } Some("null") => text("Absent (none)").size(11).into(), _ => text("Unsupported stored-history descriptor. The draft is retained; this field cannot be edited with these controls.").size(11).into(), } } fn current_observation(draft: &Draft, project: &Project, item_path: &str) -> Option { let raw = draft .materialize_scopes(&[item_path.to_owned(), "/oa".into(), "/sa".into()]) .ok()?; let fact: FacetReview = serde_json::from_value(raw.pointer(item_path)?.clone()).ok()?; let oa = raw.get("oa")?.as_str()?.parse().ok()?; let sa = raw.get("sa")?.as_str()?.parse().ok()?; semantic::capture_facet(project, oa, sa, &fact.selector()).ok() } pub(super) fn view<'a>( draft: &'a Draft, project: &'a Project, value: &'a Value, ) -> View<'a, Message> { let count = value .pointer("/semantic/facets") .and_then(Value::as_array) .map_or(0, Vec::len); let mut out = column![ text("Stored review history").size(12).color(style::INK), text(if value.is_null() { "No stored baseline".into() } else { format!("Prose baseline · {count} semantic observations") }) .size(11) .color(style::MUTED), button(if draft.history.open { "Close stored-history editor" } else { "Edit stored history (advanced)…" }) .on_press(Message::FieldShape( TOGGLE.into(), json!(!draft.history.open) )) .style(style::nav) ] .spacing(8); if !draft.history.open { return out.into(); } out = out.push(text("These edits change the trace's stored observations only. They do not change either counterpart. Ordinary Keep/Take review remains available from Traceability.").size(11)); if value.is_null() { return match named("TraceBaseline").and_then(|s| seed(s, 0)) { Ok(initial) => out .push( button("Start empty stored baseline") .on_press(Message::FieldShape(ROOT.into(), initial)), ) .into(), Err(error) => out.push(text(error).size(11)).into(), }; } out = out.push( button("Clear whole stored baseline") .on_press(Message::FieldShape(ROOT.into(), Value::Null)) .style(style::nav), ); match named("TraceBaseline") { Ok(schema) => out = out.push(render(draft, project, schema, Some(value), ROOT, "", 0)), Err(error) => out = out.push(text(error).size(11)), } let checked = draft.materialize_scopes(&[ROOT.into()]).and_then(|raw| { let baseline: TraceBaseline = serde_json::from_value(raw["baseline"].clone()).map_err(|e| e.to_string())?; baseline.semantic.as_ref().map_or(Ok(()), |b| { semantic::validate_semantic_baseline(b).map_err(|e| e.join("; ")) }) }); if let Err(error) = checked { out = out.push(text(format!("Stored history is incomplete or invalid: {error}")).size(11)); } out.into() } #[cfg(test)] mod tests;