Skip to main content

eure_schema/
type_path_trace.rs

1use std::collections::HashSet;
2
3use eure_document::document::{EureDocument, NodeId};
4use eure_document::path::{ArrayIndexKind, EurePath, PathSegment};
5use eure_document::plan::traverse as plan_traverse;
6use eure_document::plan::{ArrayForm, Form, LayoutPlan, PlanError};
7use eure_document::value::ValueKind;
8use indexmap::IndexMap;
9use thiserror::Error;
10
11use crate::SchemaNodeId;
12
13/// Single-node layout strategy: a [`Form`] taken from the seven-variant
14/// taxonomy in [`eure_document::plan`].
15///
16/// For arrays the same [`Form`] is interpreted as the element form of a
17/// [`ArrayForm::PerElement`] (except `Inline`, which maps to
18/// [`ArrayForm::Inline`], and `Flatten`, which is rejected).
19pub type LayoutStrategy = Form;
20
21pub type NodeTypeTraceMap = IndexMap<NodeId, ResolvedTypeTrace>;
22pub type SchemaNodePathMap = IndexMap<SchemaNodeId, EurePath>;
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct TypePathTrace(Vec<EurePath>);
26
27#[derive(Debug, Error, Clone, PartialEq, Eq)]
28pub enum TypePathTraceError {
29    #[error("type path trace must contain at least one hop")]
30    EmptyTrace,
31}
32
33impl TypePathTrace {
34    pub fn single(path: EurePath) -> Self {
35        Self(vec![path])
36    }
37
38    pub fn from_hops(hops: Vec<EurePath>) -> Result<Self, TypePathTraceError> {
39        if hops.is_empty() {
40            return Err(TypePathTraceError::EmptyTrace);
41        }
42        Ok(Self(hops))
43    }
44
45    pub fn with_hop(&self, path: EurePath) -> Self {
46        let mut hops = self.0.clone();
47        hops.push(path);
48        Self(hops)
49    }
50
51    pub fn hops(&self) -> &[EurePath] {
52        &self.0
53    }
54
55    pub fn current(&self) -> &EurePath {
56        debug_assert!(!self.0.is_empty(), "TypePathTrace must be non-empty");
57        &self.0[self.0.len() - 1]
58    }
59
60    pub fn is_single_hop(&self) -> bool {
61        self.0.len() == 1
62    }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum TypeTraceUnresolvedReason {
67    NotVisited,
68    UnknownField { field: String },
69    UnknownExtension { extension: String },
70    UndefinedTypeReference { name: String },
71    CrossSchemaReference { namespace: String, name: String },
72    AmbiguousUnion { candidates: Vec<TypePathTrace> },
73    NoMatchingUnionVariant { candidates: Vec<TypePathTrace> },
74    InvalidVariantTag { tag: String },
75    RequiresExplicitVariant { variant: String },
76    ReferenceCycle,
77    InternalInvariant,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum ResolvedTypeTrace {
82    Resolved(TypePathTrace),
83    Ambiguous(Vec<TypePathTrace>),
84    Unresolved(TypeTraceUnresolvedReason),
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct LayoutStrategies {
89    pub by_path: IndexMap<EurePath, LayoutStrategy>,
90    pub order_by_path: IndexMap<EurePath, Vec<PathSegment>>,
91    pub schema_node_paths: SchemaNodePathMap,
92}
93
94impl Default for LayoutStrategies {
95    fn default() -> Self {
96        Self {
97            by_path: IndexMap::new(),
98            order_by_path: IndexMap::new(),
99            schema_node_paths: IndexMap::new(),
100        }
101    }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ResolvedLayout {
106    pub strategy: LayoutStrategy,
107    pub matched_path: EurePath,
108    pub hop_index: usize,
109}
110
111impl LayoutStrategies {
112    pub fn resolve(&self, trace: &TypePathTrace) -> Option<ResolvedLayout> {
113        for (hop_index, hop) in trace.hops().iter().enumerate() {
114            if let Some(strategy) = self.by_path.get(hop) {
115                return Some(ResolvedLayout {
116                    strategy: *strategy,
117                    matched_path: hop.clone(),
118                    hop_index,
119                });
120            }
121        }
122        None
123    }
124}
125
126/// Build a fully-validated [`LayoutPlan`] by applying the given schema-derived
127/// [`LayoutStrategies`] to `doc`.
128///
129/// Unlike the old `materialize_doc_layout` (which silently fell back to
130/// `LayoutStyle::Auto` on conflicts), every mismatch surfaces as a typed
131/// [`PlanError`] so callers cannot accidentally emit partial data.
132pub fn materialize_layout_plan(
133    doc: EureDocument,
134    node_traces: &NodeTypeTraceMap,
135    strategies: &LayoutStrategies,
136) -> Result<LayoutPlan, PlanError> {
137    let node_paths = collect_document_node_paths(&doc);
138    let mut builder = LayoutPlan::builder(doc);
139    let root = builder.document().get_root_id();
140
141    for (node_id, node_path) in node_paths {
142        if node_id == root {
143            if let Some(order) = node_traces
144                .get(&node_id)
145                .and_then(|trace| resolve_order_for_trace(strategies, trace))
146            {
147                apply_order(&mut builder, node_id, &node_path, order)?;
148            }
149            continue;
150        }
151
152        let Some(trace) = node_traces.get(&node_id) else {
153            continue;
154        };
155
156        if let Some(style) = resolve_style_for_trace(strategies, trace, node_id)? {
157            let kind = builder.document().node(node_id).content.value_kind();
158            if matches!(kind, ValueKind::Array) {
159                let array_form = form_to_array_form(node_id, style)?;
160                builder.set_array_form(node_id, array_form)?;
161            } else {
162                builder.set_form(node_id, style)?;
163            }
164        }
165
166        if let Some(order) = resolve_order_for_trace(strategies, trace) {
167            apply_order(&mut builder, node_id, &node_path, order)?;
168        }
169    }
170
171    builder.build()
172}
173
174fn apply_order(
175    builder: &mut eure_document::plan::PlanBuilder,
176    node_id: NodeId,
177    node_path: &[PathSegment],
178    order: Vec<PathSegment>,
179) -> Result<(), PlanError> {
180    if !is_orderable(builder.document(), node_id) {
181        return Ok(());
182    }
183    let present: Vec<PathSegment> = {
184        let direct = plan_traverse::children_of(builder.document(), node_id);
185        order
186            .into_iter()
187            .filter(|seg| direct.iter().any(|(s, _)| s == seg))
188            .collect()
189    };
190    if present.is_empty() {
191        return Ok(());
192    }
193    builder.order_at(node_path, present)?;
194    Ok(())
195}
196
197fn is_orderable(doc: &EureDocument, node: NodeId) -> bool {
198    matches!(
199        doc.node(node).content.value_kind(),
200        ValueKind::Map | ValueKind::PartialMap
201    )
202}
203
204fn form_to_array_form(node: NodeId, form: Form) -> Result<ArrayForm, PlanError> {
205    match form {
206        Form::Inline => Ok(ArrayForm::Inline),
207        Form::Flatten => Err(PlanError::IncompatibleArrayForm {
208            node,
209            form: ArrayForm::PerElement(Form::Flatten),
210            reason: eure_document::plan::ArrayFormReason::FlattenElementDisallowed,
211        }),
212        element => Ok(ArrayForm::PerElement(element)),
213    }
214}
215
216fn resolve_style_for_trace(
217    strategies: &LayoutStrategies,
218    trace: &ResolvedTypeTrace,
219    node: NodeId,
220) -> Result<Option<LayoutStrategy>, PlanError> {
221    match trace {
222        ResolvedTypeTrace::Resolved(trace) => Ok(strategies.resolve(trace).map(|r| r.strategy)),
223        ResolvedTypeTrace::Ambiguous(candidates) => {
224            let mut resolved: Option<LayoutStrategy> = None;
225            for candidate in candidates {
226                let candidate_style = match strategies.resolve(candidate) {
227                    Some(r) => r.strategy,
228                    None => return Ok(None),
229                };
230                match resolved {
231                    Some(existing) if existing != candidate_style => {
232                        return Err(PlanError::ConflictingOverride { node });
233                    }
234                    None => resolved = Some(candidate_style),
235                    _ => {}
236                }
237            }
238            Ok(resolved)
239        }
240        ResolvedTypeTrace::Unresolved(_) => Ok(None),
241    }
242}
243
244fn resolve_order_for_trace(
245    strategies: &LayoutStrategies,
246    trace: &ResolvedTypeTrace,
247) -> Option<Vec<PathSegment>> {
248    match trace {
249        ResolvedTypeTrace::Resolved(trace) => resolve_order_for_hops(strategies, trace),
250        ResolvedTypeTrace::Ambiguous(candidates) => {
251            let mut resolved: Option<Vec<PathSegment>> = None;
252            for candidate in candidates {
253                let candidate_order = resolve_order_for_hops(strategies, candidate)?;
254                if let Some(existing) = resolved.as_ref() {
255                    if *existing != candidate_order {
256                        return None;
257                    }
258                } else {
259                    resolved = Some(candidate_order);
260                }
261            }
262            resolved
263        }
264        ResolvedTypeTrace::Unresolved(_) => None,
265    }
266}
267
268fn resolve_order_for_hops(
269    strategies: &LayoutStrategies,
270    trace: &TypePathTrace,
271) -> Option<Vec<PathSegment>> {
272    for hop in trace.hops() {
273        if let Some(order) = strategies.order_by_path.get(hop) {
274            return Some(order.clone());
275        }
276    }
277    None
278}
279
280fn collect_document_node_paths(doc: &EureDocument) -> IndexMap<NodeId, Vec<PathSegment>> {
281    let mut out = IndexMap::new();
282    let mut visited = HashSet::new();
283    collect_document_node_paths_rec(
284        doc,
285        doc.get_root_id(),
286        &mut Vec::new(),
287        &mut out,
288        &mut visited,
289    );
290    out
291}
292
293fn collect_document_node_paths_rec(
294    doc: &EureDocument,
295    node_id: NodeId,
296    path: &mut Vec<PathSegment>,
297    out: &mut IndexMap<NodeId, Vec<PathSegment>>,
298    visited: &mut HashSet<NodeId>,
299) {
300    if !visited.insert(node_id) {
301        return;
302    }
303    out.insert(node_id, path.clone());
304    let node = doc.node(node_id);
305
306    for (ext, &child_id) in node.extensions.iter() {
307        path.push(PathSegment::Extension(ext.clone()));
308        collect_document_node_paths_rec(doc, child_id, path, out, visited);
309        path.pop();
310    }
311
312    match &node.content {
313        eure_document::document::node::NodeValue::Array(array) => {
314            for (index, &child_id) in array.iter().enumerate() {
315                path.push(PathSegment::ArrayIndex(ArrayIndexKind::Specific(index)));
316                collect_document_node_paths_rec(doc, child_id, path, out, visited);
317                path.pop();
318            }
319        }
320        eure_document::document::node::NodeValue::Tuple(tuple) => {
321            for (index, &child_id) in tuple.iter().enumerate() {
322                path.push(PathSegment::TupleIndex(index as u8));
323                collect_document_node_paths_rec(doc, child_id, path, out, visited);
324                path.pop();
325            }
326        }
327        eure_document::document::node::NodeValue::Map(map) => {
328            for (key, &child_id) in map.iter() {
329                path.push(PathSegment::Value(key.clone()));
330                collect_document_node_paths_rec(doc, child_id, path, out, visited);
331                path.pop();
332            }
333        }
334        eure_document::document::node::NodeValue::PartialMap(map) => {
335            for (key, &child_id) in map.iter() {
336                path.push(PathSegment::from_partial_object_key(key.clone()));
337                collect_document_node_paths_rec(doc, child_id, path, out, visited);
338                path.pop();
339            }
340        }
341        eure_document::document::node::NodeValue::Primitive(_)
342        | eure_document::document::node::NodeValue::Hole(_) => {}
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use eure_document::value::ObjectKey;
350
351    #[test]
352    fn resolve_first_hop_wins() {
353        let first = EurePath::root();
354        let second = EurePath(vec![PathSegment::Value(ObjectKey::String("a".to_string()))]);
355        let trace = TypePathTrace::from_hops(vec![first.clone(), second.clone()]).unwrap();
356
357        let mut layout = LayoutStrategies::default();
358        layout.by_path.insert(second, Form::Section);
359        layout.by_path.insert(first.clone(), Form::Inline);
360
361        let resolved = layout.resolve(&trace).expect("should resolve");
362        assert_eq!(resolved.strategy, Form::Inline);
363        assert_eq!(resolved.matched_path, first);
364        assert_eq!(resolved.hop_index, 0);
365    }
366
367    #[test]
368    fn resolve_no_match() {
369        let trace = TypePathTrace::single(EurePath::root());
370        let layout = LayoutStrategies::default();
371        assert!(layout.resolve(&trace).is_none());
372    }
373
374    #[test]
375    fn type_path_trace_rejects_empty_hops() {
376        let err = TypePathTrace::from_hops(Vec::new()).expect_err("empty trace must be rejected");
377        assert_eq!(err, TypePathTraceError::EmptyTrace);
378    }
379
380    #[test]
381    fn resolve_exact_match_only() {
382        let parent = EurePath(vec![PathSegment::Value(ObjectKey::String(
383            "item".to_string(),
384        ))]);
385        let child = EurePath(vec![
386            PathSegment::Value(ObjectKey::String("item".to_string())),
387            PathSegment::Value(ObjectKey::String("value".to_string())),
388        ]);
389        let trace = TypePathTrace::single(child);
390
391        let mut layout = LayoutStrategies::default();
392        layout.by_path.insert(parent, Form::Section);
393
394        assert!(layout.resolve(&trace).is_none());
395    }
396
397    #[test]
398    fn ambiguous_trace_resolves_when_all_candidates_have_same_strategy() {
399        let hop_a = EurePath(vec![PathSegment::Value(ObjectKey::String("a".to_string()))]);
400        let hop_b = EurePath(vec![PathSegment::Value(ObjectKey::String("b".to_string()))]);
401        let mut strategies = LayoutStrategies::default();
402        strategies.by_path.insert(hop_a.clone(), Form::Inline);
403        strategies.by_path.insert(hop_b.clone(), Form::Inline);
404
405        let trace = ResolvedTypeTrace::Ambiguous(vec![
406            TypePathTrace::single(hop_a),
407            TypePathTrace::single(hop_b),
408        ]);
409        assert_eq!(
410            resolve_style_for_trace(&strategies, &trace, NodeId(0)).unwrap(),
411            Some(Form::Inline)
412        );
413    }
414
415    #[test]
416    fn ambiguous_trace_rejects_when_candidates_conflict() {
417        let hop_a = EurePath(vec![PathSegment::Value(ObjectKey::String("a".to_string()))]);
418        let hop_b = EurePath(vec![PathSegment::Value(ObjectKey::String("b".to_string()))]);
419        let mut strategies = LayoutStrategies::default();
420        strategies.by_path.insert(hop_a.clone(), Form::Inline);
421        strategies.by_path.insert(hop_b.clone(), Form::BindingBlock);
422
423        let trace = ResolvedTypeTrace::Ambiguous(vec![
424            TypePathTrace::single(hop_a),
425            TypePathTrace::single(hop_b),
426        ]);
427        assert!(matches!(
428            resolve_style_for_trace(&strategies, &trace, NodeId(0)),
429            Err(PlanError::ConflictingOverride { .. })
430        ));
431    }
432}