Skip to main content

tuitbot_core/automation/watchtower/
loopback.rs

1//! Loop-back metadata writing for Watchtower.
2//!
3//! When content from a source file is published (e.g. as a tweet),
4//! this module writes the published metadata back into the originating
5//! note's YAML front-matter in an idempotent, parseable format.
6
7use std::io;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12/// Metadata about a published piece of content, written back to the source file.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct LoopBackEntry {
15    pub tweet_id: String,
16    pub url: String,
17    pub published_at: String,
18    #[serde(rename = "type")]
19    pub content_type: String,
20}
21
22/// Parsed YAML front-matter with a `tuitbot` key.
23#[derive(Debug, Default, Serialize, Deserialize)]
24struct TuitbotFrontMatter {
25    #[serde(default)]
26    tuitbot: Vec<LoopBackEntry>,
27    #[serde(flatten)]
28    other: serde_yaml::Mapping,
29}
30
31/// Split a file's content into optional YAML front-matter and body.
32///
33/// Front-matter is delimited by `---` on its own line at the very start.
34/// Returns `(Some(yaml_str), body)` if present, or `(None, full_content)`.
35pub fn split_front_matter(content: &str) -> (Option<&str>, &str) {
36    if !content.starts_with("---") {
37        return (None, content);
38    }
39
40    // Find the closing `---` after the opening one.
41    let after_open = &content[3..];
42    // Skip the newline after opening ---
43    let after_open = after_open
44        .strip_prefix('\n')
45        .unwrap_or(after_open.strip_prefix("\r\n").unwrap_or(after_open));
46
47    if let Some(close_pos) = after_open.find("\n---") {
48        let yaml = &after_open[..close_pos];
49        let rest_start = close_pos + 4; // "\n---".len()
50        let body = &after_open[rest_start..];
51        // Strip the newline immediately after the closing ---
52        let body = body
53            .strip_prefix('\n')
54            .unwrap_or(body.strip_prefix("\r\n").unwrap_or(body));
55        (Some(yaml), body)
56    } else {
57        // No closing delimiter — treat entire content as body.
58        (None, content)
59    }
60}
61
62/// Parse existing tuitbot loop-back entries from file content.
63pub fn parse_tuitbot_metadata(content: &str) -> Vec<LoopBackEntry> {
64    let (yaml_str, _) = split_front_matter(content);
65    let yaml_str = match yaml_str {
66        Some(y) => y,
67        None => return Vec::new(),
68    };
69
70    match serde_yaml::from_str::<TuitbotFrontMatter>(yaml_str) {
71        Ok(fm) => fm.tuitbot,
72        Err(_) => Vec::new(),
73    }
74}
75
76/// Write published metadata back to a source file, idempotently.
77///
78/// If the `tweet_id` already exists in the file's `tuitbot` front-matter
79/// array, the write is skipped. Otherwise the entry is appended.
80///
81/// Returns `true` if the file was modified, `false` if skipped.
82pub fn write_metadata_to_file(path: &Path, entry: &LoopBackEntry) -> Result<bool, io::Error> {
83    let content = std::fs::read_to_string(path)?;
84
85    // Check if this tweet_id already exists.
86    let existing = parse_tuitbot_metadata(&content);
87    if existing.iter().any(|e| e.tweet_id == entry.tweet_id) {
88        return Ok(false);
89    }
90
91    let (yaml_str, body) = split_front_matter(&content);
92
93    // Parse or create front-matter.
94    let mut fm: TuitbotFrontMatter = match yaml_str {
95        Some(y) => serde_yaml::from_str(y).unwrap_or_default(),
96        None => TuitbotFrontMatter::default(),
97    };
98
99    fm.tuitbot.push(entry.clone());
100
101    // Serialize the front-matter.
102    let yaml_out = serde_yaml::to_string(&fm).map_err(io::Error::other)?;
103
104    // Reconstruct the file: --- + yaml + --- + body.
105    let mut output = String::with_capacity(yaml_out.len() + body.len() + 10);
106    output.push_str("---\n");
107    output.push_str(&yaml_out);
108    // serde_yaml already adds trailing newline, but ensure --- is on its own line.
109    if !yaml_out.ends_with('\n') {
110        output.push('\n');
111    }
112    output.push_str("---\n");
113    output.push_str(body);
114
115    std::fs::write(path, output)?;
116    Ok(true)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::fs;
123
124    fn sample_entry() -> LoopBackEntry {
125        LoopBackEntry {
126            tweet_id: "1234567890".to_string(),
127            url: "https://x.com/user/status/1234567890".to_string(),
128            published_at: "2026-02-28T14:30:00Z".to_string(),
129            content_type: "tweet".to_string(),
130        }
131    }
132
133    #[test]
134    fn split_no_front_matter() {
135        let content = "Just a plain note.\n";
136        let (yaml, body) = split_front_matter(content);
137        assert!(yaml.is_none());
138        assert_eq!(body, content);
139    }
140
141    #[test]
142    fn split_with_front_matter() {
143        let content = "---\ntitle: Hello\n---\nBody text here.\n";
144        let (yaml, body) = split_front_matter(content);
145        assert_eq!(yaml.unwrap(), "title: Hello");
146        assert_eq!(body, "Body text here.\n");
147    }
148
149    #[test]
150    fn split_no_closing_delimiter() {
151        let content = "---\ntitle: Hello\nNo closing.\n";
152        let (yaml, body) = split_front_matter(content);
153        assert!(yaml.is_none());
154        assert_eq!(body, content);
155    }
156
157    #[test]
158    fn parse_tuitbot_entries() {
159        let content = "---\ntuitbot:\n  - tweet_id: \"123\"\n    url: \"https://x.com/u/status/123\"\n    published_at: \"2026-01-01T00:00:00Z\"\n    type: tweet\n---\nBody.\n";
160        let entries = parse_tuitbot_metadata(content);
161        assert_eq!(entries.len(), 1);
162        assert_eq!(entries[0].tweet_id, "123");
163    }
164
165    #[test]
166    fn parse_no_tuitbot_key() {
167        let content = "---\ntitle: Hello\n---\nBody.\n";
168        let entries = parse_tuitbot_metadata(content);
169        assert!(entries.is_empty());
170    }
171
172    #[test]
173    fn loopback_write_new_file() {
174        let dir = tempfile::tempdir().unwrap();
175        let path = dir.path().join("note.md");
176        fs::write(&path, "This is my note.\n").unwrap();
177
178        let entry = sample_entry();
179        let modified = write_metadata_to_file(&path, &entry).unwrap();
180        assert!(modified);
181
182        let content = fs::read_to_string(&path).unwrap();
183        assert!(content.starts_with("---\n"));
184        assert!(content.contains("tweet_id"));
185        assert!(content.contains("1234567890"));
186        assert!(content.contains("This is my note."));
187    }
188
189    #[test]
190    fn loopback_write_existing_frontmatter() {
191        let dir = tempfile::tempdir().unwrap();
192        let path = dir.path().join("note.md");
193        fs::write(&path, "---\ntitle: My Note\n---\nBody here.\n").unwrap();
194
195        let entry = sample_entry();
196        let modified = write_metadata_to_file(&path, &entry).unwrap();
197        assert!(modified);
198
199        let content = fs::read_to_string(&path).unwrap();
200        assert!(content.contains("title"));
201        assert!(content.contains("My Note"));
202        assert!(content.contains("tweet_id"));
203        assert!(content.contains("Body here."));
204    }
205
206    #[test]
207    fn loopback_idempotent() {
208        let dir = tempfile::tempdir().unwrap();
209        let path = dir.path().join("note.md");
210        fs::write(&path, "My note.\n").unwrap();
211
212        let entry = sample_entry();
213        let first = write_metadata_to_file(&path, &entry).unwrap();
214        assert!(first);
215
216        let second = write_metadata_to_file(&path, &entry).unwrap();
217        assert!(!second);
218
219        // Verify only one entry exists.
220        let content = fs::read_to_string(&path).unwrap();
221        let entries = parse_tuitbot_metadata(&content);
222        assert_eq!(entries.len(), 1);
223    }
224
225    #[test]
226    fn loopback_multiple_tweets() {
227        let dir = tempfile::tempdir().unwrap();
228        let path = dir.path().join("note.md");
229        fs::write(&path, "My note.\n").unwrap();
230
231        let entry_a = sample_entry();
232        write_metadata_to_file(&path, &entry_a).unwrap();
233
234        let entry_b = LoopBackEntry {
235            tweet_id: "9876543210".to_string(),
236            url: "https://x.com/user/status/9876543210".to_string(),
237            published_at: "2026-03-01T10:00:00Z".to_string(),
238            content_type: "thread".to_string(),
239        };
240        write_metadata_to_file(&path, &entry_b).unwrap();
241
242        let content = fs::read_to_string(&path).unwrap();
243        let entries = parse_tuitbot_metadata(&content);
244        assert_eq!(entries.len(), 2);
245        assert_eq!(entries[0].tweet_id, "1234567890");
246        assert_eq!(entries[1].tweet_id, "9876543210");
247    }
248}