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