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 let insert_idx = find_section_insert_point(lines);
175
176 let mut section = Vec::with_capacity(2 + entries.len() * 2);
177
178 if insert_idx > 0 && !lines[insert_idx - 1].is_empty() {
180 section.push(String::new());
181 }
182 section.push("## Involved".to_string());
183 section.push(String::new());
184
185 for (name, id) in entries {
186 section.push(format!("- {name}"));
187 section.push(format!(" id: {id}"));
188 }
189
190 for (offset, line) in section.into_iter().enumerate() {
191 lines.insert(insert_idx + offset, line);
192 }
193}
194
195fn find_section_insert_point(lines: &[String]) -> usize {
199 lines
200 .iter()
201 .position(|l| {
202 let t = l.trim();
203 t == "## Timeline" || t == "## Related Cases"
204 })
205 .unwrap_or(lines.len())
206}
207
208pub fn write_file(path: &Path, content: &str) -> Result<(), String> {
210 std::fs::write(path, content)
211 .map_err(|e| format!("{}: error writing file: {e}", path.display()))
212}
213
214pub fn find_front_matter_end(content: &str) -> Option<usize> {
217 let mut in_front_matter = false;
218 for (i, line) in content.lines().enumerate() {
219 if line.trim() == "---" {
220 if in_front_matter {
221 return Some(i + 1);
222 }
223 in_front_matter = true;
224 }
225 }
226 None
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
236 fn entity_front_matter_empty() {
237 let content = "---\n---\n\n# Mark Bonnick\n\n- nationality: British\n";
238 let end_line = find_front_matter_end(content).unwrap();
239
240 let result = apply(&[pending(end_line, "01JXYZ", WriteBackKind::EntityFrontMatter)], content);
241 let lines = to_lines(&result);
242 assert_eq!(lines[0], "---");
243 assert_eq!(lines[1], "id: 01JXYZ");
244 assert_eq!(lines[2], "---");
245 }
246
247 #[test]
248 fn entity_front_matter_with_existing_fields() {
249 let content = "---\nother: value\n---\n\n# Test\n";
250 let end_line = find_front_matter_end(content).unwrap();
251
252 let result = apply(&[pending(end_line, "01JABC", WriteBackKind::EntityFrontMatter)], content);
253 let lines = to_lines(&result);
254 assert_eq!(lines[1], "other: value");
255 assert_eq!(lines[2], "id: 01JABC");
256 assert_eq!(lines[3], "---");
257 }
258
259 #[test]
260 fn entity_front_matter_replaces_empty_id() {
261 let content = "---\nid:\n---\n\n# Ali Murtopo\n\n- nationality: Indonesian\n";
262 let end_line = find_front_matter_end(content).unwrap();
263
264 let result = apply(&[pending(end_line, "01JABC", WriteBackKind::EntityFrontMatter)], content);
265 let lines = to_lines(&result);
266 assert_eq!(lines[1], "id: 01JABC");
267 assert_eq!(lines[2], "---");
268 assert_eq!(lines.len(), 7); }
270
271 #[test]
272 fn case_id_insert() {
273 let content = "---\nsources:\n - https://example.com\n---\n\n# Some Case\n";
274 let end_line = find_front_matter_end(content).unwrap();
275
276 let result = apply(&[pending(end_line, "01JXYZ", WriteBackKind::CaseId)], content);
277 let lines = to_lines(&result);
278 assert_eq!(lines[3], "id: 01JXYZ");
279 assert_eq!(lines[4], "---");
280 }
281
282 #[test]
283 fn case_id_replaces_empty_id() {
284 let content = "---\nid:\nsources:\n - https://example.com\n---\n\n# Some Case\n";
285 let end_line = find_front_matter_end(content).unwrap();
286
287 let result = apply(&[pending(end_line, "01JXYZ", WriteBackKind::CaseId)], content);
288 let lines = to_lines(&result);
289 assert_eq!(lines[1], "id: 01JXYZ");
290 assert_eq!(lines.len(), 7); }
292
293 #[test]
294 fn does_not_replace_populated_front_matter_id() {
295 let content = "---\nid: 01JEXISTING\n---\n\n# Test\n";
296 let end_line = find_front_matter_end(content).unwrap();
297
298 let result = apply(&[pending(end_line, "01JNEW", WriteBackKind::EntityFrontMatter)], content);
299 let lines = to_lines(&result);
300 assert_eq!(lines[1], "id: 01JEXISTING");
301 assert_eq!(lines[2], "id: 01JNEW"); }
303
304 #[test]
307 fn inline_event() {
308 let content = "## Events\n\n### Dismissal\n- occurred_at: 2024-12-24\n- event_type: termination\n";
309
310 let result = apply(&[pending(3, "01JXYZ", WriteBackKind::InlineEvent)], content);
311 let lines = to_lines(&result);
312 assert_eq!(lines[2], "### Dismissal");
313 assert_eq!(lines[3], "- id: 01JXYZ");
314 assert_eq!(lines[4], "- occurred_at: 2024-12-24");
315 }
316
317 #[test]
320 fn relationship_insert() {
321 let content = "## Relationships\n\n- Alice -> Bob: employed_by\n - source: https://example.com\n";
322
323 let result = apply(&[pending(3, "01JXYZ", WriteBackKind::Relationship)], content);
324 let lines = to_lines(&result);
325 assert_eq!(lines[2], "- Alice -> Bob: employed_by");
326 assert_eq!(lines[3], " id: 01JXYZ");
327 assert_eq!(lines[4], " - source: https://example.com");
328 }
329
330 #[test]
331 fn relationship_replaces_empty_id() {
332 let content = "## Relationships\n\n- A -> B: preceded_by\n id:\n description: replaced\n";
333
334 let result = apply(&[pending(3, "01JABC", WriteBackKind::Relationship)], content);
335 let lines = to_lines(&result);
336 assert_eq!(lines[3], " id: 01JABC");
337 assert_eq!(lines[4], " description: replaced");
338 assert_eq!(lines.len(), 5);
339 }
340
341 #[test]
342 fn relationship_does_not_duplicate_populated_id() {
343 let content = "## Relationships\n\n- A -> B: preceded_by\n id: 01JEXISTING\n description: test\n";
344
345 let result = apply(&[pending(3, "01JNEW", WriteBackKind::Relationship)], content);
346 let lines = to_lines(&result);
347 assert_eq!(lines[3], " id: 01JEXISTING");
348 assert_eq!(lines.len(), 5);
349 }
350
351 #[test]
352 fn relationship_does_not_replace_old_bullet_format() {
353 let content = "## Relationships\n\n- A -> B: preceded_by\n - id:\n description: test\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"); assert_eq!(lines[4], " - id:"); }
361
362 #[test]
365 fn related_case() {
366 let content = "## Related Cases\n\n- id/corruption/2013/some-case\n description: Related scandal\n";
367
368 let result = apply(&[pending(3, "01JREL", WriteBackKind::RelatedCase)], content);
369 let lines = to_lines(&result);
370 assert_eq!(lines[2], "- id/corruption/2013/some-case");
371 assert_eq!(lines[3], " id: 01JREL");
372 assert_eq!(lines[4], " description: Related scandal");
373 }
374
375 #[test]
378 fn involved_in_existing_section() {
379 let content = "## Involved\n\n- John Doe\n";
380 let kind = WriteBackKind::InvolvedIn { entity_name: "John Doe".to_string() };
381
382 let result = apply(&[pending(3, "01JINV", kind)], content);
383 let lines = to_lines(&result);
384 assert_eq!(lines[2], "- John Doe");
385 assert_eq!(lines[3], " id: 01JINV");
386 }
387
388 #[test]
389 fn involved_in_new_section() {
390 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";
391 let kind = WriteBackKind::InvolvedIn { entity_name: "Alice".to_string() };
392
393 let result = apply(&[pending(0, "01JINV", kind)], content);
394 assert!(result.contains("## Involved"));
395 assert!(result.contains("- Alice"));
396 assert!(result.contains(" id: 01JINV"));
397
398 let involved_pos = result.find("## Involved").unwrap();
399 let timeline_pos = result.find("## Timeline").unwrap();
400 assert!(involved_pos < timeline_pos);
401 }
402
403 #[test]
404 fn involved_in_new_section_multiple_entities() {
405 let content = "---\nsources:\n - https://example.com\n---\n\n# Case\n\nSummary.\n\n## Timeline\n\n- A -> B\n";
406
407 let result = apply(
408 &[
409 pending(0, "01JCC", WriteBackKind::InvolvedIn { entity_name: "Alice".to_string() }),
410 pending(0, "01JDD", WriteBackKind::InvolvedIn { entity_name: "Bob Corp".to_string() }),
411 ],
412 content,
413 );
414 assert!(result.contains("- Alice"));
415 assert!(result.contains(" id: 01JCC"));
416 assert!(result.contains("- Bob Corp"));
417 assert!(result.contains(" id: 01JDD"));
418
419 let involved_pos = result.find("## Involved").unwrap();
420 let timeline_pos = result.find("## Timeline").unwrap();
421 assert!(involved_pos < timeline_pos);
422 }
423
424 #[test]
427 fn timeline_edge() {
428 let content = "## Timeline\n\n- Event A -> Event B\n";
429
430 let result = apply(&[pending(3, "01JTIM", WriteBackKind::TimelineEdge)], content);
431 let lines = to_lines(&result);
432 assert_eq!(lines[2], "- Event A -> Event B");
433 assert_eq!(lines[3], " id: 01JTIM");
434 }
435
436 #[test]
437 fn timeline_edge_replaces_empty_id() {
438 let content = "## Timeline\n\n- Event A -> Event B\n id:\n";
439
440 let result = apply(&[pending(3, "01JABC", WriteBackKind::TimelineEdge)], content);
441 let lines = to_lines(&result);
442 assert_eq!(lines[3], " id: 01JABC");
443 assert_eq!(lines.len(), 4);
444 }
445
446 #[test]
449 fn multiple_insertions() {
450 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";
451
452 let result = apply(
453 &[
454 pending(3, "01JAAA", WriteBackKind::InlineEvent),
455 pending(6, "01JBBB", WriteBackKind::InlineEvent),
456 pending(10, "01JCCC", WriteBackKind::Relationship),
457 ],
458 content,
459 );
460 assert!(result.contains("- id: 01JAAA"));
461 assert!(result.contains("- id: 01JBBB"));
462 assert!(result.contains(" id: 01JCCC"));
463 }
464
465 #[test]
468 fn empty_pending() {
469 let mut pending: Vec<PendingId> = Vec::new();
470 assert!(apply_writebacks("some content\n", &mut pending).is_none());
471 }
472
473 #[test]
474 fn preserves_trailing_newline() {
475 let content = "---\n---\n\n# Test\n";
476 let result = apply(&[pending(2, "01JABC", WriteBackKind::EntityFrontMatter)], content);
477 assert!(result.ends_with('\n'));
478 }
479
480 #[test]
481 fn find_front_matter_end_basic() {
482 assert_eq!(find_front_matter_end("---\nid: foo\n---\n"), Some(3));
483 assert_eq!(find_front_matter_end("---\n---\n"), Some(2));
484 assert_eq!(find_front_matter_end("no front matter"), None);
485 assert_eq!(find_front_matter_end("---\nunclosed"), None);
486 }
487
488 fn pending(line: usize, id: &str, kind: WriteBackKind) -> PendingId {
491 PendingId { line, id: id.to_string(), kind }
492 }
493
494 fn apply(specs: &[PendingId], content: &str) -> String {
495 let mut owned: Vec<PendingId> = specs
497 .iter()
498 .map(|p| PendingId {
499 line: p.line,
500 id: p.id.clone(),
501 kind: match &p.kind {
502 WriteBackKind::EntityFrontMatter => WriteBackKind::EntityFrontMatter,
503 WriteBackKind::CaseId => WriteBackKind::CaseId,
504 WriteBackKind::InlineEvent => WriteBackKind::InlineEvent,
505 WriteBackKind::Relationship => WriteBackKind::Relationship,
506 WriteBackKind::RelatedCase => WriteBackKind::RelatedCase,
507 WriteBackKind::InvolvedIn { entity_name } => WriteBackKind::InvolvedIn {
508 entity_name: entity_name.clone(),
509 },
510 WriteBackKind::TimelineEdge => WriteBackKind::TimelineEdge,
511 },
512 })
513 .collect();
514 apply_writebacks(content, &mut owned).unwrap()
515 }
516
517 fn to_lines(s: &str) -> Vec<&str> {
518 s.lines().collect()
519 }
520}