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