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 entries to an existing `## Involved` section, or create a new one.
172/// Inserts before `## Timeline` or `## Related Cases`, or at end of file.
173fn append_involved_section(lines: &mut Vec<String>, entries: &[(&str, &str)]) {
174    // Check if ## Involved already exists
175    if let Some(existing_idx) = lines.iter().position(|l| l.trim() == "## Involved") {
176        // Find the end of the existing Involved section (next ## heading or EOF)
177        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        // Insert entries at the end of the existing section
186        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        // No existing section — create a new one
195        let insert_idx = find_section_insert_point(lines);
196
197        let mut section = Vec::with_capacity(2 + entries.len() * 2);
198
199        // Blank line before section if needed
200        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
217/// Find the best insertion point for a new `## Involved` section.
218/// Prefers inserting before `## Timeline` or `## Related Cases`.
219/// Falls back to end of file.
220fn 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
230/// Write modified content back to the file at `path`.
231pub 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
236/// Find the 1-indexed line number of the closing `---` in YAML front matter.
237/// Returns `None` if the file has no valid front matter.
238pub 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    // -- Front matter insertion --
256
257    #[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); // no duplicate
291    }
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); // no duplicate
313    }
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"); // inserts, doesn't replace
324    }
325
326    // -- Inline event --
327
328    #[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    // -- Relationship --
340
341    #[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        // `- id:` is NOT recognized anymore — inserts a new `  id:` line
376        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"); // new id inserted after parent
381        assert_eq!(lines[4], "  - id:");      // old bullet format left as-is
382    }
383
384    // -- Related case --
385
386    #[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    // -- Involved --
398
399    #[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        // Should have only one ## Involved section
457        let involved_count = lines.iter().filter(|l| l.trim() == "## Involved").count();
458        assert_eq!(involved_count, 1, "should not create duplicate ## Involved section");
459        // The new entry should be in the section
460        assert!(result.contains("- Event X"));
461        assert!(result.contains("  id: 01JNEW"));
462        // Original entry preserved
463        assert!(result.contains("- John Doe"));
464        assert!(result.contains("  id: 01JEXIST"));
465    }
466
467    // -- Timeline edge --
468
469    #[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    // -- Multiple insertions --
490
491    #[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    // -- Misc --
509
510    #[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    // -- Helpers --
532
533    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        // apply_writebacks takes &mut [PendingId], so we need an owned vec
539        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}