Skip to main content

weave_content/
writeback.rs

1use std::path::Path;
2
3/// A generated ID that needs to be written back to a source file.
4#[derive(Debug)]
5pub struct PendingId {
6    /// 1-indexed line number where the ID should be inserted.
7    pub line: usize,
8    /// The generated NULID string.
9    pub id: String,
10    /// What kind of insertion to perform.
11    pub kind: WriteBackKind,
12}
13
14/// The type of write-back insertion.
15#[derive(Debug)]
16pub enum WriteBackKind {
17    /// Insert `id: <NULID>` into YAML front matter of an entity file.
18    /// `line` is the line of the `---` closing delimiter; insert before it.
19    EntityFrontMatter,
20    /// Insert `id: <NULID>` into YAML front matter of a case file.
21    /// `line` is the line of the `---` closing delimiter; insert before it.
22    CaseId,
23    /// Insert `- id: <NULID>` bullet after the H3 heading line.
24    /// `line` is the line of the `### Name` heading.
25    InlineEvent,
26    /// Insert `  id: <NULID>` after the relationship line.
27    /// `line` is the line of `- Source -> Target: type`.
28    Relationship,
29    /// Insert `  id: <NULID>` after a `## Related Cases` bullet.
30    /// `line` is the line of `- case/path`.
31    RelatedCase,
32    /// Insert `  id: <NULID>` after a `## Involved` bullet.
33    /// `line` is the line of `- Entity Name` (0 if section doesn't exist yet).
34    /// `entity_name` is needed to create the bullet when the section is missing.
35    InvolvedIn { entity_name: String },
36    /// Insert `  id: <NULID>` after a `## Timeline` bullet.
37    /// `line` is the line of `- Event A -> Event B`.
38    TimelineEdge,
39}
40
41impl WriteBackKind {
42    /// The formatted `id: <NULID>` text to insert.
43    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    /// Whether this kind targets YAML front matter (insert before closing `---`).
55    const fn is_front_matter(&self) -> bool {
56        matches!(self, Self::EntityFrontMatter | Self::CaseId)
57    }
58
59    /// Whether this kind creates a new section when `line == 0`.
60    const fn needs_section_creation(&self) -> bool {
61        matches!(self, Self::InvolvedIn { .. })
62    }
63}
64
65/// Apply pending ID write-backs to a file's content.
66///
67/// Insertions are applied from bottom to top (highest line number first)
68/// so that earlier insertions don't shift line numbers for later ones.
69///
70/// Returns the modified content, or `None` if no changes were needed.
71pub 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    // Partition: section-creation entries (InvolvedIn with line==0) vs normal
80    let (new_involved, mut normal): (Vec<_>, Vec<_>) =
81        pending.iter().partition::<Vec<_>, _>(|p| p.kind.needs_section_creation() && p.line == 0);
82
83    // Sort normal insertions by line descending (bottom-to-top)
84    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    // Append ## Involved section for entities that need a new section
97    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
115/// Insert an ID into YAML front matter, replacing an empty `id:` if present.
116fn insert_front_matter_id(lines: &mut Vec<String>, closing_line: usize, text: &str) {
117    let end_idx = closing_line.saturating_sub(1); // 0-indexed
118    let bound = end_idx.min(lines.len());
119
120    // Look for an existing empty `id:` line to replace
121    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    // No empty id found — insert before the closing `---`
130    if end_idx <= lines.len() {
131        lines.insert(end_idx, text.to_string());
132    }
133}
134
135/// Insert an ID after a body element (event heading, relationship, etc.),
136/// replacing an existing empty `id:` or skipping if already populated.
137fn insert_body_id(lines: &mut Vec<String>, parent_line: usize, text: &str) {
138    // Scan indented lines after the parent for an existing id field
139    for line in lines.iter_mut().skip(parent_line) {
140        let trimmed = line.trim();
141
142        // Check for id field
143        if let Some(value) = strip_id_prefix(trimmed) {
144            if value.is_empty() {
145                // Empty id — replace, preserving original indentation
146                let indent = &line[..line.len() - line.trim_start().len()];
147                *line = format!("{indent}{}", text.trim_start());
148            }
149            // Existing id (empty or populated) — don't insert another
150            return;
151        }
152
153        // Stop at blank line, heading, or unindented line (next top-level bullet)
154        if trimmed.is_empty() || trimmed.starts_with('#') || !line.starts_with(' ') {
155            break;
156        }
157    }
158
159    // No existing id found — insert after the parent line
160    if parent_line <= lines.len() {
161        lines.insert(parent_line, text.to_string());
162    }
163}
164
165/// Strip `id:` prefix, returning the value portion (possibly empty).
166/// Returns `None` if the line is not an id field.
167fn strip_id_prefix(trimmed: &str) -> Option<&str> {
168    trimmed.strip_prefix("id:").map(str::trim)
169}
170
171/// Append a `## Involved` section with the given entity names and IDs.
172/// Inserts before `## Timeline` or `## Related Cases`, or at end of file.
173fn 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    // Blank line before section if needed
179    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
195/// Find the best insertion point for a new `## Involved` section.
196/// Prefers inserting before `## Timeline` or `## Related Cases`.
197/// Falls back to end of file.
198fn 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
208/// Write modified content back to the file at `path`.
209pub 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
214/// Find the 1-indexed line number of the closing `---` in YAML front matter.
215/// Returns `None` if the file has no valid front matter.
216pub 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    // -- Front matter insertion --
234
235    #[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); // no duplicate
269    }
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); // no duplicate
291    }
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"); // inserts, doesn't replace
302    }
303
304    // -- Inline event --
305
306    #[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    // -- Relationship --
318
319    #[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        // `- id:` is NOT recognized anymore — inserts a new `  id:` line
354        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"); // new id inserted after parent
359        assert_eq!(lines[4], "  - id:");      // old bullet format left as-is
360    }
361
362    // -- Related case --
363
364    #[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    // -- Involved --
376
377    #[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    // -- Timeline edge --
425
426    #[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    // -- Multiple insertions --
447
448    #[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    // -- Misc --
466
467    #[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    // -- Helpers --
489
490    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        // apply_writebacks takes &mut [PendingId], so we need an owned vec
496        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}