tuitbot_core/automation/watchtower/
loopback.rs1use std::io;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12#[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#[derive(Debug, Default, Serialize, Deserialize)]
24struct TuitbotFrontMatter {
25 #[serde(default)]
26 tuitbot: Vec<LoopBackEntry>,
27 #[serde(flatten)]
28 other: serde_yaml::Mapping,
29}
30
31pub fn split_front_matter(content: &str) -> (Option<&str>, &str) {
36 if !content.starts_with("---") {
37 return (None, content);
38 }
39
40 let after_open = &content[3..];
42 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; let body = &after_open[rest_start..];
51 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 (None, content)
59 }
60}
61
62pub 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
76pub fn write_metadata_to_file(path: &Path, entry: &LoopBackEntry) -> Result<bool, io::Error> {
83 let content = std::fs::read_to_string(path)?;
84
85 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 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 let yaml_out = serde_yaml::to_string(&fm).map_err(io::Error::other)?;
103
104 let mut output = String::with_capacity(yaml_out.len() + body.len() + 10);
106 output.push_str("---\n");
107 output.push_str(&yaml_out);
108 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 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}