Files
ARCADIA-Studio/crates/oa-desktop/src/forms/history_fields.rs
T

708 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String, usize>,
}
pub(super) fn is_path(path: &str) -> bool {
path == ROOT || path.starts_with("/baseline/")
}
fn api() -> &'static Value {
static API: OnceLock<Value> = 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<Value>> {
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::<usize>().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<Value, &'static str> {
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::<String>() + 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<FactChoice>, &'static str> {
static CHOICES: OnceLock<Result<Vec<FactChoice>, &'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<Value, &'static str> {
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::<Vec<_>>().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::<Result<_, _>>() {
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::<FactChoice>, 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<FacetReview> {
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;