weave_content/
timeline.rs1use std::collections::HashSet;
2
3use crate::parser::ParseError;
4use crate::relationship::Rel;
5
6const MAX_EDGES: usize = 200;
8
9pub 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 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}