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