use std::path::Path;
#[derive(Debug)]
pub struct PendingId {
pub line: usize,
pub id: String,
pub kind: WriteBackKind,
}
#[derive(Debug)]
pub enum WriteBackKind {
EntityFrontMatter,
CaseId,
InlineEvent,
Relationship,
RelatedCase,
InvolvedIn { entity_name: String },
TimelineEdge,
}
pub fn apply_writebacks(content: &str, pending: &mut [PendingId]) -> Option<String> {
if pending.is_empty() {
return None;
}
let mut new_involved: Vec<(String, String)> = Vec::new(); let mut normal: Vec<&PendingId> = Vec::new();
for p in pending.iter() {
if let WriteBackKind::InvolvedIn { ref entity_name } = p.kind
&& p.line == 0 {
new_involved.push((entity_name.clone(), p.id.clone()));
continue;
}
normal.push(p);
}
normal.sort_by_key(|p| std::cmp::Reverse(p.line));
let mut lines: Vec<String> = content.lines().map(String::from).collect();
let trailing_newline = content.ends_with('\n');
for p in &normal {
let insert_text = match p.kind {
WriteBackKind::EntityFrontMatter | WriteBackKind::CaseId => {
format!("id: {}", p.id)
}
WriteBackKind::InlineEvent => {
format!("- id: {}", p.id)
}
WriteBackKind::Relationship => {
format!(" - id: {}", p.id)
}
WriteBackKind::RelatedCase | WriteBackKind::InvolvedIn { .. } | WriteBackKind::TimelineEdge => {
format!(" id: {}", p.id)
}
};
let insert_after = match p.kind {
WriteBackKind::EntityFrontMatter | WriteBackKind::CaseId => {
let idx = p.line.saturating_sub(1); if idx <= lines.len() {
lines.insert(idx, insert_text);
}
continue;
}
WriteBackKind::InlineEvent
| WriteBackKind::Relationship
| WriteBackKind::RelatedCase
| WriteBackKind::InvolvedIn { .. }
| WriteBackKind::TimelineEdge => {
p.line }
};
if insert_after <= lines.len() {
lines.insert(insert_after, insert_text);
}
}
if !new_involved.is_empty() {
let insert_idx = find_section_insert_point(&lines);
let mut section_lines = Vec::new();
if insert_idx > 0 && !lines[insert_idx - 1].is_empty() {
section_lines.push(String::new());
}
section_lines.push("## Involved".to_string());
section_lines.push(String::new());
for (name, id) in &new_involved {
section_lines.push(format!("- {name}"));
section_lines.push(format!(" id: {id}"));
}
for (offset, line) in section_lines.into_iter().enumerate() {
lines.insert(insert_idx + offset, line);
}
}
let mut result = lines.join("\n");
if trailing_newline {
result.push('\n');
}
Some(result)
}
fn find_section_insert_point(lines: &[String]) -> usize {
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed == "## Timeline" || trimmed == "## Related Cases" {
return i;
}
}
lines.len()
}
pub fn write_file(path: &Path, content: &str) -> Result<(), String> {
std::fs::write(path, content)
.map_err(|e| format!("{}: error writing file: {e}", path.display()))
}
pub fn find_front_matter_end(content: &str) -> Option<usize> {
let mut in_front_matter = false;
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed == "---" && !in_front_matter {
in_front_matter = true;
} else if trimmed == "---" && in_front_matter {
return Some(i + 1); }
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writeback_entity_front_matter() {
let content = "---\n---\n\n# Mark Bonnick\n\n- nationality: British\n";
let end_line = find_front_matter_end(content).unwrap();
assert_eq!(end_line, 2);
let mut pending = vec![PendingId {
line: end_line,
id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
kind: WriteBackKind::EntityFrontMatter,
}];
let result = apply_writebacks(content, &mut pending).unwrap();
assert!(result.contains("id: 01JXYZ123456789ABCDEFGHIJK"));
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines[0], "---");
assert_eq!(lines[1], "id: 01JXYZ123456789ABCDEFGHIJK");
assert_eq!(lines[2], "---");
}
#[test]
fn writeback_entity_front_matter_with_existing_fields() {
let content = "---\nother: value\n---\n\n# Test\n";
let end_line = find_front_matter_end(content).unwrap();
assert_eq!(end_line, 3);
let mut pending = vec![PendingId {
line: end_line,
id: "01JABC000000000000000000AA".to_string(),
kind: WriteBackKind::EntityFrontMatter,
}];
let result = apply_writebacks(content, &mut pending).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines[0], "---");
assert_eq!(lines[1], "other: value");
assert_eq!(lines[2], "id: 01JABC000000000000000000AA");
assert_eq!(lines[3], "---");
}
#[test]
fn writeback_inline_event() {
let content = "\
## Events
### Dismissal
- occurred_at: 2024-12-24
- event_type: termination
";
let mut pending = vec![PendingId {
line: 3,
id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
kind: WriteBackKind::InlineEvent,
}];
let result = apply_writebacks(content, &mut pending).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines[2], "### Dismissal");
assert_eq!(lines[3], "- id: 01JXYZ123456789ABCDEFGHIJK");
assert_eq!(lines[4], "- occurred_at: 2024-12-24");
}
#[test]
fn writeback_relationship() {
let content = "\
## Relationships
- Alice -> Bob: employed_by
- source: https://example.com
";
let mut pending = vec![PendingId {
line: 3,
id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
kind: WriteBackKind::Relationship,
}];
let result = apply_writebacks(content, &mut pending).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines[2], "- Alice -> Bob: employed_by");
assert_eq!(lines[3], " - id: 01JXYZ123456789ABCDEFGHIJK");
assert_eq!(lines[4], " - source: https://example.com");
}
#[test]
fn writeback_multiple_insertions() {
let content = "\
## Events
### Event A
- occurred_at: 2024-01-01
### Event B
- occurred_at: 2024-06-01
## Relationships
- Event A -> Event B: associate_of
";
let mut pending = vec![
PendingId {
line: 3,
id: "01JAAA000000000000000000AA".to_string(),
kind: WriteBackKind::InlineEvent,
},
PendingId {
line: 6,
id: "01JBBB000000000000000000BB".to_string(),
kind: WriteBackKind::InlineEvent,
},
PendingId {
line: 10,
id: "01JCCC000000000000000000CC".to_string(),
kind: WriteBackKind::Relationship,
},
];
let result = apply_writebacks(content, &mut pending).unwrap();
assert!(result.contains("- id: 01JAAA000000000000000000AA"));
assert!(result.contains("- id: 01JBBB000000000000000000BB"));
assert!(result.contains(" - id: 01JCCC000000000000000000CC"));
}
#[test]
fn writeback_empty_pending() {
let content = "some content\n";
let mut pending = Vec::new();
assert!(apply_writebacks(content, &mut pending).is_none());
}
#[test]
fn writeback_preserves_trailing_newline() {
let content = "---\n---\n\n# Test\n";
let mut pending = vec![PendingId {
line: 2,
id: "01JABC000000000000000000AA".to_string(),
kind: WriteBackKind::EntityFrontMatter,
}];
let result = apply_writebacks(content, &mut pending).unwrap();
assert!(result.ends_with('\n'));
}
#[test]
fn writeback_case_id() {
let content = "---\nsources:\n - https://example.com\n---\n\n# Some Case\n";
let end_line = find_front_matter_end(content).unwrap();
assert_eq!(end_line, 4);
let mut pending = vec![PendingId {
line: end_line,
id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
kind: WriteBackKind::CaseId,
}];
let result = apply_writebacks(content, &mut pending).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines[0], "---");
assert_eq!(lines[3], "id: 01JXYZ123456789ABCDEFGHIJK");
assert_eq!(lines[4], "---");
assert!(!result.contains("\nnulid:"));
}
#[test]
fn find_front_matter_end_basic() {
assert_eq!(find_front_matter_end("---\nid: foo\n---\n"), Some(3));
assert_eq!(find_front_matter_end("---\n---\n"), Some(2));
assert_eq!(find_front_matter_end("no front matter"), None);
assert_eq!(find_front_matter_end("---\nunclosed"), None);
}
#[test]
fn writeback_related_case() {
let content = "\
## Related Cases
- id/corruption/2013/some-case
description: Related scandal
";
let mut pending = vec![PendingId {
line: 3,
id: "01JREL000000000000000000AA".to_string(),
kind: WriteBackKind::RelatedCase,
}];
let result = apply_writebacks(content, &mut pending).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines[2], "- id/corruption/2013/some-case");
assert_eq!(lines[3], " id: 01JREL000000000000000000AA");
assert_eq!(lines[4], " description: Related scandal");
}
#[test]
fn writeback_involved_in_existing_section() {
let content = "\
## Involved
- John Doe
";
let mut pending = vec![PendingId {
line: 3,
id: "01JINV000000000000000000AA".to_string(),
kind: WriteBackKind::InvolvedIn {
entity_name: "John Doe".to_string(),
},
}];
let result = apply_writebacks(content, &mut pending).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines[2], "- John Doe");
assert_eq!(lines[3], " id: 01JINV000000000000000000AA");
}
#[test]
fn writeback_involved_in_new_section() {
let content = "\
---
id: 01CASE000000000000000000AA
sources:
- https://example.com
---
# Some Case
Summary text.
## Events
### Something
- occurred_at: 2024-01-01
## Timeline
- Something -> Other thing
";
let mut pending = vec![PendingId {
line: 0,
id: "01JINV000000000000000000BB".to_string(),
kind: WriteBackKind::InvolvedIn {
entity_name: "Alice".to_string(),
},
}];
let result = apply_writebacks(content, &mut pending).unwrap();
assert!(result.contains("## Involved"));
assert!(result.contains("- Alice"));
assert!(result.contains(" id: 01JINV000000000000000000BB"));
let involved_pos = result.find("## Involved").unwrap();
let timeline_pos = result.find("## Timeline").unwrap();
assert!(involved_pos < timeline_pos);
}
#[test]
fn writeback_involved_in_new_section_multiple_entities() {
let content = "\
---
sources:
- https://example.com
---
# Case
Summary.
## Timeline
- A -> B
";
let mut pending = vec![
PendingId {
line: 0,
id: "01JINV000000000000000000CC".to_string(),
kind: WriteBackKind::InvolvedIn {
entity_name: "Alice".to_string(),
},
},
PendingId {
line: 0,
id: "01JINV000000000000000000DD".to_string(),
kind: WriteBackKind::InvolvedIn {
entity_name: "Bob Corp".to_string(),
},
},
];
let result = apply_writebacks(content, &mut pending).unwrap();
assert!(result.contains("## Involved"));
assert!(result.contains("- Alice"));
assert!(result.contains(" id: 01JINV000000000000000000CC"));
assert!(result.contains("- Bob Corp"));
assert!(result.contains(" id: 01JINV000000000000000000DD"));
let involved_pos = result.find("## Involved").unwrap();
let timeline_pos = result.find("## Timeline").unwrap();
assert!(involved_pos < timeline_pos);
}
#[test]
fn writeback_timeline_edge() {
let content = "\
## Timeline
- Event A -> Event B
";
let mut pending = vec![PendingId {
line: 3,
id: "01JTIM000000000000000000AA".to_string(),
kind: WriteBackKind::TimelineEdge,
}];
let result = apply_writebacks(content, &mut pending).unwrap();
let lines: Vec<&str> = result.lines().collect();
assert_eq!(lines[2], "- Event A -> Event B");
assert_eq!(lines[3], " id: 01JTIM000000000000000000AA");
}
}