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 chains per file.
7const MAX_CHAINS: usize = 10;
8
9/// Maximum events per chain.
10const MAX_EVENTS_PER_CHAIN: usize = 20;
11
12/// Parse the `## Timeline` section body into `preceded_by` relationships.
13///
14/// Each non-blank line is a chain: `Event A -> Event B -> Event C`.
15/// `event_names` are entity names from the `## Events` section.
16#[allow(clippy::implicit_hasher)]
17pub fn parse_timeline(
18    body: &str,
19    section_start_line: usize,
20    event_names: &HashSet<&str>,
21    errors: &mut Vec<ParseError>,
22) -> Vec<Rel> {
23    let mut chains: Vec<Vec<&str>> = Vec::new();
24    let mut chain_lines: Vec<usize> = Vec::new();
25
26    for (i, line) in body.lines().enumerate() {
27        let file_line = section_start_line + 1 + i;
28        let trimmed = line.trim();
29
30        if trimmed.is_empty() {
31            continue;
32        }
33
34        let events: Vec<&str> = trimmed.split(" -> ").map(str::trim).collect();
35
36        if events.len() < 2 {
37            errors.push(ParseError {
38                line: file_line,
39                message: format!("timeline chain must have at least 2 events (got {trimmed:?})"),
40            });
41            continue;
42        }
43
44        if events.len() > MAX_EVENTS_PER_CHAIN {
45            errors.push(ParseError {
46                line: file_line,
47                message: format!(
48                    "timeline chain exceeds {MAX_EVENTS_PER_CHAIN} events (got {})",
49                    events.len()
50                ),
51            });
52            continue;
53        }
54
55        // Validate all event names exist in Events section
56        for event in &events {
57            if !event_names.contains(event) {
58                errors.push(ParseError {
59                    line: file_line,
60                    message: format!("timeline entity {event:?} not found in Events section"),
61                });
62            }
63        }
64
65        chains.push(events);
66        chain_lines.push(file_line);
67    }
68
69    if chains.len() > MAX_CHAINS {
70        errors.push(ParseError {
71            line: section_start_line,
72            message: format!(
73                "too many timeline chains (max {MAX_CHAINS}, got {})",
74                chains.len()
75            ),
76        });
77    }
78
79    // Generate preceded_by relationships
80    let mut rels = Vec::new();
81    for (chain, &line) in chains.iter().zip(chain_lines.iter()) {
82        for pair in chain.windows(2) {
83            rels.push(Rel {
84                source_name: pair[0].to_string(),
85                target_name: pair[1].to_string(),
86                rel_type: "preceded_by".to_string(),
87                source_urls: Vec::new(),
88                fields: vec![],
89                id: None,
90                line,
91            });
92        }
93    }
94
95    rels
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn parse_single_chain() {
104        let body = "\nEvent A -> Event B -> Event C\n";
105        let names = HashSet::from(["Event A", "Event B", "Event C"]);
106        let mut errors = Vec::new();
107
108        let rels = parse_timeline(body, 80, &names, &mut errors);
109        assert!(errors.is_empty(), "errors: {errors:?}");
110        assert_eq!(rels.len(), 2);
111        assert_eq!(rels[0].source_name, "Event A");
112        assert_eq!(rels[0].target_name, "Event B");
113        assert_eq!(rels[0].rel_type, "preceded_by");
114        assert!(rels[0].source_urls.is_empty());
115        assert_eq!(rels[1].source_name, "Event B");
116        assert_eq!(rels[1].target_name, "Event C");
117    }
118
119    #[test]
120    fn parse_multiple_chains() {
121        let body = ["", "A -> B -> C", "D -> E", ""].join("\n");
122        let names = HashSet::from(["A", "B", "C", "D", "E"]);
123        let mut errors = Vec::new();
124
125        let rels = parse_timeline(&body, 1, &names, &mut errors);
126        assert!(errors.is_empty(), "errors: {errors:?}");
127        assert_eq!(rels.len(), 3); // A->B, B->C, D->E
128    }
129
130    #[test]
131    fn reject_single_event() {
132        let body = "\nJust One Event\n";
133        let mut errors = Vec::new();
134
135        parse_timeline(body, 1, &HashSet::new(), &mut errors);
136        assert!(errors.iter().any(|e| e.message.contains("at least 2")));
137    }
138
139    #[test]
140    fn reject_unknown_event() {
141        let body = "\nKnown -> Unknown\n";
142        let names = HashSet::from(["Known"]);
143        let mut errors = Vec::new();
144
145        parse_timeline(body, 1, &names, &mut errors);
146        assert!(
147            errors
148                .iter()
149                .any(|e| e.message.contains("not found in Events"))
150        );
151    }
152
153    #[test]
154    fn reject_too_many_chains() {
155        let lines: Vec<String> = (0..11).map(|i| format!("E{i}a -> E{i}b")).collect();
156        let body = format!("\n{}\n", lines.join("\n"));
157        let owned: Vec<String> = (0..11)
158            .flat_map(|i| vec![format!("E{i}a"), format!("E{i}b")])
159            .collect();
160        let names: HashSet<&str> = owned.iter().map(String::as_str).collect();
161        let mut errors = Vec::new();
162
163        parse_timeline(&body, 1, &names, &mut errors);
164        assert!(
165            errors
166                .iter()
167                .any(|e| e.message.contains("too many timeline chains"))
168        );
169    }
170
171    #[test]
172    fn reject_too_many_events_per_chain() {
173        let events: Vec<String> = (0..21).map(|i| format!("E{i}")).collect();
174        let body = format!("\n{}\n", events.join(" -> "));
175        let names: HashSet<&str> = events.iter().map(String::as_str).collect();
176        let mut errors = Vec::new();
177
178        parse_timeline(&body, 1, &names, &mut errors);
179        assert!(errors.iter().any(|e| e.message.contains("exceeds 20")));
180    }
181
182    #[test]
183    fn empty_timeline() {
184        let body = "\n\n\n";
185        let mut errors = Vec::new();
186
187        let rels = parse_timeline(body, 1, &HashSet::new(), &mut errors);
188        assert!(errors.is_empty());
189        assert!(rels.is_empty());
190    }
191}