Skip to main content

weave_content/
timeline.rs

1use std::collections::HashSet;
2
3use crate::parser::ParseError;
4use crate::relationship::Rel;
5
6/// Maximum timeline edges per file.
7const MAX_EDGES: usize = 200;
8
9/// Parse the `## Timeline` section body into `preceded_by` relationships.
10///
11/// Format:
12/// ```text
13/// - Event A -> Event B
14///   id: 01ABC...
15/// - Event B -> Event C
16///   id: 01DEF...
17/// ```
18///
19/// Each bullet is a single edge. The `id:` line is optional on first build
20/// (will be generated and written back).
21#[allow(clippy::implicit_hasher)]
22pub fn parse_timeline(
23    body: &str,
24    section_start_line: usize,
25    event_names: &HashSet<&str>,
26    errors: &mut Vec<ParseError>,
27) -> Vec<Rel> {
28    let lines: Vec<&str> = body.lines().collect();
29    let mut rels = Vec::new();
30
31    let mut i = 0;
32    while i < lines.len() {
33        let file_line = section_start_line + 1 + i;
34        let trimmed = lines[i].trim();
35
36        if trimmed.is_empty() {
37            i += 1;
38            continue;
39        }
40
41        let Some(bullet_body) = trimmed.strip_prefix("- ") else {
42            errors.push(ParseError {
43                line: file_line,
44                message: format!("expected timeline bullet `- A -> B`, got {trimmed:?}"),
45            });
46            i += 1;
47            continue;
48        };
49
50        let parts: Vec<&str> = bullet_body.split(" -> ").map(str::trim).collect();
51        if parts.len() != 2 {
52            errors.push(ParseError {
53                line: file_line,
54                message: format!(
55                    "timeline bullet must have exactly 2 events: `- A -> B` (got {bullet_body:?})"
56                ),
57            });
58            i += 1;
59            continue;
60        }
61
62        let source = parts[0];
63        let target = parts[1];
64
65        for event in [source, target] {
66            if !event_names.contains(event) {
67                errors.push(ParseError {
68                    line: file_line,
69                    message: format!("timeline entity {event:?} not found in Events section"),
70                });
71            }
72        }
73
74        // Look ahead for `id:` on the next line
75        let mut id: Option<String> = None;
76        if i + 1 < lines.len() {
77            let next = lines[i + 1].trim();
78            if let Some(id_val) = next.strip_prefix("id: ") {
79                id = Some(id_val.trim().to_string());
80                i += 1;
81            }
82        }
83
84        rels.push(Rel {
85            source_name: source.to_string(),
86            target_name: target.to_string(),
87            rel_type: "preceded_by".to_string(),
88            source_urls: Vec::new(),
89            fields: vec![],
90            id,
91            line: file_line,
92        });
93
94        i += 1;
95    }
96
97    if rels.len() > MAX_EDGES {
98        errors.push(ParseError {
99            line: section_start_line,
100            message: format!(
101                "too many timeline edges (max {MAX_EDGES}, got {})",
102                rels.len()
103            ),
104        });
105    }
106
107    rels
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn parse_basic() {
116        let body = "\n- Event A -> Event B\n  id: 01JABC000000000000000000AA\n- Event B -> Event C\n  id: 01JDEF000000000000000000BB\n";
117        let names = HashSet::from(["Event A", "Event B", "Event C"]);
118        let mut errors = Vec::new();
119
120        let rels = parse_timeline(body, 80, &names, &mut errors);
121        assert!(errors.is_empty(), "errors: {errors:?}");
122        assert_eq!(rels.len(), 2);
123        assert_eq!(rels[0].source_name, "Event A");
124        assert_eq!(rels[0].target_name, "Event B");
125        assert_eq!(rels[0].rel_type, "preceded_by");
126        assert!(rels[0].source_urls.is_empty());
127        assert_eq!(rels[0].id.as_deref(), Some("01JABC000000000000000000AA"));
128        assert_eq!(rels[1].source_name, "Event B");
129        assert_eq!(rels[1].target_name, "Event C");
130        assert_eq!(rels[1].id.as_deref(), Some("01JDEF000000000000000000BB"));
131    }
132
133    #[test]
134    fn parse_without_ids() {
135        let body = "\n- Event A -> Event B\n- Event B -> Event C\n";
136        let names = HashSet::from(["Event A", "Event B", "Event C"]);
137        let mut errors = Vec::new();
138
139        let rels = parse_timeline(body, 80, &names, &mut errors);
140        assert!(errors.is_empty(), "errors: {errors:?}");
141        assert_eq!(rels.len(), 2);
142        assert!(rels[0].id.is_none());
143        assert!(rels[1].id.is_none());
144    }
145
146    #[test]
147    fn parse_mixed_ids() {
148        let body = "\n- A -> B\n  id: 01JABC000000000000000000AA\n- B -> C\n";
149        let names = HashSet::from(["A", "B", "C"]);
150        let mut errors = Vec::new();
151
152        let rels = parse_timeline(body, 1, &names, &mut errors);
153        assert!(errors.is_empty(), "errors: {errors:?}");
154        assert_eq!(rels.len(), 2);
155        assert_eq!(rels[0].id.as_deref(), Some("01JABC000000000000000000AA"));
156        assert!(rels[1].id.is_none());
157    }
158
159    #[test]
160    fn reject_unknown_event() {
161        let body = "\n- Known -> Unknown\n";
162        let names = HashSet::from(["Known"]);
163        let mut errors = Vec::new();
164
165        parse_timeline(body, 1, &names, &mut errors);
166        assert!(
167            errors
168                .iter()
169                .any(|e| e.message.contains("not found in Events"))
170        );
171    }
172
173    #[test]
174    fn reject_invalid_syntax() {
175        let body = "\n- Just One Event\n";
176        let names = HashSet::from(["Just One Event"]);
177        let mut errors = Vec::new();
178
179        parse_timeline(body, 1, &names, &mut errors);
180        assert!(
181            errors
182                .iter()
183                .any(|e| e.message.contains("exactly 2 events"))
184        );
185    }
186
187    #[test]
188    fn empty_timeline() {
189        let body = "\n\n\n";
190        let mut errors = Vec::new();
191
192        let rels = parse_timeline(body, 1, &HashSet::new(), &mut errors);
193        assert!(errors.is_empty());
194        assert!(rels.is_empty());
195    }
196}