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