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