weave_content/
timeline.rs1use std::collections::HashSet;
2
3use crate::parser::ParseError;
4use crate::relationship::Rel;
5
6const MAX_CHAINS: usize = 10;
8
9const MAX_EVENTS_PER_CHAIN: usize = 20;
11
12#[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 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 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); }
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}