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>` nested bullet 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
41/// Apply pending ID write-backs to a file's content.
42///
43/// Insertions are applied from bottom to top (highest line number first)
44/// so that earlier insertions don't shift line numbers for later ones.
45///
46/// Returns the modified content, or None if no changes were needed.
47pub fn apply_writebacks(content: &str, pending: &mut [PendingId]) -> Option<String> {
48    if pending.is_empty() {
49        return None;
50    }
51
52    // Separate out InvolvedIn entries with line==0 (need section creation)
53    // vs normal insertions
54    let mut new_involved: Vec<(String, String)> = Vec::new(); // (entity_name, id)
55    let mut normal: Vec<&PendingId> = Vec::new();
56
57    for p in pending.iter() {
58        if let WriteBackKind::InvolvedIn { ref entity_name } = p.kind
59            && p.line == 0
60        {
61            new_involved.push((entity_name.clone(), p.id.clone()));
62            continue;
63        }
64        normal.push(p);
65    }
66
67    // Sort normal insertions by line descending so we insert from bottom to top
68    normal.sort_by_key(|p| std::cmp::Reverse(p.line));
69
70    let mut lines: Vec<String> = content.lines().map(String::from).collect();
71    // Preserve trailing newline if original had one
72    let trailing_newline = content.ends_with('\n');
73
74    for p in &normal {
75        let insert_text = match p.kind {
76            WriteBackKind::EntityFrontMatter | WriteBackKind::CaseId => {
77                format!("id: {}", p.id)
78            }
79            WriteBackKind::InlineEvent => {
80                format!("- id: {}", p.id)
81            }
82            WriteBackKind::Relationship => {
83                format!("  - id: {}", p.id)
84            }
85            WriteBackKind::RelatedCase
86            | WriteBackKind::InvolvedIn { .. }
87            | WriteBackKind::TimelineEdge => {
88                format!("  id: {}", p.id)
89            }
90        };
91
92        let insert_after = match p.kind {
93            WriteBackKind::EntityFrontMatter | WriteBackKind::CaseId => {
94                // Insert before the closing `---` delimiter.
95                // `line` points to the closing `---`. Insert at that position
96                // (pushing `---` down).
97                let idx = p.line.saturating_sub(1); // 0-indexed
98                if idx <= lines.len() {
99                    lines.insert(idx, insert_text);
100                }
101                continue;
102            }
103            WriteBackKind::InlineEvent
104            | WriteBackKind::Relationship
105            | WriteBackKind::RelatedCase
106            | WriteBackKind::InvolvedIn { .. }
107            | WriteBackKind::TimelineEdge => {
108                // Insert after the heading/relationship/bullet line.
109                p.line // 1-indexed line; inserting after means index = line (0-indexed + 1)
110            }
111        };
112
113        if insert_after <= lines.len() {
114            lines.insert(insert_after, insert_text);
115        }
116    }
117
118    // Append ## Involved section for entities without existing entries
119    if !new_involved.is_empty() {
120        // Find insertion point: before ## Timeline or ## Related Cases, or at end
121        let insert_idx = find_section_insert_point(&lines);
122
123        let mut section_lines = Vec::new();
124        // Add blank line before section if needed
125        if insert_idx > 0 && !lines[insert_idx - 1].is_empty() {
126            section_lines.push(String::new());
127        }
128        section_lines.push("## Involved".to_string());
129        section_lines.push(String::new());
130        for (name, id) in &new_involved {
131            section_lines.push(format!("- {name}"));
132            section_lines.push(format!("  id: {id}"));
133        }
134
135        // Insert all section lines at the insertion point
136        for (offset, line) in section_lines.into_iter().enumerate() {
137            lines.insert(insert_idx + offset, line);
138        }
139    }
140
141    let mut result = lines.join("\n");
142    if trailing_newline {
143        result.push('\n');
144    }
145    Some(result)
146}
147
148/// Find the best insertion point for a new `## Involved` section.
149/// Prefers inserting before `## Timeline` or `## Related Cases`.
150/// Falls back to end of file.
151fn find_section_insert_point(lines: &[String]) -> usize {
152    for (i, line) in lines.iter().enumerate() {
153        let trimmed = line.trim();
154        if trimmed == "## Timeline" || trimmed == "## Related Cases" {
155            return i;
156        }
157    }
158    lines.len()
159}
160
161/// Write modified content back to the file at `path`.
162pub fn write_file(path: &Path, content: &str) -> Result<(), String> {
163    std::fs::write(path, content)
164        .map_err(|e| format!("{}: error writing file: {e}", path.display()))
165}
166
167/// Find the line number of the closing `---` in YAML front matter.
168/// Returns None if the file has no front matter or the closing delimiter
169/// is not found.
170pub fn find_front_matter_end(content: &str) -> Option<usize> {
171    let mut in_front_matter = false;
172    for (i, line) in content.lines().enumerate() {
173        let trimmed = line.trim();
174        if trimmed == "---" && !in_front_matter {
175            in_front_matter = true;
176        } else if trimmed == "---" && in_front_matter {
177            return Some(i + 1); // 1-indexed
178        }
179    }
180    None
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn writeback_entity_front_matter() {
189        let content = "---\n---\n\n# Mark Bonnick\n\n- nationality: British\n";
190        let end_line = find_front_matter_end(content).unwrap();
191        assert_eq!(end_line, 2);
192
193        let mut pending = vec![PendingId {
194            line: end_line,
195            id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
196            kind: WriteBackKind::EntityFrontMatter,
197        }];
198
199        let result = apply_writebacks(content, &mut pending).unwrap();
200        assert!(result.contains("id: 01JXYZ123456789ABCDEFGHIJK"));
201        // id should appear between the two ---
202        let lines: Vec<&str> = result.lines().collect();
203        assert_eq!(lines[0], "---");
204        assert_eq!(lines[1], "id: 01JXYZ123456789ABCDEFGHIJK");
205        assert_eq!(lines[2], "---");
206    }
207
208    #[test]
209    fn writeback_entity_front_matter_with_existing_fields() {
210        let content = "---\nother: value\n---\n\n# Test\n";
211        let end_line = find_front_matter_end(content).unwrap();
212        assert_eq!(end_line, 3);
213
214        let mut pending = vec![PendingId {
215            line: end_line,
216            id: "01JABC000000000000000000AA".to_string(),
217            kind: WriteBackKind::EntityFrontMatter,
218        }];
219
220        let result = apply_writebacks(content, &mut pending).unwrap();
221        let lines: Vec<&str> = result.lines().collect();
222        assert_eq!(lines[0], "---");
223        assert_eq!(lines[1], "other: value");
224        assert_eq!(lines[2], "id: 01JABC000000000000000000AA");
225        assert_eq!(lines[3], "---");
226    }
227
228    #[test]
229    fn writeback_inline_event() {
230        let content = "\
231## Events
232
233### Dismissal
234- occurred_at: 2024-12-24
235- event_type: termination
236";
237        // H3 heading is on line 3 (1-indexed)
238        let mut pending = vec![PendingId {
239            line: 3,
240            id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
241            kind: WriteBackKind::InlineEvent,
242        }];
243
244        let result = apply_writebacks(content, &mut pending).unwrap();
245        let lines: Vec<&str> = result.lines().collect();
246        assert_eq!(lines[2], "### Dismissal");
247        assert_eq!(lines[3], "- id: 01JXYZ123456789ABCDEFGHIJK");
248        assert_eq!(lines[4], "- occurred_at: 2024-12-24");
249    }
250
251    #[test]
252    fn writeback_relationship() {
253        let content = "\
254## Relationships
255
256- Alice -> Bob: employed_by
257  - source: https://example.com
258";
259        // Relationship line is on line 3 (1-indexed)
260        let mut pending = vec![PendingId {
261            line: 3,
262            id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
263            kind: WriteBackKind::Relationship,
264        }];
265
266        let result = apply_writebacks(content, &mut pending).unwrap();
267        let lines: Vec<&str> = result.lines().collect();
268        assert_eq!(lines[2], "- Alice -> Bob: employed_by");
269        assert_eq!(lines[3], "  - id: 01JXYZ123456789ABCDEFGHIJK");
270        assert_eq!(lines[4], "  - source: https://example.com");
271    }
272
273    #[test]
274    fn writeback_multiple_insertions() {
275        let content = "\
276## Events
277
278### Event A
279- occurred_at: 2024-01-01
280
281### Event B
282- occurred_at: 2024-06-01
283
284## Relationships
285
286- Event A -> Event B: associate_of
287";
288        let mut pending = vec![
289            PendingId {
290                line: 3,
291                id: "01JAAA000000000000000000AA".to_string(),
292                kind: WriteBackKind::InlineEvent,
293            },
294            PendingId {
295                line: 6,
296                id: "01JBBB000000000000000000BB".to_string(),
297                kind: WriteBackKind::InlineEvent,
298            },
299            PendingId {
300                line: 10,
301                id: "01JCCC000000000000000000CC".to_string(),
302                kind: WriteBackKind::Relationship,
303            },
304        ];
305
306        let result = apply_writebacks(content, &mut pending).unwrap();
307        assert!(result.contains("- id: 01JAAA000000000000000000AA"));
308        assert!(result.contains("- id: 01JBBB000000000000000000BB"));
309        assert!(result.contains("  - id: 01JCCC000000000000000000CC"));
310    }
311
312    #[test]
313    fn writeback_empty_pending() {
314        let content = "some content\n";
315        let mut pending = Vec::new();
316        assert!(apply_writebacks(content, &mut pending).is_none());
317    }
318
319    #[test]
320    fn writeback_preserves_trailing_newline() {
321        let content = "---\n---\n\n# Test\n";
322        let mut pending = vec![PendingId {
323            line: 2,
324            id: "01JABC000000000000000000AA".to_string(),
325            kind: WriteBackKind::EntityFrontMatter,
326        }];
327        let result = apply_writebacks(content, &mut pending).unwrap();
328        assert!(result.ends_with('\n'));
329    }
330
331    #[test]
332    fn writeback_case_id() {
333        let content = "---\nsources:\n  - https://example.com\n---\n\n# Some Case\n";
334        let end_line = find_front_matter_end(content).unwrap();
335        assert_eq!(end_line, 4);
336
337        let mut pending = vec![PendingId {
338            line: end_line,
339            id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
340            kind: WriteBackKind::CaseId,
341        }];
342
343        let result = apply_writebacks(content, &mut pending).unwrap();
344        let lines: Vec<&str> = result.lines().collect();
345        assert_eq!(lines[0], "---");
346        assert_eq!(lines[3], "id: 01JXYZ123456789ABCDEFGHIJK");
347        assert_eq!(lines[4], "---");
348        assert!(!result.contains("\nnulid:"));
349    }
350
351    #[test]
352    fn find_front_matter_end_basic() {
353        assert_eq!(find_front_matter_end("---\nid: foo\n---\n"), Some(3));
354        assert_eq!(find_front_matter_end("---\n---\n"), Some(2));
355        assert_eq!(find_front_matter_end("no front matter"), None);
356        assert_eq!(find_front_matter_end("---\nunclosed"), None);
357    }
358
359    #[test]
360    fn writeback_related_case() {
361        let content = "\
362## Related Cases
363
364- id/corruption/2013/some-case
365  description: Related scandal
366";
367        // Bullet line is on line 3 (1-indexed)
368        let mut pending = vec![PendingId {
369            line: 3,
370            id: "01JREL000000000000000000AA".to_string(),
371            kind: WriteBackKind::RelatedCase,
372        }];
373
374        let result = apply_writebacks(content, &mut pending).unwrap();
375        let lines: Vec<&str> = result.lines().collect();
376        assert_eq!(lines[2], "- id/corruption/2013/some-case");
377        assert_eq!(lines[3], "  id: 01JREL000000000000000000AA");
378        assert_eq!(lines[4], "  description: Related scandal");
379    }
380
381    #[test]
382    fn writeback_involved_in_existing_section() {
383        let content = "\
384## Involved
385
386- John Doe
387";
388        // Bullet line is on line 3 (1-indexed)
389        let mut pending = vec![PendingId {
390            line: 3,
391            id: "01JINV000000000000000000AA".to_string(),
392            kind: WriteBackKind::InvolvedIn {
393                entity_name: "John Doe".to_string(),
394            },
395        }];
396
397        let result = apply_writebacks(content, &mut pending).unwrap();
398        let lines: Vec<&str> = result.lines().collect();
399        assert_eq!(lines[2], "- John Doe");
400        assert_eq!(lines[3], "  id: 01JINV000000000000000000AA");
401    }
402
403    #[test]
404    fn writeback_involved_in_new_section() {
405        let content = "\
406---
407id: 01CASE000000000000000000AA
408sources:
409  - https://example.com
410---
411
412# Some Case
413
414Summary text.
415
416## Events
417
418### Something
419- occurred_at: 2024-01-01
420
421## Timeline
422
423- Something -> Other thing
424";
425        let mut pending = vec![PendingId {
426            line: 0,
427            id: "01JINV000000000000000000BB".to_string(),
428            kind: WriteBackKind::InvolvedIn {
429                entity_name: "Alice".to_string(),
430            },
431        }];
432
433        let result = apply_writebacks(content, &mut pending).unwrap();
434        assert!(result.contains("## Involved"));
435        assert!(result.contains("- Alice"));
436        assert!(result.contains("  id: 01JINV000000000000000000BB"));
437
438        // ## Involved should appear before ## Timeline
439        let involved_pos = result.find("## Involved").unwrap();
440        let timeline_pos = result.find("## Timeline").unwrap();
441        assert!(involved_pos < timeline_pos);
442    }
443
444    #[test]
445    fn writeback_involved_in_new_section_multiple_entities() {
446        let content = "\
447---
448sources:
449  - https://example.com
450---
451
452# Case
453
454Summary.
455
456## Timeline
457
458- A -> B
459";
460        let mut pending = vec![
461            PendingId {
462                line: 0,
463                id: "01JINV000000000000000000CC".to_string(),
464                kind: WriteBackKind::InvolvedIn {
465                    entity_name: "Alice".to_string(),
466                },
467            },
468            PendingId {
469                line: 0,
470                id: "01JINV000000000000000000DD".to_string(),
471                kind: WriteBackKind::InvolvedIn {
472                    entity_name: "Bob Corp".to_string(),
473                },
474            },
475        ];
476
477        let result = apply_writebacks(content, &mut pending).unwrap();
478        assert!(result.contains("## Involved"));
479        assert!(result.contains("- Alice"));
480        assert!(result.contains("  id: 01JINV000000000000000000CC"));
481        assert!(result.contains("- Bob Corp"));
482        assert!(result.contains("  id: 01JINV000000000000000000DD"));
483
484        // Section should be before ## Timeline
485        let involved_pos = result.find("## Involved").unwrap();
486        let timeline_pos = result.find("## Timeline").unwrap();
487        assert!(involved_pos < timeline_pos);
488    }
489
490    #[test]
491    fn writeback_timeline_edge() {
492        let content = "\
493## Timeline
494
495- Event A -> Event B
496";
497        // Timeline bullet is on line 3 (1-indexed)
498        let mut pending = vec![PendingId {
499            line: 3,
500            id: "01JTIM000000000000000000AA".to_string(),
501            kind: WriteBackKind::TimelineEdge,
502        }];
503
504        let result = apply_writebacks(content, &mut pending).unwrap();
505        let lines: Vec<&str> = result.lines().collect();
506        assert_eq!(lines[2], "- Event A -> Event B");
507        assert_eq!(lines[3], "  id: 01JTIM000000000000000000AA");
508    }
509}