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 {
61 new_involved.push((entity_name.clone(), p.id.clone()));
62 continue;
63 }
64 normal.push(p);
65 }
66
67 normal.sort_by_key(|p| std::cmp::Reverse(p.line));
69
70 let mut lines: Vec<String> = content.lines().map(String::from).collect();
71 let trailing_newline = content.ends_with('\n');
73
74 for p in &normal {
75 let insert_text = match p.kind {
76 WriteBackKind::EntityFrontMatter | WriteBackKind::CaseId => {
77 format!("id: {}", p.id)
78 }
79 WriteBackKind::InlineEvent => {
80 format!("- id: {}", p.id)
81 }
82 WriteBackKind::Relationship => {
83 format!(" - id: {}", p.id)
84 }
85 WriteBackKind::RelatedCase
86 | WriteBackKind::InvolvedIn { .. }
87 | WriteBackKind::TimelineEdge => {
88 format!(" id: {}", p.id)
89 }
90 };
91
92 let insert_after = match p.kind {
93 WriteBackKind::EntityFrontMatter | WriteBackKind::CaseId => {
94 let fm_end_idx = p.line.saturating_sub(1); let mut replaced = false;
98 for i in 0..fm_end_idx.min(lines.len()) {
99 let trimmed = lines[i].trim();
100 if trimmed == "id:" || trimmed == "id: " {
101 lines[i] = insert_text.clone();
102 replaced = true;
103 break;
104 }
105 }
106 if !replaced && fm_end_idx <= lines.len() {
107 lines.insert(fm_end_idx, insert_text);
108 }
109 continue;
110 }
111 WriteBackKind::InlineEvent
112 | WriteBackKind::Relationship
113 | WriteBackKind::RelatedCase
114 | WriteBackKind::InvolvedIn { .. }
115 | WriteBackKind::TimelineEdge => {
116 p.line }
119 };
120
121 if insert_after <= lines.len() {
122 lines.insert(insert_after, insert_text);
123 }
124 }
125
126 if !new_involved.is_empty() {
128 let insert_idx = find_section_insert_point(&lines);
130
131 let mut section_lines = Vec::new();
132 if insert_idx > 0 && !lines[insert_idx - 1].is_empty() {
134 section_lines.push(String::new());
135 }
136 section_lines.push("## Involved".to_string());
137 section_lines.push(String::new());
138 for (name, id) in &new_involved {
139 section_lines.push(format!("- {name}"));
140 section_lines.push(format!(" id: {id}"));
141 }
142
143 for (offset, line) in section_lines.into_iter().enumerate() {
145 lines.insert(insert_idx + offset, line);
146 }
147 }
148
149 let mut result = lines.join("\n");
150 if trailing_newline {
151 result.push('\n');
152 }
153 Some(result)
154}
155
156fn find_section_insert_point(lines: &[String]) -> usize {
160 for (i, line) in lines.iter().enumerate() {
161 let trimmed = line.trim();
162 if trimmed == "## Timeline" || trimmed == "## Related Cases" {
163 return i;
164 }
165 }
166 lines.len()
167}
168
169pub fn write_file(path: &Path, content: &str) -> Result<(), String> {
171 std::fs::write(path, content)
172 .map_err(|e| format!("{}: error writing file: {e}", path.display()))
173}
174
175pub fn find_front_matter_end(content: &str) -> Option<usize> {
179 let mut in_front_matter = false;
180 for (i, line) in content.lines().enumerate() {
181 let trimmed = line.trim();
182 if trimmed == "---" && !in_front_matter {
183 in_front_matter = true;
184 } else if trimmed == "---" && in_front_matter {
185 return Some(i + 1); }
187 }
188 None
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn writeback_entity_front_matter() {
197 let content = "---\n---\n\n# Mark Bonnick\n\n- nationality: British\n";
198 let end_line = find_front_matter_end(content).unwrap();
199 assert_eq!(end_line, 2);
200
201 let mut pending = vec![PendingId {
202 line: end_line,
203 id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
204 kind: WriteBackKind::EntityFrontMatter,
205 }];
206
207 let result = apply_writebacks(content, &mut pending).unwrap();
208 assert!(result.contains("id: 01JXYZ123456789ABCDEFGHIJK"));
209 let lines: Vec<&str> = result.lines().collect();
211 assert_eq!(lines[0], "---");
212 assert_eq!(lines[1], "id: 01JXYZ123456789ABCDEFGHIJK");
213 assert_eq!(lines[2], "---");
214 }
215
216 #[test]
217 fn writeback_entity_front_matter_with_existing_fields() {
218 let content = "---\nother: value\n---\n\n# Test\n";
219 let end_line = find_front_matter_end(content).unwrap();
220 assert_eq!(end_line, 3);
221
222 let mut pending = vec![PendingId {
223 line: end_line,
224 id: "01JABC000000000000000000AA".to_string(),
225 kind: WriteBackKind::EntityFrontMatter,
226 }];
227
228 let result = apply_writebacks(content, &mut pending).unwrap();
229 let lines: Vec<&str> = result.lines().collect();
230 assert_eq!(lines[0], "---");
231 assert_eq!(lines[1], "other: value");
232 assert_eq!(lines[2], "id: 01JABC000000000000000000AA");
233 assert_eq!(lines[3], "---");
234 }
235
236 #[test]
237 fn writeback_inline_event() {
238 let content = "\
239## Events
240
241### Dismissal
242- occurred_at: 2024-12-24
243- event_type: termination
244";
245 let mut pending = vec![PendingId {
247 line: 3,
248 id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
249 kind: WriteBackKind::InlineEvent,
250 }];
251
252 let result = apply_writebacks(content, &mut pending).unwrap();
253 let lines: Vec<&str> = result.lines().collect();
254 assert_eq!(lines[2], "### Dismissal");
255 assert_eq!(lines[3], "- id: 01JXYZ123456789ABCDEFGHIJK");
256 assert_eq!(lines[4], "- occurred_at: 2024-12-24");
257 }
258
259 #[test]
260 fn writeback_relationship() {
261 let content = "\
262## Relationships
263
264- Alice -> Bob: employed_by
265 - source: https://example.com
266";
267 let mut pending = vec![PendingId {
269 line: 3,
270 id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
271 kind: WriteBackKind::Relationship,
272 }];
273
274 let result = apply_writebacks(content, &mut pending).unwrap();
275 let lines: Vec<&str> = result.lines().collect();
276 assert_eq!(lines[2], "- Alice -> Bob: employed_by");
277 assert_eq!(lines[3], " - id: 01JXYZ123456789ABCDEFGHIJK");
278 assert_eq!(lines[4], " - source: https://example.com");
279 }
280
281 #[test]
282 fn writeback_multiple_insertions() {
283 let content = "\
284## Events
285
286### Event A
287- occurred_at: 2024-01-01
288
289### Event B
290- occurred_at: 2024-06-01
291
292## Relationships
293
294- Event A -> Event B: associate_of
295";
296 let mut pending = vec![
297 PendingId {
298 line: 3,
299 id: "01JAAA000000000000000000AA".to_string(),
300 kind: WriteBackKind::InlineEvent,
301 },
302 PendingId {
303 line: 6,
304 id: "01JBBB000000000000000000BB".to_string(),
305 kind: WriteBackKind::InlineEvent,
306 },
307 PendingId {
308 line: 10,
309 id: "01JCCC000000000000000000CC".to_string(),
310 kind: WriteBackKind::Relationship,
311 },
312 ];
313
314 let result = apply_writebacks(content, &mut pending).unwrap();
315 assert!(result.contains("- id: 01JAAA000000000000000000AA"));
316 assert!(result.contains("- id: 01JBBB000000000000000000BB"));
317 assert!(result.contains(" - id: 01JCCC000000000000000000CC"));
318 }
319
320 #[test]
321 fn writeback_empty_pending() {
322 let content = "some content\n";
323 let mut pending = Vec::new();
324 assert!(apply_writebacks(content, &mut pending).is_none());
325 }
326
327 #[test]
328 fn writeback_preserves_trailing_newline() {
329 let content = "---\n---\n\n# Test\n";
330 let mut pending = vec![PendingId {
331 line: 2,
332 id: "01JABC000000000000000000AA".to_string(),
333 kind: WriteBackKind::EntityFrontMatter,
334 }];
335 let result = apply_writebacks(content, &mut pending).unwrap();
336 assert!(result.ends_with('\n'));
337 }
338
339 #[test]
340 fn writeback_case_id() {
341 let content = "---\nsources:\n - https://example.com\n---\n\n# Some Case\n";
342 let end_line = find_front_matter_end(content).unwrap();
343 assert_eq!(end_line, 4);
344
345 let mut pending = vec![PendingId {
346 line: end_line,
347 id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
348 kind: WriteBackKind::CaseId,
349 }];
350
351 let result = apply_writebacks(content, &mut pending).unwrap();
352 let lines: Vec<&str> = result.lines().collect();
353 assert_eq!(lines[0], "---");
354 assert_eq!(lines[3], "id: 01JXYZ123456789ABCDEFGHIJK");
355 assert_eq!(lines[4], "---");
356 assert!(!result.contains("\nnulid:"));
357 }
358
359 #[test]
360 fn find_front_matter_end_basic() {
361 assert_eq!(find_front_matter_end("---\nid: foo\n---\n"), Some(3));
362 assert_eq!(find_front_matter_end("---\n---\n"), Some(2));
363 assert_eq!(find_front_matter_end("no front matter"), None);
364 assert_eq!(find_front_matter_end("---\nunclosed"), None);
365 }
366
367 #[test]
368 fn writeback_related_case() {
369 let content = "\
370## Related Cases
371
372- id/corruption/2013/some-case
373 description: Related scandal
374";
375 let mut pending = vec![PendingId {
377 line: 3,
378 id: "01JREL000000000000000000AA".to_string(),
379 kind: WriteBackKind::RelatedCase,
380 }];
381
382 let result = apply_writebacks(content, &mut pending).unwrap();
383 let lines: Vec<&str> = result.lines().collect();
384 assert_eq!(lines[2], "- id/corruption/2013/some-case");
385 assert_eq!(lines[3], " id: 01JREL000000000000000000AA");
386 assert_eq!(lines[4], " description: Related scandal");
387 }
388
389 #[test]
390 fn writeback_involved_in_existing_section() {
391 let content = "\
392## Involved
393
394- John Doe
395";
396 let mut pending = vec![PendingId {
398 line: 3,
399 id: "01JINV000000000000000000AA".to_string(),
400 kind: WriteBackKind::InvolvedIn {
401 entity_name: "John Doe".to_string(),
402 },
403 }];
404
405 let result = apply_writebacks(content, &mut pending).unwrap();
406 let lines: Vec<&str> = result.lines().collect();
407 assert_eq!(lines[2], "- John Doe");
408 assert_eq!(lines[3], " id: 01JINV000000000000000000AA");
409 }
410
411 #[test]
412 fn writeback_involved_in_new_section() {
413 let content = "\
414---
415id: 01CASE000000000000000000AA
416sources:
417 - https://example.com
418---
419
420# Some Case
421
422Summary text.
423
424## Events
425
426### Something
427- occurred_at: 2024-01-01
428
429## Timeline
430
431- Something -> Other thing
432";
433 let mut pending = vec![PendingId {
434 line: 0,
435 id: "01JINV000000000000000000BB".to_string(),
436 kind: WriteBackKind::InvolvedIn {
437 entity_name: "Alice".to_string(),
438 },
439 }];
440
441 let result = apply_writebacks(content, &mut pending).unwrap();
442 assert!(result.contains("## Involved"));
443 assert!(result.contains("- Alice"));
444 assert!(result.contains(" id: 01JINV000000000000000000BB"));
445
446 let involved_pos = result.find("## Involved").unwrap();
448 let timeline_pos = result.find("## Timeline").unwrap();
449 assert!(involved_pos < timeline_pos);
450 }
451
452 #[test]
453 fn writeback_involved_in_new_section_multiple_entities() {
454 let content = "\
455---
456sources:
457 - https://example.com
458---
459
460# Case
461
462Summary.
463
464## Timeline
465
466- A -> B
467";
468 let mut pending = vec![
469 PendingId {
470 line: 0,
471 id: "01JINV000000000000000000CC".to_string(),
472 kind: WriteBackKind::InvolvedIn {
473 entity_name: "Alice".to_string(),
474 },
475 },
476 PendingId {
477 line: 0,
478 id: "01JINV000000000000000000DD".to_string(),
479 kind: WriteBackKind::InvolvedIn {
480 entity_name: "Bob Corp".to_string(),
481 },
482 },
483 ];
484
485 let result = apply_writebacks(content, &mut pending).unwrap();
486 assert!(result.contains("## Involved"));
487 assert!(result.contains("- Alice"));
488 assert!(result.contains(" id: 01JINV000000000000000000CC"));
489 assert!(result.contains("- Bob Corp"));
490 assert!(result.contains(" id: 01JINV000000000000000000DD"));
491
492 let involved_pos = result.find("## Involved").unwrap();
494 let timeline_pos = result.find("## Timeline").unwrap();
495 assert!(involved_pos < timeline_pos);
496 }
497
498 #[test]
499 fn writeback_timeline_edge() {
500 let content = "\
501## Timeline
502
503- Event A -> Event B
504";
505 let mut pending = vec![PendingId {
507 line: 3,
508 id: "01JTIM000000000000000000AA".to_string(),
509 kind: WriteBackKind::TimelineEdge,
510 }];
511
512 let result = apply_writebacks(content, &mut pending).unwrap();
513 let lines: Vec<&str> = result.lines().collect();
514 assert_eq!(lines[2], "- Event A -> Event B");
515 assert_eq!(lines[3], " id: 01JTIM000000000000000000AA");
516 }
517
518 #[test]
519 fn writeback_entity_front_matter_replaces_empty_id() {
520 let content = "---\nid:\n---\n\n# Ali Murtopo\n\n- nationality: Indonesian\n";
521 let end_line = find_front_matter_end(content).unwrap();
522 assert_eq!(end_line, 3);
523
524 let mut pending = vec![PendingId {
525 line: end_line,
526 id: "01JABC000000000000000000AA".to_string(),
527 kind: WriteBackKind::EntityFrontMatter,
528 }];
529
530 let result = apply_writebacks(content, &mut pending).unwrap();
531 let lines: Vec<&str> = result.lines().collect();
532 assert_eq!(lines[0], "---");
533 assert_eq!(lines[1], "id: 01JABC000000000000000000AA");
534 assert_eq!(lines[2], "---");
535 assert_eq!(lines.len(), 7);
537 }
538
539 #[test]
540 fn writeback_case_id_replaces_empty_id() {
541 let content = "---\nid:\nsources:\n - https://example.com\n---\n\n# Some Case\n";
542 let end_line = find_front_matter_end(content).unwrap();
543 assert_eq!(end_line, 5);
544
545 let mut pending = vec![PendingId {
546 line: end_line,
547 id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
548 kind: WriteBackKind::CaseId,
549 }];
550
551 let result = apply_writebacks(content, &mut pending).unwrap();
552 let lines: Vec<&str> = result.lines().collect();
553 assert_eq!(lines[1], "id: 01JXYZ123456789ABCDEFGHIJK");
554 assert_eq!(lines.len(), 7);
556 }
557
558 #[test]
559 fn writeback_does_not_replace_populated_id() {
560 let content = "---\nid: 01JEXISTING00000000000000\n---\n\n# Test\n";
561 let end_line = find_front_matter_end(content).unwrap();
562
563 let mut pending = vec![PendingId {
564 line: end_line,
565 id: "01JNEW000000000000000000AA".to_string(),
566 kind: WriteBackKind::EntityFrontMatter,
567 }];
568
569 let result = apply_writebacks(content, &mut pending).unwrap();
570 let lines: Vec<&str> = result.lines().collect();
571 assert_eq!(lines[1], "id: 01JEXISTING00000000000000");
573 assert_eq!(lines[2], "id: 01JNEW000000000000000000AA");
574 }
575}