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