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
12use crate::storage::DbPool;
13
14/// Metadata about a published piece of content, written back to the source file.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct LoopBackEntry {
17    pub tweet_id: String,
18    pub url: String,
19    pub published_at: String,
20    #[serde(rename = "type")]
21    pub content_type: String,
22    /// Post status: "posted", "deleted", etc.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub status: Option<String>,
25    /// Thread URL when this entry is part of a thread.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub thread_url: Option<String>,
28}
29
30/// Result of an `execute_loopback()` call.
31#[derive(Debug, PartialEq, Eq)]
32pub enum LoopBackResult {
33    /// Metadata was written to the source file.
34    Written,
35    /// The tweet_id was already present in the file — no write needed.
36    AlreadyPresent,
37    /// The source type does not support writes (e.g. google_drive, manual).
38    SourceNotWritable(String),
39    /// The content node was not found in the database.
40    NodeNotFound,
41    /// The source file does not exist on disk.
42    FileNotFound,
43}
44
45/// Execute provenance-driven loop-back: look up the source note for a content
46/// node and write publishing metadata into its YAML front-matter.
47///
48/// Returns `LoopBackResult` indicating the outcome. DB lookup failures are
49/// logged and mapped to result variants rather than propagated as errors.
50pub async fn execute_loopback(
51    pool: &DbPool,
52    node_id: i64,
53    tweet_id: &str,
54    url: &str,
55    content_type: &str,
56) -> LoopBackResult {
57    use crate::storage::watchtower::{get_content_node, get_source_context};
58
59    // 1. Look up the content node.
60    let node = match get_content_node(pool, node_id).await {
61        Ok(Some(n)) => n,
62        Ok(None) => return LoopBackResult::NodeNotFound,
63        Err(e) => {
64            tracing::warn!(node_id, error = %e, "Loopback: failed to get content node");
65            return LoopBackResult::NodeNotFound;
66        }
67    };
68
69    // 2. Look up the source context.
70    let source = match get_source_context(pool, node.source_id).await {
71        Ok(Some(s)) => s,
72        Ok(None) => return LoopBackResult::SourceNotWritable("source not found".into()),
73        Err(e) => {
74            tracing::warn!(node_id, error = %e, "Loopback: failed to get source context");
75            return LoopBackResult::SourceNotWritable("db error".into());
76        }
77    };
78
79    // 3. Gate on source type.
80    if source.source_type != "local_fs" {
81        return LoopBackResult::SourceNotWritable(source.source_type);
82    }
83
84    // 4. Resolve the base path from config_json.
85    let base_path = match serde_json::from_str::<serde_json::Value>(&source.config_json)
86        .ok()
87        .and_then(|v| v.get("path")?.as_str().map(String::from))
88    {
89        Some(p) => p,
90        None => return LoopBackResult::SourceNotWritable("no path in config".into()),
91    };
92
93    let expanded = crate::storage::expand_tilde(&base_path);
94    let full_path = std::path::PathBuf::from(expanded).join(&node.relative_path);
95
96    if !full_path.exists() {
97        return LoopBackResult::FileNotFound;
98    }
99
100    // 5. Build entry and write.
101    let entry = LoopBackEntry {
102        tweet_id: tweet_id.to_string(),
103        url: url.to_string(),
104        published_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
105        content_type: content_type.to_string(),
106        status: Some("posted".to_string()),
107        thread_url: None,
108    };
109
110    match write_metadata_to_file(&full_path, &entry) {
111        Ok(true) => LoopBackResult::Written,
112        Ok(false) => LoopBackResult::AlreadyPresent,
113        Err(e) => {
114            tracing::warn!(
115                node_id,
116                path = %full_path.display(),
117                error = %e,
118                "Loopback file write failed"
119            );
120            LoopBackResult::FileNotFound
121        }
122    }
123}
124
125/// Parsed YAML front-matter with a `tuitbot` key.
126#[derive(Debug, Default, Serialize, Deserialize)]
127struct TuitbotFrontMatter {
128    #[serde(default)]
129    tuitbot: Vec<LoopBackEntry>,
130    #[serde(flatten)]
131    other: serde_yaml::Mapping,
132}
133
134/// Split a file's content into optional YAML front-matter and body.
135///
136/// Front-matter is delimited by `---` on its own line at the very start.
137/// Returns `(Some(yaml_str), body)` if present, or `(None, full_content)`.
138pub fn split_front_matter(content: &str) -> (Option<&str>, &str) {
139    if !content.starts_with("---") {
140        return (None, content);
141    }
142
143    // Find the closing `---` after the opening one.
144    let after_open = &content[3..];
145    // Skip the newline after opening ---
146    let after_open = after_open
147        .strip_prefix('\n')
148        .unwrap_or(after_open.strip_prefix("\r\n").unwrap_or(after_open));
149
150    if let Some(close_pos) = after_open.find("\n---") {
151        let yaml = &after_open[..close_pos];
152        let rest_start = close_pos + 4; // "\n---".len()
153        let body = &after_open[rest_start..];
154        // Strip the newline immediately after the closing ---
155        let body = body
156            .strip_prefix('\n')
157            .unwrap_or(body.strip_prefix("\r\n").unwrap_or(body));
158        (Some(yaml), body)
159    } else {
160        // No closing delimiter — treat entire content as body.
161        (None, content)
162    }
163}
164
165/// Parse existing tuitbot loop-back entries from file content.
166pub fn parse_tuitbot_metadata(content: &str) -> Vec<LoopBackEntry> {
167    let (yaml_str, _) = split_front_matter(content);
168    let yaml_str = match yaml_str {
169        Some(y) => y,
170        None => return Vec::new(),
171    };
172
173    match serde_yaml::from_str::<TuitbotFrontMatter>(yaml_str) {
174        Ok(fm) => fm.tuitbot,
175        Err(_) => Vec::new(),
176    }
177}
178
179/// Write published metadata back to a source file, idempotently.
180///
181/// If the `tweet_id` already exists in the file's `tuitbot` front-matter
182/// array, the write is skipped. Otherwise the entry is appended.
183///
184/// Returns `true` if the file was modified, `false` if skipped.
185pub fn write_metadata_to_file(path: &Path, entry: &LoopBackEntry) -> Result<bool, io::Error> {
186    let content = std::fs::read_to_string(path)?;
187
188    // Check if this tweet_id already exists.
189    let existing = parse_tuitbot_metadata(&content);
190    if existing.iter().any(|e| e.tweet_id == entry.tweet_id) {
191        return Ok(false);
192    }
193
194    let (yaml_str, body) = split_front_matter(&content);
195
196    // Parse or create front-matter.
197    let mut fm: TuitbotFrontMatter = match yaml_str {
198        Some(y) => serde_yaml::from_str(y).unwrap_or_default(),
199        None => TuitbotFrontMatter::default(),
200    };
201
202    fm.tuitbot.push(entry.clone());
203
204    // Serialize the front-matter.
205    let yaml_out = serde_yaml::to_string(&fm).map_err(io::Error::other)?;
206
207    // Reconstruct the file: --- + yaml + --- + body.
208    let mut output = String::with_capacity(yaml_out.len() + body.len() + 10);
209    output.push_str("---\n");
210    output.push_str(&yaml_out);
211    // serde_yaml already adds trailing newline, but ensure --- is on its own line.
212    if !yaml_out.ends_with('\n') {
213        output.push('\n');
214    }
215    output.push_str("---\n");
216    output.push_str(body);
217
218    std::fs::write(path, output)?;
219    Ok(true)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use std::fs;
226
227    fn sample_entry() -> LoopBackEntry {
228        LoopBackEntry {
229            tweet_id: "1234567890".to_string(),
230            url: "https://x.com/user/status/1234567890".to_string(),
231            published_at: "2026-02-28T14:30:00Z".to_string(),
232            content_type: "tweet".to_string(),
233            status: None,
234            thread_url: None,
235        }
236    }
237
238    #[test]
239    fn split_no_front_matter() {
240        let content = "Just a plain note.\n";
241        let (yaml, body) = split_front_matter(content);
242        assert!(yaml.is_none());
243        assert_eq!(body, content);
244    }
245
246    #[test]
247    fn split_with_front_matter() {
248        let content = "---\ntitle: Hello\n---\nBody text here.\n";
249        let (yaml, body) = split_front_matter(content);
250        assert_eq!(yaml.unwrap(), "title: Hello");
251        assert_eq!(body, "Body text here.\n");
252    }
253
254    #[test]
255    fn split_no_closing_delimiter() {
256        let content = "---\ntitle: Hello\nNo closing.\n";
257        let (yaml, body) = split_front_matter(content);
258        assert!(yaml.is_none());
259        assert_eq!(body, content);
260    }
261
262    #[test]
263    fn parse_tuitbot_entries() {
264        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";
265        let entries = parse_tuitbot_metadata(content);
266        assert_eq!(entries.len(), 1);
267        assert_eq!(entries[0].tweet_id, "123");
268    }
269
270    #[test]
271    fn parse_no_tuitbot_key() {
272        let content = "---\ntitle: Hello\n---\nBody.\n";
273        let entries = parse_tuitbot_metadata(content);
274        assert!(entries.is_empty());
275    }
276
277    #[test]
278    fn loopback_write_new_file() {
279        let dir = tempfile::tempdir().unwrap();
280        let path = dir.path().join("note.md");
281        fs::write(&path, "This is my note.\n").unwrap();
282
283        let entry = sample_entry();
284        let modified = write_metadata_to_file(&path, &entry).unwrap();
285        assert!(modified);
286
287        let content = fs::read_to_string(&path).unwrap();
288        assert!(content.starts_with("---\n"));
289        assert!(content.contains("tweet_id"));
290        assert!(content.contains("1234567890"));
291        assert!(content.contains("This is my note."));
292    }
293
294    #[test]
295    fn loopback_write_existing_frontmatter() {
296        let dir = tempfile::tempdir().unwrap();
297        let path = dir.path().join("note.md");
298        fs::write(&path, "---\ntitle: My Note\n---\nBody here.\n").unwrap();
299
300        let entry = sample_entry();
301        let modified = write_metadata_to_file(&path, &entry).unwrap();
302        assert!(modified);
303
304        let content = fs::read_to_string(&path).unwrap();
305        assert!(content.contains("title"));
306        assert!(content.contains("My Note"));
307        assert!(content.contains("tweet_id"));
308        assert!(content.contains("Body here."));
309    }
310
311    #[test]
312    fn loopback_idempotent() {
313        let dir = tempfile::tempdir().unwrap();
314        let path = dir.path().join("note.md");
315        fs::write(&path, "My note.\n").unwrap();
316
317        let entry = sample_entry();
318        let first = write_metadata_to_file(&path, &entry).unwrap();
319        assert!(first);
320
321        let second = write_metadata_to_file(&path, &entry).unwrap();
322        assert!(!second);
323
324        // Verify only one entry exists.
325        let content = fs::read_to_string(&path).unwrap();
326        let entries = parse_tuitbot_metadata(&content);
327        assert_eq!(entries.len(), 1);
328    }
329
330    #[test]
331    fn loopback_multiple_tweets() {
332        let dir = tempfile::tempdir().unwrap();
333        let path = dir.path().join("note.md");
334        fs::write(&path, "My note.\n").unwrap();
335
336        let entry_a = sample_entry();
337        write_metadata_to_file(&path, &entry_a).unwrap();
338
339        let entry_b = LoopBackEntry {
340            tweet_id: "9876543210".to_string(),
341            url: "https://x.com/user/status/9876543210".to_string(),
342            published_at: "2026-03-01T10:00:00Z".to_string(),
343            content_type: "thread".to_string(),
344            status: None,
345            thread_url: None,
346        };
347        write_metadata_to_file(&path, &entry_b).unwrap();
348
349        let content = fs::read_to_string(&path).unwrap();
350        let entries = parse_tuitbot_metadata(&content);
351        assert_eq!(entries.len(), 2);
352        assert_eq!(entries[0].tweet_id, "1234567890");
353        assert_eq!(entries[1].tweet_id, "9876543210");
354    }
355}