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
41impl WriteBackKind {
42 fn format_id(&self, id: &str) -> String {
44 match self {
45 Self::EntityFrontMatter | Self::CaseId => format!("id: {id}"),
46 Self::InlineEvent => format!("- id: {id}"),
47 Self::Relationship
48 | Self::RelatedCase
49 | Self::InvolvedIn { .. }
50 | Self::TimelineEdge => format!(" id: {id}"),
51 }
52 }
53
54 const fn is_front_matter(&self) -> bool {
56 matches!(self, Self::EntityFrontMatter | Self::CaseId)
57 }
58
59 const fn needs_section_creation(&self) -> bool {
61 matches!(self, Self::InvolvedIn { .. })
62 }
63}
64
65pub fn apply_writebacks(content: &str, pending: &mut [PendingId]) -> Option<String> {
72 if pending.is_empty() {
73 return None;
74 }
75
76 let mut lines: Vec<String> = content.lines().map(String::from).collect();
77 let trailing_newline = content.ends_with('\n');
78
79 let (new_involved, mut normal): (Vec<_>, Vec<_>) =
81 pending.iter().partition::<Vec<_>, _>(|p| p.kind.needs_section_creation() && p.line == 0);
82
83 normal.sort_by_key(|p| std::cmp::Reverse(p.line));
85
86 for p in &normal {
87 let text = p.kind.format_id(&p.id);
88
89 if p.kind.is_front_matter() {
90 insert_front_matter_id(&mut lines, p.line, &text);
91 } else {
92 insert_body_id(&mut lines, p.line, &text);
93 }
94 }
95
96 if !new_involved.is_empty() {
98 let entries: Vec<_> = new_involved
99 .iter()
100 .filter_map(|p| match &p.kind {
101 WriteBackKind::InvolvedIn { entity_name } => Some((entity_name.as_str(), &*p.id)),
102 _ => None,
103 })
104 .collect();
105 append_involved_section(&mut lines, &entries);
106 }
107
108 let mut result = lines.join("\n");
109 if trailing_newline {
110 result.push('\n');
111 }
112 Some(result)
113}
114
115fn insert_front_matter_id(lines: &mut Vec<String>, closing_line: usize, text: &str) {
117 let end_idx = closing_line.saturating_sub(1); let bound = end_idx.min(lines.len());
119
120 for line in lines.iter_mut().take(bound) {
122 let trimmed = line.trim();
123 if trimmed == "id:" || trimmed == "id: " {
124 *line = text.to_string();
125 return;
126 }
127 }
128
129 if end_idx <= lines.len() {
131 lines.insert(end_idx, text.to_string());
132 }
133}
134
135fn insert_body_id(lines: &mut Vec<String>, parent_line: usize, text: &str) {
138 for line in lines.iter_mut().skip(parent_line) {
140 let trimmed = line.trim();
141
142 if let Some(value) = strip_id_prefix(trimmed) {
144 if value.is_empty() {
145 let indent = &line[..line.len() - line.trim_start().len()];
147 *line = format!("{indent}{}", text.trim_start());
148 }
149 return;
151 }
152
153 if trimmed.is_empty() || trimmed.starts_with('#') || !line.starts_with(' ') {
155 break;
156 }
157 }
158
159 if parent_line <= lines.len() {
161 lines.insert(parent_line, text.to_string());
162 }
163}
164
165fn strip_id_prefix(trimmed: &str) -> Option<&str> {
168 trimmed.strip_prefix("id:").map(str::trim)
169}
170
171fn append_involved_section(lines: &mut Vec<String>, entries: &[(&str, &str)]) {
174 if let Some(existing_idx) = lines.iter().position(|l| l.trim() == "## Involved") {
176 let mut insert_at = existing_idx + 1;
178 for (i, line) in lines.iter().enumerate().skip(existing_idx + 1) {
179 if line.trim().starts_with("## ") {
180 break;
181 }
182 insert_at = i + 1;
183 }
184
185 let mut offset = 0;
187 for (name, id) in entries {
188 lines.insert(insert_at + offset, format!("- {name}"));
189 offset += 1;
190 lines.insert(insert_at + offset, format!(" id: {id}"));
191 offset += 1;
192 }
193 } else {
194 let insert_idx = find_section_insert_point(lines);
196
197 let mut section = Vec::with_capacity(2 + entries.len() * 2);
198
199 if insert_idx > 0 && !lines[insert_idx - 1].is_empty() {
201 section.push(String::new());
202 }
203 section.push("## Involved".to_string());
204 section.push(String::new());
205
206 for (name, id) in entries {
207 section.push(format!("- {name}"));
208 section.push(format!(" id: {id}"));
209 }
210
211 for (offset, line) in section.into_iter().enumerate() {
212 lines.insert(insert_idx + offset, line);
213 }
214 }
215}
216
217fn find_section_insert_point(lines: &[String]) -> usize {
221 lines
222 .iter()
223 .position(|l| {
224 let t = l.trim();
225 t == "## Timeline" || t == "## Related Cases"
226 })
227 .unwrap_or(lines.len())
228}
229
230pub fn write_file(path: &Path, content: &str) -> Result<(), String> {
232 std::fs::write(path, content)
233 .map_err(|e| format!("{}: error writing file: {e}", path.display()))
234}
235
236pub fn find_front_matter_end(content: &str) -> Option<usize> {
239 let mut in_front_matter = false;
240 for (i, line) in content.lines().enumerate() {
241 if line.trim() == "---" {
242 if in_front_matter {
243 return Some(i + 1);
244 }
245 in_front_matter = true;
246 }
247 }
248 None
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
258 fn entity_front_matter_empty() {
259 let content = "---\n---\n\n# Mark Bonnick\n\n- nationality: British\n";
260 let end_line = find_front_matter_end(content).unwrap();
261
262 let result = apply(&[pending(end_line, "01JXYZ", WriteBackKind::EntityFrontMatter)], content);
263 let lines = to_lines(&result);
264 assert_eq!(lines[0], "---");
265 assert_eq!(lines[1], "id: 01JXYZ");
266 assert_eq!(lines[2], "---");
267 }
268
269 #[test]
270 fn entity_front_matter_with_existing_fields() {
271 let content = "---\nother: value\n---\n\n# Test\n";
272 let end_line = find_front_matter_end(content).unwrap();
273
274 let result = apply(&[pending(end_line, "01JABC", WriteBackKind::EntityFrontMatter)], content);
275 let lines = to_lines(&result);
276 assert_eq!(lines[1], "other: value");
277 assert_eq!(lines[2], "id: 01JABC");
278 assert_eq!(lines[3], "---");
279 }
280
281 #[test]
282 fn entity_front_matter_replaces_empty_id() {
283 let content = "---\nid:\n---\n\n# Ali Murtopo\n\n- nationality: Indonesian\n";
284 let end_line = find_front_matter_end(content).unwrap();
285
286 let result = apply(&[pending(end_line, "01JABC", WriteBackKind::EntityFrontMatter)], content);
287 let lines = to_lines(&result);
288 assert_eq!(lines[1], "id: 01JABC");
289 assert_eq!(lines[2], "---");
290 assert_eq!(lines.len(), 7); }
292
293 #[test]
294 fn case_id_insert() {
295 let content = "---\nsources:\n - https://example.com\n---\n\n# Some Case\n";
296 let end_line = find_front_matter_end(content).unwrap();
297
298 let result = apply(&[pending(end_line, "01JXYZ", WriteBackKind::CaseId)], content);
299 let lines = to_lines(&result);
300 assert_eq!(lines[3], "id: 01JXYZ");
301 assert_eq!(lines[4], "---");
302 }
303
304 #[test]
305 fn case_id_replaces_empty_id() {
306 let content = "---\nid:\nsources:\n - https://example.com\n---\n\n# Some Case\n";
307 let end_line = find_front_matter_end(content).unwrap();
308
309 let result = apply(&[pending(end_line, "01JXYZ", WriteBackKind::CaseId)], content);
310 let lines = to_lines(&result);
311 assert_eq!(lines[1], "id: 01JXYZ");
312 assert_eq!(lines.len(), 7); }
314
315 #[test]
316 fn does_not_replace_populated_front_matter_id() {
317 let content = "---\nid: 01JEXISTING\n---\n\n# Test\n";
318 let end_line = find_front_matter_end(content).unwrap();
319
320 let result = apply(&[pending(end_line, "01JNEW", WriteBackKind::EntityFrontMatter)], content);
321 let lines = to_lines(&result);
322 assert_eq!(lines[1], "id: 01JEXISTING");
323 assert_eq!(lines[2], "id: 01JNEW"); }
325
326 #[test]
329 fn inline_event() {
330 let content = "## Events\n\n### Dismissal\n- occurred_at: 2024-12-24\n- event_type: termination\n";
331
332 let result = apply(&[pending(3, "01JXYZ", WriteBackKind::InlineEvent)], content);
333 let lines = to_lines(&result);
334 assert_eq!(lines[2], "### Dismissal");
335 assert_eq!(lines[3], "- id: 01JXYZ");
336 assert_eq!(lines[4], "- occurred_at: 2024-12-24");
337 }
338
339 #[test]
342 fn relationship_insert() {
343 let content = "## Relationships\n\n- Alice -> Bob: employed_by\n - source: https://example.com\n";
344
345 let result = apply(&[pending(3, "01JXYZ", WriteBackKind::Relationship)], content);
346 let lines = to_lines(&result);
347 assert_eq!(lines[2], "- Alice -> Bob: employed_by");
348 assert_eq!(lines[3], " id: 01JXYZ");
349 assert_eq!(lines[4], " - source: https://example.com");
350 }
351
352 #[test]
353 fn relationship_replaces_empty_id() {
354 let content = "## Relationships\n\n- A -> B: preceded_by\n id:\n description: replaced\n";
355
356 let result = apply(&[pending(3, "01JABC", WriteBackKind::Relationship)], content);
357 let lines = to_lines(&result);
358 assert_eq!(lines[3], " id: 01JABC");
359 assert_eq!(lines[4], " description: replaced");
360 assert_eq!(lines.len(), 5);
361 }
362
363 #[test]
364 fn relationship_does_not_duplicate_populated_id() {
365 let content = "## Relationships\n\n- A -> B: preceded_by\n id: 01JEXISTING\n description: test\n";
366
367 let result = apply(&[pending(3, "01JNEW", WriteBackKind::Relationship)], content);
368 let lines = to_lines(&result);
369 assert_eq!(lines[3], " id: 01JEXISTING");
370 assert_eq!(lines.len(), 5);
371 }
372
373 #[test]
374 fn relationship_does_not_replace_old_bullet_format() {
375 let content = "## Relationships\n\n- A -> B: preceded_by\n - id:\n description: test\n";
377
378 let result = apply(&[pending(3, "01JABC", WriteBackKind::Relationship)], content);
379 let lines = to_lines(&result);
380 assert_eq!(lines[3], " id: 01JABC"); assert_eq!(lines[4], " - id:"); }
383
384 #[test]
387 fn related_case() {
388 let content = "## Related Cases\n\n- id/corruption/2013/some-case\n description: Related scandal\n";
389
390 let result = apply(&[pending(3, "01JREL", WriteBackKind::RelatedCase)], content);
391 let lines = to_lines(&result);
392 assert_eq!(lines[2], "- id/corruption/2013/some-case");
393 assert_eq!(lines[3], " id: 01JREL");
394 assert_eq!(lines[4], " description: Related scandal");
395 }
396
397 #[test]
400 fn involved_in_existing_section() {
401 let content = "## Involved\n\n- John Doe\n";
402 let kind = WriteBackKind::InvolvedIn { entity_name: "John Doe".to_string() };
403
404 let result = apply(&[pending(3, "01JINV", kind)], content);
405 let lines = to_lines(&result);
406 assert_eq!(lines[2], "- John Doe");
407 assert_eq!(lines[3], " id: 01JINV");
408 }
409
410 #[test]
411 fn involved_in_new_section() {
412 let content = "---\nid: 01CASE\nsources:\n - https://example.com\n---\n\n# Some Case\n\nSummary.\n\n## Events\n\n### Something\n- occurred_at: 2024-01-01\n\n## Timeline\n\n- Something -> Other thing\n";
413 let kind = WriteBackKind::InvolvedIn { entity_name: "Alice".to_string() };
414
415 let result = apply(&[pending(0, "01JINV", kind)], content);
416 assert!(result.contains("## Involved"));
417 assert!(result.contains("- Alice"));
418 assert!(result.contains(" id: 01JINV"));
419
420 let involved_pos = result.find("## Involved").unwrap();
421 let timeline_pos = result.find("## Timeline").unwrap();
422 assert!(involved_pos < timeline_pos);
423 }
424
425 #[test]
426 fn involved_in_new_section_multiple_entities() {
427 let content = "---\nsources:\n - https://example.com\n---\n\n# Case\n\nSummary.\n\n## Timeline\n\n- A -> B\n";
428
429 let result = apply(
430 &[
431 pending(0, "01JCC", WriteBackKind::InvolvedIn { entity_name: "Alice".to_string() }),
432 pending(0, "01JDD", WriteBackKind::InvolvedIn { entity_name: "Bob Corp".to_string() }),
433 ],
434 content,
435 );
436 assert!(result.contains("- Alice"));
437 assert!(result.contains(" id: 01JCC"));
438 assert!(result.contains("- Bob Corp"));
439 assert!(result.contains(" id: 01JDD"));
440
441 let involved_pos = result.find("## Involved").unwrap();
442 let timeline_pos = result.find("## Timeline").unwrap();
443 assert!(involved_pos < timeline_pos);
444 }
445
446 #[test]
447 fn involved_in_appends_to_existing_section() {
448 let content = "## Involved\n\n- John Doe\n id: 01JEXIST\n\n## Timeline\n\n- A -> B\n";
449 let result = apply(
450 &[
451 pending(0, "01JNEW", WriteBackKind::InvolvedIn { entity_name: "Event X".to_string() }),
452 ],
453 content,
454 );
455 let lines = to_lines(&result);
456 let involved_count = lines.iter().filter(|l| l.trim() == "## Involved").count();
458 assert_eq!(involved_count, 1, "should not create duplicate ## Involved section");
459 assert!(result.contains("- Event X"));
461 assert!(result.contains(" id: 01JNEW"));
462 assert!(result.contains("- John Doe"));
464 assert!(result.contains(" id: 01JEXIST"));
465 }
466
467 #[test]
470 fn timeline_edge() {
471 let content = "## Timeline\n\n- Event A -> Event B\n";
472
473 let result = apply(&[pending(3, "01JTIM", WriteBackKind::TimelineEdge)], content);
474 let lines = to_lines(&result);
475 assert_eq!(lines[2], "- Event A -> Event B");
476 assert_eq!(lines[3], " id: 01JTIM");
477 }
478
479 #[test]
480 fn timeline_edge_replaces_empty_id() {
481 let content = "## Timeline\n\n- Event A -> Event B\n id:\n";
482
483 let result = apply(&[pending(3, "01JABC", WriteBackKind::TimelineEdge)], content);
484 let lines = to_lines(&result);
485 assert_eq!(lines[3], " id: 01JABC");
486 assert_eq!(lines.len(), 4);
487 }
488
489 #[test]
492 fn multiple_insertions() {
493 let content = "## Events\n\n### Event A\n- occurred_at: 2024-01-01\n\n### Event B\n- occurred_at: 2024-06-01\n\n## Relationships\n\n- Event A -> Event B: associate_of\n";
494
495 let result = apply(
496 &[
497 pending(3, "01JAAA", WriteBackKind::InlineEvent),
498 pending(6, "01JBBB", WriteBackKind::InlineEvent),
499 pending(10, "01JCCC", WriteBackKind::Relationship),
500 ],
501 content,
502 );
503 assert!(result.contains("- id: 01JAAA"));
504 assert!(result.contains("- id: 01JBBB"));
505 assert!(result.contains(" id: 01JCCC"));
506 }
507
508 #[test]
511 fn empty_pending() {
512 let mut pending: Vec<PendingId> = Vec::new();
513 assert!(apply_writebacks("some content\n", &mut pending).is_none());
514 }
515
516 #[test]
517 fn preserves_trailing_newline() {
518 let content = "---\n---\n\n# Test\n";
519 let result = apply(&[pending(2, "01JABC", WriteBackKind::EntityFrontMatter)], content);
520 assert!(result.ends_with('\n'));
521 }
522
523 #[test]
524 fn find_front_matter_end_basic() {
525 assert_eq!(find_front_matter_end("---\nid: foo\n---\n"), Some(3));
526 assert_eq!(find_front_matter_end("---\n---\n"), Some(2));
527 assert_eq!(find_front_matter_end("no front matter"), None);
528 assert_eq!(find_front_matter_end("---\nunclosed"), None);
529 }
530
531 fn pending(line: usize, id: &str, kind: WriteBackKind) -> PendingId {
534 PendingId { line, id: id.to_string(), kind }
535 }
536
537 fn apply(specs: &[PendingId], content: &str) -> String {
538 let mut owned: Vec<PendingId> = specs
540 .iter()
541 .map(|p| PendingId {
542 line: p.line,
543 id: p.id.clone(),
544 kind: match &p.kind {
545 WriteBackKind::EntityFrontMatter => WriteBackKind::EntityFrontMatter,
546 WriteBackKind::CaseId => WriteBackKind::CaseId,
547 WriteBackKind::InlineEvent => WriteBackKind::InlineEvent,
548 WriteBackKind::Relationship => WriteBackKind::Relationship,
549 WriteBackKind::RelatedCase => WriteBackKind::RelatedCase,
550 WriteBackKind::InvolvedIn { entity_name } => WriteBackKind::InvolvedIn {
551 entity_name: entity_name.clone(),
552 },
553 WriteBackKind::TimelineEdge => WriteBackKind::TimelineEdge,
554 },
555 })
556 .collect();
557 apply_writebacks(content, &mut owned).unwrap()
558 }
559
560 fn to_lines(s: &str) -> Vec<&str> {
561 s.lines().collect()
562 }
563}