1use std::path::Path;
2
3#[derive(Debug)]
5pub struct PendingId {
6 pub line: usize,
8 pub id: String,
10 pub kind: WriteBackKind,
12}
13
14#[derive(Debug)]
16pub enum WriteBackKind {
17 EntityFrontMatter,
20 CaseId,
23 InlineEvent,
26 Relationship,
29 RelatedCase,
32 InvolvedIn { entity_name: String },
36 TimelineEdge,
39}
40
41pub fn apply_writebacks(content: &str, pending: &mut [PendingId]) -> Option<String> {
48 if pending.is_empty() {
49 return None;
50 }
51
52 let mut new_involved: Vec<(String, String)> = Vec::new(); let mut normal: Vec<&PendingId> = Vec::new();
56
57 for p in pending.iter() {
58 if let WriteBackKind::InvolvedIn { ref entity_name } = p.kind
59 && p.line == 0 {
60 new_involved.push((entity_name.clone(), p.id.clone()));
61 continue;
62 }
63 normal.push(p);
64 }
65
66 normal.sort_by_key(|p| std::cmp::Reverse(p.line));
68
69 let mut lines: Vec<String> = content.lines().map(String::from).collect();
70 let trailing_newline = content.ends_with('\n');
72
73 for p in &normal {
74 let insert_text = match p.kind {
75 WriteBackKind::EntityFrontMatter | WriteBackKind::CaseId => {
76 format!("id: {}", p.id)
77 }
78 WriteBackKind::InlineEvent => {
79 format!("- id: {}", p.id)
80 }
81 WriteBackKind::Relationship => {
82 format!(" - id: {}", p.id)
83 }
84 WriteBackKind::RelatedCase | WriteBackKind::InvolvedIn { .. } | WriteBackKind::TimelineEdge => {
85 format!(" id: {}", p.id)
86 }
87 };
88
89 let insert_after = match p.kind {
90 WriteBackKind::EntityFrontMatter | WriteBackKind::CaseId => {
91 let idx = p.line.saturating_sub(1); if idx <= lines.len() {
96 lines.insert(idx, insert_text);
97 }
98 continue;
99 }
100 WriteBackKind::InlineEvent
101 | WriteBackKind::Relationship
102 | WriteBackKind::RelatedCase
103 | WriteBackKind::InvolvedIn { .. }
104 | WriteBackKind::TimelineEdge => {
105 p.line }
108 };
109
110 if insert_after <= lines.len() {
111 lines.insert(insert_after, insert_text);
112 }
113 }
114
115 if !new_involved.is_empty() {
117 let insert_idx = find_section_insert_point(&lines);
119
120 let mut section_lines = Vec::new();
121 if insert_idx > 0 && !lines[insert_idx - 1].is_empty() {
123 section_lines.push(String::new());
124 }
125 section_lines.push("## Involved".to_string());
126 section_lines.push(String::new());
127 for (name, id) in &new_involved {
128 section_lines.push(format!("- {name}"));
129 section_lines.push(format!(" id: {id}"));
130 }
131
132 for (offset, line) in section_lines.into_iter().enumerate() {
134 lines.insert(insert_idx + offset, line);
135 }
136 }
137
138 let mut result = lines.join("\n");
139 if trailing_newline {
140 result.push('\n');
141 }
142 Some(result)
143}
144
145fn find_section_insert_point(lines: &[String]) -> usize {
149 for (i, line) in lines.iter().enumerate() {
150 let trimmed = line.trim();
151 if trimmed == "## Timeline" || trimmed == "## Related Cases" {
152 return i;
153 }
154 }
155 lines.len()
156}
157
158pub fn write_file(path: &Path, content: &str) -> Result<(), String> {
160 std::fs::write(path, content)
161 .map_err(|e| format!("{}: error writing file: {e}", path.display()))
162}
163
164pub fn find_front_matter_end(content: &str) -> Option<usize> {
168 let mut in_front_matter = false;
169 for (i, line) in content.lines().enumerate() {
170 let trimmed = line.trim();
171 if trimmed == "---" && !in_front_matter {
172 in_front_matter = true;
173 } else if trimmed == "---" && in_front_matter {
174 return Some(i + 1); }
176 }
177 None
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn writeback_entity_front_matter() {
186 let content = "---\n---\n\n# Mark Bonnick\n\n- nationality: British\n";
187 let end_line = find_front_matter_end(content).unwrap();
188 assert_eq!(end_line, 2);
189
190 let mut pending = vec![PendingId {
191 line: end_line,
192 id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
193 kind: WriteBackKind::EntityFrontMatter,
194 }];
195
196 let result = apply_writebacks(content, &mut pending).unwrap();
197 assert!(result.contains("id: 01JXYZ123456789ABCDEFGHIJK"));
198 let lines: Vec<&str> = result.lines().collect();
200 assert_eq!(lines[0], "---");
201 assert_eq!(lines[1], "id: 01JXYZ123456789ABCDEFGHIJK");
202 assert_eq!(lines[2], "---");
203 }
204
205 #[test]
206 fn writeback_entity_front_matter_with_existing_fields() {
207 let content = "---\nother: value\n---\n\n# Test\n";
208 let end_line = find_front_matter_end(content).unwrap();
209 assert_eq!(end_line, 3);
210
211 let mut pending = vec![PendingId {
212 line: end_line,
213 id: "01JABC000000000000000000AA".to_string(),
214 kind: WriteBackKind::EntityFrontMatter,
215 }];
216
217 let result = apply_writebacks(content, &mut pending).unwrap();
218 let lines: Vec<&str> = result.lines().collect();
219 assert_eq!(lines[0], "---");
220 assert_eq!(lines[1], "other: value");
221 assert_eq!(lines[2], "id: 01JABC000000000000000000AA");
222 assert_eq!(lines[3], "---");
223 }
224
225 #[test]
226 fn writeback_inline_event() {
227 let content = "\
228## Events
229
230### Dismissal
231- occurred_at: 2024-12-24
232- event_type: termination
233";
234 let mut pending = vec![PendingId {
236 line: 3,
237 id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
238 kind: WriteBackKind::InlineEvent,
239 }];
240
241 let result = apply_writebacks(content, &mut pending).unwrap();
242 let lines: Vec<&str> = result.lines().collect();
243 assert_eq!(lines[2], "### Dismissal");
244 assert_eq!(lines[3], "- id: 01JXYZ123456789ABCDEFGHIJK");
245 assert_eq!(lines[4], "- occurred_at: 2024-12-24");
246 }
247
248 #[test]
249 fn writeback_relationship() {
250 let content = "\
251## Relationships
252
253- Alice -> Bob: employed_by
254 - source: https://example.com
255";
256 let mut pending = vec![PendingId {
258 line: 3,
259 id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
260 kind: WriteBackKind::Relationship,
261 }];
262
263 let result = apply_writebacks(content, &mut pending).unwrap();
264 let lines: Vec<&str> = result.lines().collect();
265 assert_eq!(lines[2], "- Alice -> Bob: employed_by");
266 assert_eq!(lines[3], " - id: 01JXYZ123456789ABCDEFGHIJK");
267 assert_eq!(lines[4], " - source: https://example.com");
268 }
269
270 #[test]
271 fn writeback_multiple_insertions() {
272 let content = "\
273## Events
274
275### Event A
276- occurred_at: 2024-01-01
277
278### Event B
279- occurred_at: 2024-06-01
280
281## Relationships
282
283- Event A -> Event B: associate_of
284";
285 let mut pending = vec![
286 PendingId {
287 line: 3,
288 id: "01JAAA000000000000000000AA".to_string(),
289 kind: WriteBackKind::InlineEvent,
290 },
291 PendingId {
292 line: 6,
293 id: "01JBBB000000000000000000BB".to_string(),
294 kind: WriteBackKind::InlineEvent,
295 },
296 PendingId {
297 line: 10,
298 id: "01JCCC000000000000000000CC".to_string(),
299 kind: WriteBackKind::Relationship,
300 },
301 ];
302
303 let result = apply_writebacks(content, &mut pending).unwrap();
304 assert!(result.contains("- id: 01JAAA000000000000000000AA"));
305 assert!(result.contains("- id: 01JBBB000000000000000000BB"));
306 assert!(result.contains(" - id: 01JCCC000000000000000000CC"));
307 }
308
309 #[test]
310 fn writeback_empty_pending() {
311 let content = "some content\n";
312 let mut pending = Vec::new();
313 assert!(apply_writebacks(content, &mut pending).is_none());
314 }
315
316 #[test]
317 fn writeback_preserves_trailing_newline() {
318 let content = "---\n---\n\n# Test\n";
319 let mut pending = vec![PendingId {
320 line: 2,
321 id: "01JABC000000000000000000AA".to_string(),
322 kind: WriteBackKind::EntityFrontMatter,
323 }];
324 let result = apply_writebacks(content, &mut pending).unwrap();
325 assert!(result.ends_with('\n'));
326 }
327
328 #[test]
329 fn writeback_case_id() {
330 let content = "---\nsources:\n - https://example.com\n---\n\n# Some Case\n";
331 let end_line = find_front_matter_end(content).unwrap();
332 assert_eq!(end_line, 4);
333
334 let mut pending = vec![PendingId {
335 line: end_line,
336 id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
337 kind: WriteBackKind::CaseId,
338 }];
339
340 let result = apply_writebacks(content, &mut pending).unwrap();
341 let lines: Vec<&str> = result.lines().collect();
342 assert_eq!(lines[0], "---");
343 assert_eq!(lines[3], "id: 01JXYZ123456789ABCDEFGHIJK");
344 assert_eq!(lines[4], "---");
345 assert!(!result.contains("\nnulid:"));
346 }
347
348 #[test]
349 fn find_front_matter_end_basic() {
350 assert_eq!(find_front_matter_end("---\nid: foo\n---\n"), Some(3));
351 assert_eq!(find_front_matter_end("---\n---\n"), Some(2));
352 assert_eq!(find_front_matter_end("no front matter"), None);
353 assert_eq!(find_front_matter_end("---\nunclosed"), None);
354 }
355
356 #[test]
357 fn writeback_related_case() {
358 let content = "\
359## Related Cases
360
361- id/corruption/2013/some-case
362 description: Related scandal
363";
364 let mut pending = vec![PendingId {
366 line: 3,
367 id: "01JREL000000000000000000AA".to_string(),
368 kind: WriteBackKind::RelatedCase,
369 }];
370
371 let result = apply_writebacks(content, &mut pending).unwrap();
372 let lines: Vec<&str> = result.lines().collect();
373 assert_eq!(lines[2], "- id/corruption/2013/some-case");
374 assert_eq!(lines[3], " id: 01JREL000000000000000000AA");
375 assert_eq!(lines[4], " description: Related scandal");
376 }
377
378 #[test]
379 fn writeback_involved_in_existing_section() {
380 let content = "\
381## Involved
382
383- John Doe
384";
385 let mut pending = vec![PendingId {
387 line: 3,
388 id: "01JINV000000000000000000AA".to_string(),
389 kind: WriteBackKind::InvolvedIn {
390 entity_name: "John Doe".to_string(),
391 },
392 }];
393
394 let result = apply_writebacks(content, &mut pending).unwrap();
395 let lines: Vec<&str> = result.lines().collect();
396 assert_eq!(lines[2], "- John Doe");
397 assert_eq!(lines[3], " id: 01JINV000000000000000000AA");
398 }
399
400 #[test]
401 fn writeback_involved_in_new_section() {
402 let content = "\
403---
404id: 01CASE000000000000000000AA
405sources:
406 - https://example.com
407---
408
409# Some Case
410
411Summary text.
412
413## Events
414
415### Something
416- occurred_at: 2024-01-01
417
418## Timeline
419
420- Something -> Other thing
421";
422 let mut pending = vec![PendingId {
423 line: 0,
424 id: "01JINV000000000000000000BB".to_string(),
425 kind: WriteBackKind::InvolvedIn {
426 entity_name: "Alice".to_string(),
427 },
428 }];
429
430 let result = apply_writebacks(content, &mut pending).unwrap();
431 assert!(result.contains("## Involved"));
432 assert!(result.contains("- Alice"));
433 assert!(result.contains(" id: 01JINV000000000000000000BB"));
434
435 let involved_pos = result.find("## Involved").unwrap();
437 let timeline_pos = result.find("## Timeline").unwrap();
438 assert!(involved_pos < timeline_pos);
439 }
440
441 #[test]
442 fn writeback_involved_in_new_section_multiple_entities() {
443 let content = "\
444---
445sources:
446 - https://example.com
447---
448
449# Case
450
451Summary.
452
453## Timeline
454
455- A -> B
456";
457 let mut pending = vec![
458 PendingId {
459 line: 0,
460 id: "01JINV000000000000000000CC".to_string(),
461 kind: WriteBackKind::InvolvedIn {
462 entity_name: "Alice".to_string(),
463 },
464 },
465 PendingId {
466 line: 0,
467 id: "01JINV000000000000000000DD".to_string(),
468 kind: WriteBackKind::InvolvedIn {
469 entity_name: "Bob Corp".to_string(),
470 },
471 },
472 ];
473
474 let result = apply_writebacks(content, &mut pending).unwrap();
475 assert!(result.contains("## Involved"));
476 assert!(result.contains("- Alice"));
477 assert!(result.contains(" id: 01JINV000000000000000000CC"));
478 assert!(result.contains("- Bob Corp"));
479 assert!(result.contains(" id: 01JINV000000000000000000DD"));
480
481 let involved_pos = result.find("## Involved").unwrap();
483 let timeline_pos = result.find("## Timeline").unwrap();
484 assert!(involved_pos < timeline_pos);
485 }
486
487 #[test]
488 fn writeback_timeline_edge() {
489 let content = "\
490## Timeline
491
492- Event A -> Event B
493";
494 let mut pending = vec![PendingId {
496 line: 3,
497 id: "01JTIM000000000000000000AA".to_string(),
498 kind: WriteBackKind::TimelineEdge,
499 }];
500
501 let result = apply_writebacks(content, &mut pending).unwrap();
502 let lines: Vec<&str> = result.lines().collect();
503 assert_eq!(lines[2], "- Event A -> Event B");
504 assert_eq!(lines[3], " id: 01JTIM000000000000000000AA");
505 }
506}