tuitbot_core/automation/watchtower/
loopback.rs1use std::io;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12use crate::storage::DbPool;
13
14#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub status: Option<String>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub thread_url: Option<String>,
28}
29
30#[derive(Debug, PartialEq, Eq)]
32pub enum LoopBackResult {
33 Written,
35 AlreadyPresent,
37 SourceNotWritable(String),
39 NodeNotFound,
41 FileNotFound,
43}
44
45pub 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 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 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 if source.source_type != "local_fs" {
81 return LoopBackResult::SourceNotWritable(source.source_type);
82 }
83
84 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 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#[derive(Debug, Default, Serialize, Deserialize)]
127struct TuitbotFrontMatter {
128 #[serde(default)]
129 tuitbot: Vec<LoopBackEntry>,
130 #[serde(flatten)]
131 other: serde_yaml::Mapping,
132}
133
134pub fn split_front_matter(content: &str) -> (Option<&str>, &str) {
139 if !content.starts_with("---") {
140 return (None, content);
141 }
142
143 let after_open = &content[3..];
145 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; let body = &after_open[rest_start..];
154 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 (None, content)
162 }
163}
164
165pub 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
179pub fn write_metadata_to_file(path: &Path, entry: &LoopBackEntry) -> Result<bool, io::Error> {
186 let content = std::fs::read_to_string(path)?;
187
188 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 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 let yaml_out = serde_yaml::to_string(&fm).map_err(io::Error::other)?;
206
207 let mut output = String::with_capacity(yaml_out.len() + body.len() + 10);
209 output.push_str("---\n");
210 output.push_str(&yaml_out);
211 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 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}