//! Typed artifact lifecycle drafts; all authored values belong to an exact pair/facet. use super::*; use serde::{Deserialize, Serialize}; pub(super) const METADATA: [ArtifactFacet; 7] = [ ArtifactFacet::Name, ArtifactFacet::Description, ArtifactFacet::Owner, ArtifactFacet::Relation, ArtifactFacet::Uri, ArtifactFacet::Revision, ArtifactFacet::Fragment, ]; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum Raw { Text(String), Owner(String), Relation(ArtifactRelation), Optional { present: bool, text: String }, } #[derive(Debug, Clone)] pub enum Event { Pick(Layer, Option), Search(String), Stage, Reserve, Input(FacetSelector, Raw), Description(FacetSelector, text_editor::Action), } #[derive(Default)] pub struct Editor { pub oa: Option, pub sa: Option, pub search: String, pub seeds: BTreeMap, pub drafts: BTreeMap, pub descriptions: BTreeMap, } #[derive(Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Saved { oa: Option, sa: Option, seeds: Vec, drafts: Vec<(FacetSelector, Raw)>, } impl Saved { pub fn is_empty(&self) -> bool { self.oa.is_none() && self.sa.is_none() && self.seeds.is_empty() && self.drafts.is_empty() } } pub struct Proposal { // The final selector addresses the actual mutation slot; the last selector // retains the reviewed facet solely for conflict diagnostics. pub slots: Vec<((Id, FacetSelector), Value, FacetSelector)>, pub creations: Vec, pub candidates: BTreeSet, } pub(super) fn parts(facet: &FacetSelector) -> Option<(&KeyPair, ArtifactFacet)> { if let FacetSelector::Artifact { keys, facet } = facet { Some((keys, *facet)) } else { None } } pub(super) fn supported(facet: &FacetSelector) -> bool { parts(facet).is_some() } pub(super) fn selector(keys: &KeyPair, facet: ArtifactFacet) -> FacetSelector { FacetSelector::Artifact { keys: keys.clone(), facet, } } pub(super) fn key(keys: &KeyPair, layer: Layer) -> Option { if layer == Layer::Oa { keys.oa } else { keys.sa } } pub(super) fn field(facet: ArtifactFacet) -> Option<&'static str> { Some(match facet { ArtifactFacet::Presence => return None, ArtifactFacet::Name => "name", ArtifactFacet::Description => "description", ArtifactFacet::Owner => "owner", ArtifactFacet::Relation => "relation", ArtifactFacet::Uri => "uri", ArtifactFacet::Revision => "revision", ArtifactFacet::Fragment => "fragment", }) } pub(super) fn slot(id: Id, facet: ArtifactFacet) -> (Id, FacetSelector) { ( id, selector( &KeyPair { oa: None, sa: Some(id), }, if facet == ArtifactFacet::Presence { ArtifactFacet::Owner } else { facet }, ), ) } pub(super) fn observation(record: Option<&ArtifactLink>, facet: ArtifactFacet) -> Value { match record { None => Value::String("NotApplicable".into()), Some(record) => { serde_json::json!({"Value":serde_json::to_value(record).expect("Artifact")[field(facet).unwrap_or("owner")]}) } } } pub(super) fn raw(record: &ArtifactLink, facet: ArtifactFacet) -> Option { Some(match facet { ArtifactFacet::Presence => return None, ArtifactFacet::Name => Raw::Text(record.name.clone()), ArtifactFacet::Description => Raw::Text(record.description.clone()), ArtifactFacet::Uri => Raw::Text(record.uri.clone()), ArtifactFacet::Owner => Raw::Owner(record.owner.to_string()), ArtifactFacet::Relation => Raw::Relation(record.relation), ArtifactFacet::Revision => Raw::Optional { present: record.revision.is_some(), text: record.revision.clone().unwrap_or_default(), }, ArtifactFacet::Fragment => Raw::Optional { present: record.fragment.is_some(), text: record.fragment.clone().unwrap_or_default(), }, }) } fn compatible(facet: ArtifactFacet, raw: &Raw) -> bool { matches!( (facet, raw), ( ArtifactFacet::Name | ArtifactFacet::Description | ArtifactFacet::Uri, Raw::Text(_) ) | (ArtifactFacet::Owner, Raw::Owner(_)) | (ArtifactFacet::Relation, Raw::Relation(_)) | ( ArtifactFacet::Revision | ArtifactFacet::Fragment, Raw::Optional { .. } ) ) } fn write(record: &mut ArtifactLink, facet: ArtifactFacet, raw: &Raw) -> Result<(), String> { match (facet, raw) { (ArtifactFacet::Name, Raw::Text(v)) => record.name = v.clone(), (ArtifactFacet::Description, Raw::Text(v)) => record.description = v.clone(), (ArtifactFacet::Uri, Raw::Text(v)) => record.uri = v.clone(), (ArtifactFacet::Owner, Raw::Owner(v)) => { record.owner = v .parse() .map_err(|_| "Choose a valid explicit artifact owner identity.")? } (ArtifactFacet::Relation, Raw::Relation(v)) => record.relation = *v, (ArtifactFacet::Revision, Raw::Optional { present, text }) => { record.revision = present.then(|| text.clone()) } (ArtifactFacet::Fragment, Raw::Optional { present, text }) => { record.fragment = present.then(|| text.clone()) } _ => return Err("Artifact draft type disagrees with its selected metadata facet.".into()), }; Ok(()) } impl Editor { pub fn pair(&self) -> KeyPair { KeyPair { oa: self.oa.as_ref().map(|c| c.id), sa: self.sa.as_ref().map(|c| c.id), } } pub fn select_pair(&mut self, p: &Project, facet: &FacetSelector) -> Result<(), String> { let (keys, _) = parts(facet).ok_or("Choose an artifact address.")?; self.oa = keys.oa.map(|id| self.choice(p, id)); self.sa = keys.sa.map(|id| self.choice(p, id)); Ok(()) } pub fn choice(&self, p: &Project, id: Id) -> Choice { if p.contains(id) { return view::choice(p, id); } self.seeds.get(&id).map_or_else( || view::choice(p, id), |a| Choice { id, label: format!("{} · reserved @{}", a.name, a.alias), }, ) } pub fn choices(&self, p: &Project, trace: &Trace, layer: Layer) -> Vec { let owner = if layer == Layer::Oa { trace.oa } else { trace.sa }; let selected = key(&self.pair(), layer); let mut ids: BTreeSet<_> = p .artifact_links .values() .filter(|a| p.layer_of(a.owner).is_none_or(|actual| actual == layer)) .map(|a| a.id) .chain( self.seeds .values() .filter(|a| a.owner == owner) .map(|a| a.id), ) .collect(); if let Some(id) = selected { ids.insert(id); } if let Some(base) = trace.baseline.as_ref().and_then(|b| b.semantic.as_ref()) { for fact in &base.facets { if let Some((keys, _)) = parts(&fact.selector()) && let Some(id) = key(keys, layer) { ids.insert(id); } } } let search = self.search.to_lowercase(); let mut choices: Vec<_> = ids .into_iter() .map(|id| { let mut choice = self.choice(p, id); if let Some(artifact) = p.artifact_links.get(&id) { choice.label.push_str(&format!( " · owner: {}{}", p.named(artifact.owner), if artifact.owner == owner { "" } else { " · not attached to reviewed owner" } )); } choice }) .filter(|c| Some(c.id) == selected || c.label.to_lowercase().contains(&search)) .collect(); choices.sort_by_cached_key(|c| c.label.to_lowercase()); choices } pub fn ensure( &mut self, p: &Project, facet: &FacetSelector, layer: Layer, ) -> Result<(), String> { let (keys, which) = parts(facet).ok_or("Choose artifact metadata.")?; let id = key(keys, layer).ok_or("Choose a destination artifact identity.")?; let record = p .artifact_links .get(&id) .or_else(|| self.seeds.get(&id)) .ok_or("Reserve a destination counterpart before reviewing its metadata.")?; let value = raw(record, which).ok_or( "Presence uses explicit Keep or Take; there is no deletion tombstone candidate.", )?; self.drafts.entry(facet.clone()).or_insert(value); if which == ArtifactFacet::Description && let Some(Raw::Text(text)) = self.drafts.get(facet) { self.descriptions .entry(facet.clone()) .or_insert_with(|| text_editor::Content::with_text(text)); } Ok(()) } pub fn update( &mut self, p: &Project, trace: &Trace, direction: TransitionDirection, event: Event, ) -> Result>, String> { match event { Event::Pick(layer, choice) => { if layer == Layer::Oa { self.oa = choice } else { self.sa = choice } } Event::Search(search) => self.search = search, Event::Stage => { let keys = self.pair(); if keys.oa.is_none() && keys.sa.is_none() { return Err("Choose at least one exact artifact identity.".into()); } for layer in [Layer::Oa, Layer::Sa] { if key(&keys, layer) .and_then(|id| p.layer_of(id)) .is_some_and(|actual| actual != layer) { return Err( "Artifact pair identity belongs to the other perspective.".into() ); } } capture_facet( p, trace.oa, trace.sa, &selector(&keys, ArtifactFacet::Presence), )?; return Ok(Some( std::iter::once(ArtifactFacet::Presence) .chain(METADATA) .map(|facet| selector(&keys, facet)) .filter(|facet| capture_facet(p, trace.oa, trace.sa, facet).is_ok()) .collect(), )); } Event::Reserve => { let source = key(&self.pair(), direction.source_layer()) .and_then(|id| p.artifact_links.get(&id)) .ok_or( "Choose an existing source artifact before reserving its counterpart.", )?; let source_owner = if direction.source_layer() == Layer::Oa { trace.oa } else { trace.sa }; if source.owner != source_owner { return Err( "The source artifact is not attached to this reviewed owner.".into(), ); } let mut seed = source.clone(); seed.id = Id::new_v4(); seed.alias = format!("artifact_{}", seed.id.simple()); seed.owner = if direction.target_layer() == Layer::Oa { trace.oa } else { trace.sa }; let choice = Choice { id: seed.id, label: format!("{} · reserved @{}", seed.name, seed.alias), }; self.seeds.insert(seed.id, seed); if direction.target_layer() == Layer::Oa { self.oa = Some(choice) } else { self.sa = Some(choice) }; } Event::Input(facet, value) => { let (_, which) = parts(&facet).ok_or("Choose artifact metadata.")?; if !compatible(which, &value) || !self.drafts.contains_key(&facet) { return Err( "Choose Set final value for this exact artifact metadata first.".into(), ); } if which == ArtifactFacet::Description && let Raw::Text(text) = &value { self.descriptions .insert(facet.clone(), text_editor::Content::with_text(text)); } self.drafts.insert(facet, value); } Event::Description(facet, action) => { let editor = self .descriptions .get_mut(&facet) .ok_or("Choose the artifact description draft first.")?; editor.perform(action); self.drafts.insert(facet, Raw::Text(editor.text())); } } Ok(None) } pub fn editable( &self, p: &Project, trace: &Trace, facet: &FacetSelector, layer: Layer, ) -> bool { let Some((keys, which)) = parts(facet) else { return false; }; key(keys, layer).is_some() && capture_facet(p, trace.oa, trace.sa, facet).is_ok() && (which == ArtifactFacet::Presence || key(keys, layer).is_some_and(|id| { p.artifact_links.contains_key(&id) || self.seeds.contains_key(&id) })) } pub fn propose( &self, p: &Project, trace: &Trace, direction: TransitionDirection, references: &BTreeMap, decisions: &BTreeMap, ) -> Result { let mut result = Proposal { slots: vec![], creations: vec![], candidates: BTreeSet::new(), }; let mut pairs: BTreeMap> = BTreeMap::new(); for (facet, decision) in decisions { if let Some((keys, _)) = parts(facet) { let id = key(keys, direction.target_layer()) .ok_or("Choose a destination artifact identity.")?; pairs.entry(id).or_default().push((facet, *decision)); } } for (id, selected) in pairs { let original = p.artifact_links.get(&id); let mut record = original.cloned(); let mut deletion_origin = None; let owner = if direction.target_layer() == Layer::Oa { trace.oa } else { trace.sa }; for (facet, decision) in &selected { let (keys, which) = parts(facet).expect("Artifact"); if which != ArtifactFacet::Presence { continue; } if *decision == SemanticDecision::Candidate { return Err("Artifact presence uses Keep or Take; deletion requires explicit source-owner absence.".into()); } if *decision == SemanticDecision::TakeSource && key(keys, direction.source_layer()).is_some() { let fact = capture_facet(p, trace.oa, trace.sa, facet)?; let present = scalar::value(&fact, direction.source_layer()) .and_then(|value| value.as_bool()) .ok_or("Source artifact attachment unavailable.")?; if !present { record = None; deletion_origin = Some((*facet).clone()); } else if record.is_none() { record=Some(self.seeds.get(&id).cloned().ok_or("Reserve a complete destination artifact counterpart before taking its presence.")?); } else if record.as_ref().is_some_and(|a| a.owner != owner) { return Err("Take presence cannot move an artifact from another owner; review its Owner from the initially attached trace.".into()); } } } for (facet, decision) in &selected { let (keys, which) = parts(facet).expect("Artifact"); if which == ArtifactFacet::Presence { continue; } for (layer, side_owner) in [(Layer::Oa, trace.oa), (Layer::Sa, trace.sa)] { if key(keys, layer) .and_then(|id| p.artifact_links.get(&id)) .is_some_and(|a| a.owner != side_owner) { return Err("Artifact metadata must be reviewed from its initially attached owner trace.".into()); } } if *decision == SemanticDecision::KeepTarget { continue; } let value = if *decision == SemanticDecision::Candidate { self.drafts .get(*facet) .cloned() .ok_or("Choose an explicit final metadata value.")? } else { if key(keys, direction.source_layer()).is_none() { continue; } let source=key(keys,direction.source_layer()).and_then(|id|p.artifact_links.get(&id)).ok_or("Source artifact metadata is absent; keep or set the target metadata explicitly.")?; let mut value = raw(source, which).expect("Metadata"); if let Raw::Owner(ref mut owner) = value { let from = source.owner; let target = if direction == TransitionDirection::OaToSa { references.get(&from).copied() } else { let ids: Vec<_> = references .iter() .filter(|(_, sa)| **sa == from) .map(|(oa, _)| *oa) .collect(); (ids.len() == 1).then(|| ids[0]) } .ok_or("Map the source artifact owner explicitly.")?; *owner = target.to_string(); } value }; write( record .as_mut() .ok_or("This metadata decision conflicts with absent artifact presence.")?, which, &value, )?; if *decision == SemanticDecision::Candidate { result.candidates.insert(id); } } for (facet, decision) in &selected { let (_, which) = parts(facet).expect("Artifact"); result.slots.push(( slot(id, which), observation( if *decision == SemanticDecision::KeepTarget { original } else { record.as_ref() }, which, ), (*facet).clone(), )); } if original.is_some() && record.is_none() { for which in METADATA { result.slots.push(( slot(id, which), observation(None, which), deletion_origin .clone() .expect("Absent record has an explicit Presence decision"), )); } } if original.is_none() && let Some(record) = record { result.candidates.insert(id); result.creations.push(record); } } Ok(result) } pub fn recovery_snapshot(&self) -> Saved { Saved { oa: self.oa.as_ref().map(|c| c.id), sa: self.sa.as_ref().map(|c| c.id), seeds: self.seeds.values().cloned().collect(), drafts: self .drafts .iter() .map(|(facet, value)| (facet.clone(), value.clone())) .collect(), } } pub fn from_recovery( p: &Project, trace: &Trace, direction: TransitionDirection, saved: Saved, ) -> Result { if saved.seeds.len().saturating_add(saved.drafts.len()) > crate::source_workspace::LIMITS.model.max_records { return Err("Artifact recovery exceeds supported limits.".into()); } let mut editor = Self::default(); let owner = if direction.target_layer() == Layer::Oa { trace.oa } else { trace.sa }; let mut aliases = BTreeSet::new(); for seed in saved.seeds { if p.contains(seed.id) || seed.owner != owner || seed.alias != format!("artifact_{}", seed.id.simple()) || !aliases.insert(seed.alias.clone()) || editor.seeds.insert(seed.id, seed).is_some() { return Err("Recovered reserved artifact identity, owner or alias is invalid or duplicated.".into()); } } for (facet, value) in saved.drafts { let (keys, which) = parts(&facet).ok_or("Recovered artifact draft has a different facet family.")?; if !compatible(which, &value) { return Err("Recovered artifact draft type disagrees with its facet.".into()); } for layer in [Layer::Oa, Layer::Sa] { if key(keys, layer) .and_then(|id| p.layer_of(id)) .is_some_and(|actual| actual != layer) { return Err("Recovered artifact pair has a wrong-layer identity.".into()); } } let id = key(keys, direction.target_layer()) .ok_or("Recovered artifact draft has no destination identity.")?; if !p.artifact_links.contains_key(&id) && !editor.seeds.contains_key(&id) { return Err( "Recovered artifact draft has no existing or reserved destination record." .into(), ); } if let (ArtifactFacet::Description, Raw::Text(text)) = (which, &value) { editor .descriptions .insert(facet.clone(), text_editor::Content::with_text(text)); } if editor.drafts.insert(facet, value).is_some() { return Err("Recovered artifact draft address is duplicated.".into()); } } editor.oa = saved.oa.map(|id| editor.choice(p, id)); editor.sa = saved.sa.map(|id| editor.choice(p, id)); Ok(editor) } }