Skip to main content

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