1use std::collections::{BTreeMap, BTreeSet};
11
12use crate::claims::parse_claims;
13use crate::manifest::{
14 Binding, BindingRole, ClaimId, Link, LinkKind, Manifest, Node, NodeFields, NodeId, NodeKind,
15 is_canonical_id,
16};
17use crate::report::ParseReport;
18use crate::schema::{RawNode, parse_doc};
19
20pub fn parse_sources(
29 tree_yaml: &str,
30 claims_md: Option<&str>,
31) -> Result<(Manifest, ParseReport), ParseReport> {
32 let mut report = ParseReport::default();
33
34 let doc = match parse_doc(tree_yaml) {
35 Ok(doc) => doc,
36 Err(msg) => {
37 report.error("document", msg);
38 return Err(report);
39 }
40 };
41
42 for key in doc.extra.keys() {
43 report.warn("document", format!("unknown field `{key}`"));
44 }
45
46 let roots: Vec<RawNode> = match (doc.tree, doc.root) {
47 (Some(_), Some(_)) => {
48 report.error(
49 "document",
50 "both `tree:` and `root:` are present; exactly one is allowed",
51 );
52 return Err(report);
53 }
54 (None, None) => {
55 report.error("document", "neither `tree:` nor `root:` is present");
56 return Err(report);
57 }
58 (Some(tree), None) => {
59 if tree.is_empty() {
60 report.warn("document", "empty manifest (`tree: []`)");
61 }
62 tree
63 }
64 (None, Some(root)) => vec![*root],
65 };
66
67 let claims_present = claims_md.is_some();
69 let (claims, duplicate_claim_ids) = match claims_md {
70 Some(md) => {
71 let parsed = parse_claims(md);
72 (parsed.claims, parsed.duplicate_ids)
73 }
74 None => (Vec::new(), Vec::new()),
75 };
76 let claim_ids: BTreeSet<ClaimId> = claims.iter().map(|c| c.id.clone()).collect();
77 for id in duplicate_claim_ids {
78 report.error(format!("claims[{id}]"), "duplicate claim id");
79 }
80
81 let mut norm = Normalizer {
82 report,
83 claims_present,
84 claim_ids,
85 nodes: Vec::new(),
86 node_ids: BTreeSet::new(),
87 bindings: Vec::new(),
88 child_links: Vec::new(),
89 also: Vec::new(),
90 };
91 for raw in &roots {
92 norm.dfs(raw, None);
93 }
94
95 let parent_of: BTreeMap<NodeId, NodeId> = norm
106 .child_links
107 .iter()
108 .map(|l| (l.to.clone(), l.from.clone()))
109 .collect();
110 let mut depends_links: Vec<Link> = Vec::new();
111 for (from, targets) in &norm.also {
112 for (i, target) in targets.iter().enumerate() {
113 let t = target.trim();
114 let to = NodeId::new(t);
115 if !norm.node_ids.contains(&to) {
116 norm.report.error(
117 format!("nodes[{from}].also_depends_on[{i}]"),
118 format!("`also_depends_on` references unknown node `{t}`"),
119 );
120 continue;
121 }
122 if is_ancestor(&to, from, &parent_of) {
123 norm.report.warn(
124 format!("nodes[{from}].also_depends_on[{i}]"),
125 format!(
126 "redundant `also_depends_on` on ancestor `{t}` (already nested under it)"
127 ),
128 );
129 continue;
130 }
131 depends_links.push(Link {
132 from: from.clone(),
133 to,
134 kind: LinkKind::DependsOn,
135 });
136 }
137 }
138
139 let mut links = norm.child_links;
140 links.extend(depends_links);
141 let links = dedupe_links(links, &mut norm.report);
142
143 detect_cycles(&norm.nodes, &links, &mut norm.report);
144
145 for claim in &claims {
147 for (i, dep) in claim.deps.iter().enumerate() {
148 if !norm.claim_ids.contains(dep) {
149 norm.report.error(
150 format!("claims[{}].dependencies[{i}]", claim.id),
151 format!("dependency references unknown claim `{dep}`"),
152 );
153 }
154 }
155 }
156
157 let manifest = Manifest {
158 nodes: norm.nodes,
159 links,
160 bindings: norm.bindings,
161 claims,
162 bounds: None,
163 paper: None,
164 related_work: Vec::new(),
165 concepts: Vec::new(),
166 problem: None,
167 recipes: Vec::new(),
168 exhibits: Vec::new(),
169 built_on: Vec::new(),
170 node_exhibits: Vec::new(),
171 };
172
173 if norm.report.is_ok() {
174 Ok((manifest, norm.report))
175 } else {
176 Err(norm.report)
177 }
178}
179
180#[cfg(feature = "native")]
187pub fn parse_dir(dir: &std::path::Path) -> Result<(Manifest, ParseReport), ParseReport> {
188 let tree_path = dir.join("trace/exploration_tree.yaml");
189 let tree_yaml = match std::fs::read_to_string(&tree_path) {
190 Ok(s) => s,
191 Err(e) => {
192 let mut report = ParseReport::default();
193 report.error(
194 "document",
195 format!("cannot read {}: {e}", tree_path.display()),
196 );
197 return Err(report);
198 }
199 };
200 let claims_path = dir.join("logic/claims.md");
202 let claims_md = std::fs::read_to_string(&claims_path).ok();
203
204 let (mut manifest, mut report) = parse_sources(&tree_yaml, claims_md.as_deref())?;
206 read_logic_layer(dir, &mut manifest, &mut report);
207 read_evidence_layer(dir, &mut manifest, &mut report);
208 Ok((manifest, report))
209}
210
211#[cfg(feature = "native")]
216fn read_evidence_layer(dir: &std::path::Path, manifest: &mut Manifest, report: &mut ParseReport) {
217 use crate::evidence::{read_evidence, resolve_built_on, resolve_node_exhibits};
218
219 manifest.exhibits = read_evidence(dir, report);
220 manifest.node_exhibits =
221 resolve_node_exhibits(&manifest.nodes, &manifest.bindings, &manifest.exhibits);
222 manifest.built_on =
223 resolve_built_on(&manifest.nodes, &manifest.bindings, &manifest.related_work);
224}
225
226#[cfg(feature = "native")]
230fn read_logic_layer(dir: &std::path::Path, manifest: &mut Manifest, report: &mut ParseReport) {
231 use crate::paper::parse_paper;
232 use crate::sections::{parse_concepts, parse_problem, parse_related_work};
233
234 if let Some(md) = read_opt(&dir.join("PAPER.md")) {
235 let (paper, warnings) = parse_paper(&md);
236 manifest.paper = paper;
237 for w in warnings {
238 report.warn("PAPER.md", w);
239 }
240 }
241
242 if let Some(md) = read_opt(&dir.join("logic/problem.md")) {
243 manifest.problem = Some(parse_problem(&md));
244 }
245
246 if let Some(md) = read_opt(&dir.join("logic/concepts.md")) {
247 let concepts = parse_concepts(&md);
248 for c in &concepts {
249 if c.definition.is_none() {
250 report.warn(format!("concepts[{}]", c.term), "concept has no definition");
251 }
252 }
253 manifest.concepts = concepts;
254 }
255
256 if let Some(md) = read_opt(&dir.join("logic/related_work.md")) {
257 let related_work = parse_related_work(&md);
258 for r in &related_work {
259 if r.doi.is_none() {
260 report.warn(format!("related_work[{}]", r.id), "related work has no DOI");
261 }
262 }
263 manifest.related_work = related_work;
264 }
265
266 manifest.recipes = read_recipes(&dir.join("logic/solution"));
267}
268
269#[cfg(feature = "native")]
272fn read_opt(path: &std::path::Path) -> Option<String> {
273 std::fs::read_to_string(path).ok()
274}
275
276#[cfg(feature = "native")]
280fn read_recipes(solution_dir: &std::path::Path) -> Vec<crate::manifest::Recipe> {
281 let Ok(entries) = std::fs::read_dir(solution_dir) else {
282 return Vec::new();
283 };
284 let mut paths: Vec<std::path::PathBuf> = entries
285 .flatten()
286 .map(|e| e.path())
287 .filter(|p| p.extension().is_some_and(|ext| ext == "md"))
288 .collect();
289 paths.sort();
290
291 let mut recipes = Vec::new();
292 for path in paths {
293 let Ok(body) = std::fs::read_to_string(&path) else {
294 continue;
295 };
296 let name = path
297 .file_stem()
298 .map(|s| s.to_string_lossy().into_owned())
299 .unwrap_or_default();
300 let title = crate::paper::first_h1(&body);
301 recipes.push(crate::manifest::Recipe { name, title, body });
302 }
303 recipes
304}
305
306struct Normalizer {
308 report: ParseReport,
309 claims_present: bool,
310 claim_ids: BTreeSet<ClaimId>,
311 nodes: Vec<Node>,
312 node_ids: BTreeSet<NodeId>,
313 bindings: Vec<Binding>,
314 child_links: Vec<Link>,
315 also: Vec<(NodeId, Vec<String>)>,
317}
318
319impl Normalizer {
320 fn dfs(&mut self, raw: &RawNode, parent: Option<&NodeId>) {
324 let id_str = raw.id.as_deref().map(str::trim).filter(|s| !s.is_empty());
325 let Some(id_str) = id_str else {
326 let label = raw.title.as_deref().unwrap_or("<no id>");
327 self.report
328 .error(format!("nodes[{label}]"), "node is missing an `id`");
329 return;
330 };
331 let id = NodeId::new(id_str);
332 if self.node_ids.contains(&id) {
333 self.report
334 .error(format!("nodes[{id}]"), "duplicate node id");
335 return;
336 }
337 self.node_ids.insert(id.clone());
338
339 if let Some(parent) = parent {
340 self.child_links.push(Link {
341 from: parent.clone(),
342 to: id.clone(),
343 kind: LinkKind::Child,
344 });
345 }
346
347 let (kind, fields) = self.project_kind(raw, &id);
348 let evidence_notes = self.split_evidence(raw, &id);
349
350 for key in raw.extra.keys() {
351 self.report
352 .warn(format!("nodes[{id}]"), format!("unknown field `{key}`"));
353 }
354
355 self.nodes.push(Node {
356 id: id.clone(),
357 kind,
358 label: raw.title.clone(),
359 support_level: raw.support_level.clone(),
360 source_refs: raw.source_refs.clone(),
361 description: raw.description.clone(),
362 fields,
363 evidence_notes,
364 isolated: raw.isolated,
365 pos: None,
366 });
367 self.also.push((id.clone(), raw.also_depends_on.clone()));
368
369 for child in &raw.children {
370 self.dfs(child, Some(&id));
371 }
372 }
373
374 fn project_kind(&mut self, raw: &RawNode, id: &NodeId) -> (NodeKind, NodeFields) {
378 match raw.ty.as_deref().map(str::trim) {
379 Some("question") => (NodeKind::Question, NodeFields::Question),
380 Some("experiment") => (
381 NodeKind::Experiment,
382 NodeFields::Experiment {
383 result: raw.result.clone(),
384 },
385 ),
386 Some("decision") => (
387 NodeKind::Decision,
388 NodeFields::Decision {
389 choice: raw.choice.clone(),
390 alternatives: raw.alternatives.clone(),
391 rationale: raw.rationale.clone(),
392 },
393 ),
394 Some("dead_end") => (
395 NodeKind::DeadEnd,
396 NodeFields::DeadEnd {
397 hypothesis: raw.hypothesis.clone(),
398 failure_mode: raw.failure_mode.clone(),
399 lesson: raw.lesson.clone(),
400 why_failed: raw.why_failed.clone(),
401 },
402 ),
403 Some("insight") => (NodeKind::Insight, NodeFields::Insight),
404 Some("pivot") => (
405 NodeKind::Pivot,
406 NodeFields::Pivot {
407 from: raw.from.clone(),
408 to: raw.to.clone(),
409 trigger: raw.trigger.clone(),
410 },
411 ),
412 Some("") | None => {
413 self.report
414 .warn(format!("nodes[{id}]"), "node is missing a `type`");
415 (NodeKind::Other(String::new()), NodeFields::Other)
416 }
417 Some(other) => {
418 for field in body_field_names(raw) {
419 self.report.warn(
420 format!("nodes[{id}]"),
421 format!("field `{field}` dropped for unknown type `{other}`"),
422 );
423 }
424 (NodeKind::Other(other.to_string()), NodeFields::Other)
425 }
426 }
427 }
428
429 fn split_evidence(&mut self, raw: &RawNode, id: &NodeId) -> Vec<String> {
431 let mut notes = Vec::new();
432 let Some(evidence) = &raw.evidence else {
433 return notes;
434 };
435 for (i, entry) in evidence.entries().iter().enumerate() {
436 let trimmed = entry.trim();
437 if is_canonical_id(trimmed, 'C') {
438 let claim = ClaimId::new(trimmed);
439 let path = format!("nodes[{id}].evidence[{i}]");
440 if !self.claims_present {
441 self.report.warn(
442 path,
443 format!("claim reference `{trimmed}` unresolved (no claims.md provided)"),
444 );
445 } else if self.claim_ids.contains(&claim) {
446 self.bindings.push(Binding {
447 node: id.clone(),
448 claim,
449 role: BindingRole::Evidence,
450 });
451 } else {
452 self.report.error(
453 path,
454 format!("evidence references unknown claim `{trimmed}`"),
455 );
456 }
457 } else {
458 notes.push(entry.clone());
459 }
460 }
461 notes
462 }
463}
464
465fn body_field_names(raw: &RawNode) -> Vec<&'static str> {
468 let mut names = Vec::new();
469 if raw.result.is_some() {
470 names.push("result");
471 }
472 if raw.why_failed.is_some() {
473 names.push("why_failed");
474 }
475 if raw.hypothesis.is_some() {
476 names.push("hypothesis");
477 }
478 if raw.failure_mode.is_some() {
479 names.push("failure_mode");
480 }
481 if raw.lesson.is_some() {
482 names.push("lesson");
483 }
484 if raw.from.is_some() {
485 names.push("from");
486 }
487 if raw.to.is_some() {
488 names.push("to");
489 }
490 if raw.trigger.is_some() {
491 names.push("trigger");
492 }
493 if raw.choice.is_some() {
494 names.push("choice");
495 }
496 if !raw.alternatives.is_empty() {
497 names.push("alternatives");
498 }
499 if raw.rationale.is_some() {
500 names.push("rationale");
501 }
502 names
503}
504
505fn is_ancestor(ancestor: &NodeId, node: &NodeId, parent_of: &BTreeMap<NodeId, NodeId>) -> bool {
509 let mut cur = node;
512 while let Some(parent) = parent_of.get(cur) {
513 if parent == ancestor {
514 return true;
515 }
516 cur = parent;
517 }
518 false
519}
520
521fn dedupe_links(links: Vec<Link>, report: &mut ParseReport) -> Vec<Link> {
524 let mut seen: BTreeSet<(NodeId, NodeId, LinkKind)> = BTreeSet::new();
525 let mut out = Vec::with_capacity(links.len());
526 for link in links {
527 let key = (link.from.clone(), link.to.clone(), link.kind);
528 if seen.contains(&key) {
529 report.warn(
530 format!("nodes[{}]", link.from),
531 format!("duplicate {:?} link to `{}`", link.kind, link.to),
532 );
533 } else {
534 seen.insert(key);
535 out.push(link);
536 }
537 }
538 out
539}
540
541fn detect_cycles(nodes: &[Node], links: &[Link], report: &mut ParseReport) {
544 let mut adj: BTreeMap<&NodeId, Vec<&NodeId>> = BTreeMap::new();
547 for link in links {
548 adj.entry(&link.from).or_default().push(&link.to);
549 }
550 let mut color: BTreeMap<&NodeId, u8> = BTreeMap::new(); for node in nodes {
552 if color.get(&node.id).copied().unwrap_or(0) == 0 {
553 visit(&node.id, &adj, &mut color, report);
554 }
555 }
556}
557
558fn visit<'a>(
559 u: &'a NodeId,
560 adj: &BTreeMap<&'a NodeId, Vec<&'a NodeId>>,
561 color: &mut BTreeMap<&'a NodeId, u8>,
562 report: &mut ParseReport,
563) {
564 color.insert(u, 1);
565 if let Some(neighbors) = adj.get(u) {
566 for &v in neighbors {
567 match color.get(v).copied().unwrap_or(0) {
568 0 => visit(v, adj, color, report),
569 1 => report.error(
570 format!("nodes[{u}]"),
571 format!("cycle detected: edge to `{v}` closes a cycle"),
572 ),
573 _ => {}
574 }
575 }
576 }
577 color.insert(u, 2);
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 const MINIMAL: &str = "\
585tree:
586 - id: N01
587 type: question
588 title: Q?
589 children:
590 - id: N02
591 type: experiment
592 result: 28.4 BLEU
593 evidence: [C01, \"Table 2\"]
594";
595 const CLAIMS: &str = "## C01: A claim\n- **Statement**: yes\n";
596
597 #[test]
598 fn resolves_bindings_and_splits_evidence() {
599 let (m, report) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
600 assert!(report.is_ok());
601 assert!(report.warnings().is_empty());
602 assert_eq!(m.nodes.len(), 2);
603 assert_eq!(m.nodes[0].id, NodeId::new("N01")); assert_eq!(m.nodes[1].id, NodeId::new("N02"));
605 assert_eq!(m.links.len(), 1); assert_eq!(m.links[0].kind, LinkKind::Child);
607 assert_eq!(m.bindings.len(), 1); assert_eq!(m.bindings[0].claim, ClaimId::new("C01"));
609 assert_eq!(m.nodes[1].evidence_notes, vec!["Table 2"]);
610 }
611
612 #[test]
613 fn missing_claims_downgrades_binding_to_warning() {
614 let (m, report) = parse_sources(MINIMAL, None).expect("ok");
615 assert!(report.is_ok());
616 assert!(m.bindings.is_empty());
617 assert_eq!(report.warnings().len(), 1);
618 assert!(report.warnings()[0].message.contains("unresolved"));
619 }
620
621 #[test]
622 fn broken_claim_ref_is_error() {
623 let err = parse_sources(MINIMAL, Some("## C99: other\n")).unwrap_err();
624 assert!(!err.is_ok());
625 assert!(err.errors()[0].message.contains("unknown claim"));
626 }
627
628 #[test]
629 fn malformed_yaml_is_error_not_panic() {
630 let err = parse_sources("tree: not-a-list\n", None).unwrap_err();
631 assert_eq!(err.errors()[0].path, "document");
632 }
633
634 #[test]
635 fn both_roots_is_error() {
636 let err = parse_sources("tree: []\nroot:\n id: N01\n", None).unwrap_err();
637 assert!(err.errors()[0].message.contains("both"));
638 }
639
640 #[test]
641 fn neither_root_is_error() {
642 let err = parse_sources("meta: hi\n", None).unwrap_err();
643 assert!(err.errors()[0].message.contains("neither"));
644 }
645
646 #[test]
647 fn empty_tree_warns_and_is_ok() {
648 let (m, report) = parse_sources("tree: []\n", None).expect("ok");
649 assert!(m.nodes.is_empty());
650 assert_eq!(report.warnings().len(), 1);
651 }
652
653 #[test]
654 fn cycle_is_detected() {
655 let yaml = "\
659tree:
660 - id: N01
661 type: question
662 children:
663 - id: N02
664 type: experiment
665 also_depends_on: [N04]
666 - id: N03
667 type: decision
668 children:
669 - id: N04
670 type: insight
671 also_depends_on: [N02]
672";
673 let err = parse_sources(yaml, None).unwrap_err();
674 assert!(err.errors().iter().any(|d| d.message.contains("cycle")));
675 }
676
677 #[test]
678 fn duplicate_node_id_is_error() {
679 let yaml = "\
680tree:
681 - id: N01
682 type: question
683 - id: N01
684 type: insight
685";
686 let err = parse_sources(yaml, None).unwrap_err();
687 assert!(
688 err.errors()
689 .iter()
690 .any(|d| d.message.contains("duplicate node id"))
691 );
692 }
693
694 #[test]
695 fn unknown_type_becomes_other_and_warns() {
696 let yaml = "tree:\n - id: N01\n type: hypothesis\n title: h\n";
697 let (m, _r) = parse_sources(yaml, None).expect("ok");
698 assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
699 }
700
701 #[test]
702 fn root_single_matches_tree_shape() {
703 let tree = "tree:\n - id: N01\n type: question\n title: q\n";
704 let root = "root:\n id: N01\n type: question\n title: q\n";
705 let (mt, _) = parse_sources(tree, None).expect("ok");
706 let (mr, _) = parse_sources(root, None).expect("ok");
707 assert_eq!(mt.nodes, mr.nodes);
708 }
709
710 #[test]
711 fn determinism_parse_twice_identical() {
712 let (a, _) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
713 let (b, _) = parse_sources(MINIMAL, Some(CLAIMS)).expect("ok");
714 assert_eq!(a, b);
715 }
716
717 #[test]
718 fn broken_node_to_node_ref_is_error() {
719 let yaml = "\
720tree:
721 - id: N01
722 type: question
723 children:
724 - id: N02
725 type: experiment
726 also_depends_on: [N99]
727";
728 let err = parse_sources(yaml, None).unwrap_err();
729 assert!(
730 err.errors()
731 .iter()
732 .any(|d| d.message.contains("unknown node") && d.path.contains("also_depends_on")),
733 "expected broken node->node error, got: {err}"
734 );
735 }
736
737 #[test]
738 fn broken_claim_to_claim_dep_is_error() {
739 let claims = "## C01: A\n- **Dependencies**: [C99]\n";
741 let err = parse_sources(MINIMAL, Some(claims)).unwrap_err();
742 assert!(
743 err.errors()
744 .iter()
745 .any(|d| d.message.contains("unknown claim") && d.path.contains("dependencies")),
746 "expected broken claim->claim error, got: {err}"
747 );
748 }
749
750 #[test]
751 fn proof_evidence_refs_emit_no_error() {
752 let claims = "## C01: A\n- **Statement**: s\n- **Proof**: [E01, E02]\n";
754 let (m, report) = parse_sources(MINIMAL, Some(claims)).expect("ok");
755 assert_eq!(m.claims[0].proof, vec!["E01", "E02"]);
756 assert!(report.is_ok());
758 assert!(report.errors().is_empty());
759 }
760
761 #[test]
762 fn sibling_only_depends_on_cycle_is_detected() {
763 let yaml = "\
765tree:
766 - id: N01
767 type: question
768 children:
769 - id: N02
770 type: experiment
771 also_depends_on: [N03]
772 - id: N03
773 type: insight
774 also_depends_on: [N02]
775";
776 let err = parse_sources(yaml, None).unwrap_err();
777 assert!(err.errors().iter().any(|d| d.message.contains("cycle")));
778 }
779
780 #[test]
781 fn redundant_ancestor_depends_on_is_dropped_with_warning() {
782 let yaml = "\
786tree:
787 - id: N01
788 type: question
789 children:
790 - id: N02
791 type: experiment
792 also_depends_on: [N01]
793";
794 let (m, report) = parse_sources(yaml, None).expect("parses ok despite ancestor dep");
795 assert!(report.is_ok(), "must not error: {report}");
796 assert_eq!(m.links.len(), 1);
798 assert_eq!(m.links[0].kind, LinkKind::Child);
799 assert!(
800 report
801 .warnings()
802 .iter()
803 .any(|d| d.message.contains("redundant") && d.message.contains("ancestor")),
804 "expected redundant-ancestor warning, got: {report}"
805 );
806 }
807
808 #[test]
809 fn redundant_grandparent_depends_on_is_dropped() {
810 let yaml = "\
812tree:
813 - id: N01
814 type: question
815 children:
816 - id: N02
817 type: experiment
818 children:
819 - id: N03
820 type: insight
821 also_depends_on: [N01]
822";
823 let (m, report) = parse_sources(yaml, None).expect("ok");
824 assert!(report.is_ok());
825 assert!(m.links.iter().all(|l| l.kind == LinkKind::Child));
827 assert_eq!(m.links.len(), 2);
828 }
829
830 #[test]
831 fn sibling_depends_on_is_kept_not_dropped() {
832 let yaml = "\
835tree:
836 - id: N01
837 type: question
838 children:
839 - id: N02
840 type: experiment
841 - id: N03
842 type: insight
843 also_depends_on: [N02]
844";
845 let (m, report) = parse_sources(yaml, None).expect("ok");
846 assert!(report.is_ok());
847 assert!(
848 m.links.iter().any(|l| l.kind == LinkKind::DependsOn
849 && l.from == NodeId::new("N03")
850 && l.to == NodeId::new("N02")),
851 "sibling cross-edge must be kept"
852 );
853 }
854
855 #[test]
856 fn missing_node_id_is_error() {
857 let err = parse_sources("tree:\n - type: question\n title: q\n", None).unwrap_err();
859 assert!(
860 err.errors()
861 .iter()
862 .any(|d| d.message.contains("missing an `id`")),
863 "expected missing-id error, got: {err}"
864 );
865 }
866
867 #[test]
868 fn duplicate_claim_id_is_error() {
869 let err = parse_sources(MINIMAL, Some("## C01: A\n## C01: B\n")).unwrap_err();
872 assert!(
873 err.errors()
874 .iter()
875 .any(|d| d.path.contains("claims[C01]") && d.message.contains("duplicate claim id")),
876 "expected duplicate-claim-id error, got: {err}"
877 );
878 }
879
880 #[test]
881 fn isolated_field_defaults_false_and_sources_from_raw() {
882 let yaml = "\
885tree:
886 - id: N01
887 type: question
888 children:
889 - id: N02
890 type: experiment
891 isolated: true
892";
893 let (m, _r) = parse_sources(yaml, None).expect("ok");
894 assert!(!m.nodes[0].isolated, "N01 has no isolated key → false");
895 assert!(m.nodes[1].isolated, "N02 carries isolated: true");
896 }
897
898 #[test]
899 fn missing_type_warns() {
900 let (m, report) = parse_sources("tree:\n - id: N01\n title: q\n", None).expect("ok");
903 assert_eq!(m.nodes[0].kind, NodeKind::Other(String::new()));
904 assert!(
905 report
906 .warnings()
907 .iter()
908 .any(|d| d.message.contains("missing a `type`")),
909 "expected missing-type warning, got: {report}"
910 );
911 }
912
913 #[test]
914 fn unknown_type_dropped_body_field_warns() {
915 let yaml = "tree:\n - id: N01\n type: hypothesis\n result: 28.4 BLEU\n";
918 let (m, report) = parse_sources(yaml, None).expect("ok");
919 assert_eq!(m.nodes[0].kind, NodeKind::Other("hypothesis".into()));
920 assert!(
921 report.warnings().iter().any(|d| d
922 .message
923 .contains("`result` dropped for unknown type `hypothesis`")),
924 "expected dropped-field warning, got: {report}"
925 );
926 }
927
928 #[test]
929 fn pivot_projects_kind_and_fields_no_warning() {
930 let yaml = "\
933tree:
934 - id: N01
935 type: pivot
936 from: manual
937 to: automated
938 trigger: infeasible at scale
939";
940 let (m, report) = parse_sources(yaml, None).expect("ok");
941 assert_eq!(m.nodes[0].kind, NodeKind::Pivot);
942 assert_eq!(
943 m.nodes[0].fields,
944 NodeFields::Pivot {
945 from: Some("manual".to_string()),
946 to: Some("automated".to_string()),
947 trigger: Some("infeasible at scale".to_string()),
948 }
949 );
950 assert!(
951 report.warnings().is_empty(),
952 "pivot fields must not warn, got: {report}"
953 );
954 }
955
956 #[test]
957 fn dead_end_widened_fields_no_warning() {
958 let yaml = "\
961tree:
962 - id: N01
963 type: dead_end
964 hypothesis: h
965 failure_mode: fm
966 lesson: l
967 why_failed: wf
968";
969 let (m, report) = parse_sources(yaml, None).expect("ok");
970 assert_eq!(m.nodes[0].kind, NodeKind::DeadEnd);
971 assert_eq!(
972 m.nodes[0].fields,
973 NodeFields::DeadEnd {
974 hypothesis: Some("h".to_string()),
975 failure_mode: Some("fm".to_string()),
976 lesson: Some("l".to_string()),
977 why_failed: Some("wf".to_string()),
978 }
979 );
980 assert!(
981 report.warnings().is_empty(),
982 "dead_end fields must not warn, got: {report}"
983 );
984 }
985
986 #[test]
987 fn duplicate_link_warns() {
988 let yaml = "\
992tree:
993 - id: N01
994 type: question
995 children:
996 - id: N02
997 type: experiment
998 also_depends_on: [N03, N03]
999 - id: N03
1000 type: insight
1001";
1002 let (_m, report) = parse_sources(yaml, None).expect("ok");
1003 assert!(
1004 report
1005 .warnings()
1006 .iter()
1007 .any(|d| d.message.contains("duplicate") && d.message.contains("link to `N03`")),
1008 "expected duplicate-link warning, got: {report}"
1009 );
1010 }
1011}