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}
30
31/// Apply pending ID write-backs to a file's content.
32///
33/// Insertions are applied from bottom to top (highest line number first)
34/// so that earlier insertions don't shift line numbers for later ones.
35///
36/// Returns the modified content, or None if no changes were needed.
37pub fn apply_writebacks(content: &str, pending: &mut [PendingId]) -> Option<String> {
38    if pending.is_empty() {
39        return None;
40    }
41
42    // Sort by line descending so we insert from bottom to top
43    pending.sort_by_key(|p| std::cmp::Reverse(p.line));
44
45    let mut lines: Vec<String> = content.lines().map(String::from).collect();
46    // Preserve trailing newline if original had one
47    let trailing_newline = content.ends_with('\n');
48
49    for p in pending.iter() {
50        let insert_text = match p.kind {
51            WriteBackKind::EntityFrontMatter => {
52                format!("id: {}", p.id)
53            }
54            WriteBackKind::CaseId => {
55                format!("id: {}", p.id)
56            }
57            WriteBackKind::InlineEvent => {
58                format!("- id: {}", p.id)
59            }
60            WriteBackKind::Relationship => {
61                format!("  - id: {}", p.id)
62            }
63        };
64
65        let insert_after = match p.kind {
66            WriteBackKind::EntityFrontMatter | WriteBackKind::CaseId => {
67                // Insert before the closing `---` delimiter.
68                // `line` points to the closing `---`. Insert at that position
69                // (pushing `---` down).
70                let idx = p.line.saturating_sub(1); // 0-indexed
71                if idx <= lines.len() {
72                    lines.insert(idx, insert_text);
73                }
74                continue;
75            }
76            WriteBackKind::InlineEvent | WriteBackKind::Relationship => {
77                // Insert after the heading/relationship line.
78                p.line // 1-indexed line; inserting after means index = line (0-indexed + 1)
79            }
80        };
81
82        if insert_after <= lines.len() {
83            lines.insert(insert_after, insert_text);
84        }
85    }
86
87    let mut result = lines.join("\n");
88    if trailing_newline {
89        result.push('\n');
90    }
91    Some(result)
92}
93
94/// Write modified content back to the file at `path`.
95pub fn write_file(path: &Path, content: &str) -> Result<(), String> {
96    std::fs::write(path, content)
97        .map_err(|e| format!("{}: error writing file: {e}", path.display()))
98}
99
100/// Find the line number of the closing `---` in YAML front matter.
101/// Returns None if the file has no front matter or the closing delimiter
102/// is not found.
103pub fn find_front_matter_end(content: &str) -> Option<usize> {
104    let mut in_front_matter = false;
105    for (i, line) in content.lines().enumerate() {
106        let trimmed = line.trim();
107        if trimmed == "---" && !in_front_matter {
108            in_front_matter = true;
109        } else if trimmed == "---" && in_front_matter {
110            return Some(i + 1); // 1-indexed
111        }
112    }
113    None
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn writeback_entity_front_matter() {
122        let content = "---\n---\n\n# Mark Bonnick\n\n- nationality: British\n";
123        let end_line = find_front_matter_end(content).unwrap();
124        assert_eq!(end_line, 2);
125
126        let mut pending = vec![PendingId {
127            line: end_line,
128            id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
129            kind: WriteBackKind::EntityFrontMatter,
130        }];
131
132        let result = apply_writebacks(content, &mut pending).unwrap();
133        assert!(result.contains("id: 01JXYZ123456789ABCDEFGHIJK"));
134        // id should appear between the two ---
135        let lines: Vec<&str> = result.lines().collect();
136        assert_eq!(lines[0], "---");
137        assert_eq!(lines[1], "id: 01JXYZ123456789ABCDEFGHIJK");
138        assert_eq!(lines[2], "---");
139    }
140
141    #[test]
142    fn writeback_entity_front_matter_with_existing_fields() {
143        let content = "---\nother: value\n---\n\n# Test\n";
144        let end_line = find_front_matter_end(content).unwrap();
145        assert_eq!(end_line, 3);
146
147        let mut pending = vec![PendingId {
148            line: end_line,
149            id: "01JABC000000000000000000AA".to_string(),
150            kind: WriteBackKind::EntityFrontMatter,
151        }];
152
153        let result = apply_writebacks(content, &mut pending).unwrap();
154        let lines: Vec<&str> = result.lines().collect();
155        assert_eq!(lines[0], "---");
156        assert_eq!(lines[1], "other: value");
157        assert_eq!(lines[2], "id: 01JABC000000000000000000AA");
158        assert_eq!(lines[3], "---");
159    }
160
161    #[test]
162    fn writeback_inline_event() {
163        let content = "\
164## Events
165
166### Dismissal
167- occurred_at: 2024-12-24
168- event_type: termination
169";
170        // H3 heading is on line 3 (1-indexed)
171        let mut pending = vec![PendingId {
172            line: 3,
173            id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
174            kind: WriteBackKind::InlineEvent,
175        }];
176
177        let result = apply_writebacks(content, &mut pending).unwrap();
178        let lines: Vec<&str> = result.lines().collect();
179        assert_eq!(lines[2], "### Dismissal");
180        assert_eq!(lines[3], "- id: 01JXYZ123456789ABCDEFGHIJK");
181        assert_eq!(lines[4], "- occurred_at: 2024-12-24");
182    }
183
184    #[test]
185    fn writeback_relationship() {
186        let content = "\
187## Relationships
188
189- Alice -> Bob: employed_by
190  - source: https://example.com
191";
192        // Relationship line is on line 3 (1-indexed)
193        let mut pending = vec![PendingId {
194            line: 3,
195            id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
196            kind: WriteBackKind::Relationship,
197        }];
198
199        let result = apply_writebacks(content, &mut pending).unwrap();
200        let lines: Vec<&str> = result.lines().collect();
201        assert_eq!(lines[2], "- Alice -> Bob: employed_by");
202        assert_eq!(lines[3], "  - id: 01JXYZ123456789ABCDEFGHIJK");
203        assert_eq!(lines[4], "  - source: https://example.com");
204    }
205
206    #[test]
207    fn writeback_multiple_insertions() {
208        let content = "\
209## Events
210
211### Event A
212- occurred_at: 2024-01-01
213
214### Event B
215- occurred_at: 2024-06-01
216
217## Relationships
218
219- Event A -> Event B: associate_of
220";
221        let mut pending = vec![
222            PendingId {
223                line: 3,
224                id: "01JAAA000000000000000000AA".to_string(),
225                kind: WriteBackKind::InlineEvent,
226            },
227            PendingId {
228                line: 6,
229                id: "01JBBB000000000000000000BB".to_string(),
230                kind: WriteBackKind::InlineEvent,
231            },
232            PendingId {
233                line: 10,
234                id: "01JCCC000000000000000000CC".to_string(),
235                kind: WriteBackKind::Relationship,
236            },
237        ];
238
239        let result = apply_writebacks(content, &mut pending).unwrap();
240        assert!(result.contains("- id: 01JAAA000000000000000000AA"));
241        assert!(result.contains("- id: 01JBBB000000000000000000BB"));
242        assert!(result.contains("  - id: 01JCCC000000000000000000CC"));
243    }
244
245    #[test]
246    fn writeback_empty_pending() {
247        let content = "some content\n";
248        let mut pending = Vec::new();
249        assert!(apply_writebacks(content, &mut pending).is_none());
250    }
251
252    #[test]
253    fn writeback_preserves_trailing_newline() {
254        let content = "---\n---\n\n# Test\n";
255        let mut pending = vec![PendingId {
256            line: 2,
257            id: "01JABC000000000000000000AA".to_string(),
258            kind: WriteBackKind::EntityFrontMatter,
259        }];
260        let result = apply_writebacks(content, &mut pending).unwrap();
261        assert!(result.ends_with('\n'));
262    }
263
264    #[test]
265    fn writeback_case_id() {
266        let content = "---\nsources:\n  - https://example.com\n---\n\n# Some Case\n";
267        let end_line = find_front_matter_end(content).unwrap();
268        assert_eq!(end_line, 4);
269
270        let mut pending = vec![PendingId {
271            line: end_line,
272            id: "01JXYZ123456789ABCDEFGHIJK".to_string(),
273            kind: WriteBackKind::CaseId,
274        }];
275
276        let result = apply_writebacks(content, &mut pending).unwrap();
277        let lines: Vec<&str> = result.lines().collect();
278        assert_eq!(lines[0], "---");
279        assert_eq!(lines[3], "id: 01JXYZ123456789ABCDEFGHIJK");
280        assert_eq!(lines[4], "---");
281        assert!(!result.contains("\nnulid:"));
282    }
283
284    #[test]
285    fn find_front_matter_end_basic() {
286        assert_eq!(find_front_matter_end("---\nid: foo\n---\n"), Some(3));
287        assert_eq!(find_front_matter_end("---\n---\n"), Some(2));
288        assert_eq!(find_front_matter_end("no front matter"), None);
289        assert_eq!(find_front_matter_end("---\nunclosed"), None);
290    }
291}