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