1use std::fs::{self, File, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use chrono::{DateTime, Utc};
8use thiserror::Error;
9
10use crate::config::types::{ActivityConfig, ResolvedConfig};
11
12use super::types::{ActivityEntry, Operation};
13
14#[derive(Debug, Error)]
16pub enum ActivityError {
17 #[error("Failed to write activity log: {0}")]
18 WriteError(#[from] std::io::Error),
19
20 #[error("Failed to serialize entry: {0}")]
21 SerializeError(#[from] serde_json::Error),
22
23 #[error("Activity logging is disabled")]
24 Disabled,
25}
26
27type Result<T> = std::result::Result<T, ActivityError>;
28
29pub struct ActivityLogService {
31 log_path: PathBuf,
33
34 config: ActivityConfig,
36
37 vault_root: PathBuf,
39}
40
41impl ActivityLogService {
42 const LOG_FILE: &'static str = ".mdvault/activity.jsonl";
43 const ARCHIVE_DIR: &'static str = ".mdvault/activity_archive";
44
45 pub fn new(vault_root: &Path, config: ActivityConfig) -> Self {
47 let log_path = vault_root.join(Self::LOG_FILE);
48 Self { log_path, config, vault_root: vault_root.to_path_buf() }
49 }
50
51 pub fn try_from_config(config: &ResolvedConfig) -> Option<Self> {
54 if config.activity.enabled {
55 Some(Self::new(&config.vault_root, config.activity.clone()))
56 } else {
57 None
58 }
59 }
60
61 pub fn is_enabled(&self) -> bool {
63 self.config.enabled
64 }
65
66 pub fn should_log(&self, op: Operation) -> bool {
68 if !self.config.enabled {
69 return false;
70 }
71 if self.config.log_operations.is_empty() {
73 return true;
74 }
75 self.config.log_operations.contains(&op.to_string())
76 }
77
78 pub fn log(&self, entry: ActivityEntry) -> Result<()> {
80 if !self.should_log(entry.op) {
81 return Ok(()); }
83
84 if let Some(parent) = self.log_path.parent() {
86 fs::create_dir_all(parent)?;
87 }
88
89 let json = serde_json::to_string(&entry)?;
91
92 let mut file =
94 OpenOptions::new().create(true).append(true).open(&self.log_path)?;
95
96 writeln!(file, "{}", json)?;
97 Ok(())
98 }
99
100 pub fn log_new(
102 &self,
103 note_type: &str,
104 id: &str,
105 path: &Path,
106 title: Option<&str>,
107 ) -> Result<()> {
108 let rel_path = self.relativize(path);
109 let mut entry =
110 ActivityEntry::new(Operation::New, note_type, rel_path).with_id(id);
111
112 if let Some(t) = title {
113 entry = entry.with_meta("title", t);
114 }
115
116 self.log(entry)
117 }
118
119 pub fn log_complete(
121 &self,
122 note_type: &str,
123 id: &str,
124 path: &Path,
125 summary: Option<&str>,
126 ) -> Result<()> {
127 let rel_path = self.relativize(path);
128 let mut entry =
129 ActivityEntry::new(Operation::Complete, note_type, rel_path).with_id(id);
130
131 if let Some(s) = summary {
132 entry = entry.with_meta("summary", s);
133 }
134
135 self.log(entry)
136 }
137
138 pub fn log_capture(
140 &self,
141 capture_name: &str,
142 target_path: &Path,
143 section: Option<&str>,
144 ) -> Result<()> {
145 let rel_path = self.relativize(target_path);
146 let mut entry = ActivityEntry::new(Operation::Capture, "capture", rel_path)
147 .with_meta("capture_name", capture_name);
148
149 if let Some(s) = section {
150 entry = entry.with_meta("section", s);
151 }
152
153 self.log(entry)
154 }
155
156 pub fn log_rename(
158 &self,
159 note_type: &str,
160 old_path: &Path,
161 new_path: &Path,
162 references_updated: usize,
163 ) -> Result<()> {
164 let rel_new = self.relativize(new_path);
165 let rel_old = self.relativize(old_path);
166
167 let entry = ActivityEntry::new(Operation::Rename, note_type, rel_new)
168 .with_meta("old_path", rel_old.to_string_lossy())
169 .with_meta("references_updated", references_updated);
170
171 self.log(entry)
172 }
173
174 pub fn log_focus(
176 &self,
177 project: &str,
178 note: Option<&str>,
179 action: &str,
180 ) -> Result<()> {
181 let mut entry = ActivityEntry::new(
182 Operation::Focus,
183 "focus",
184 PathBuf::new(), )
186 .with_meta("project", project)
187 .with_meta("action", action);
188
189 if let Some(n) = note {
190 entry = entry.with_meta("note", n);
191 }
192
193 self.log(entry)
194 }
195
196 fn relativize(&self, path: &Path) -> PathBuf {
198 path.strip_prefix(&self.vault_root).unwrap_or(path).to_path_buf()
199 }
200
201 pub fn rotate_if_needed(&self) -> Result<()> {
204 super::rotation::rotate_log(
205 &self.log_path,
206 &self.vault_root.join(Self::ARCHIVE_DIR),
207 self.config.retention_days,
208 )
209 }
210
211 pub fn read_entries(
213 &self,
214 since: Option<DateTime<Utc>>,
215 until: Option<DateTime<Utc>>,
216 ) -> Result<Vec<ActivityEntry>> {
217 if !self.log_path.exists() {
218 return Ok(Vec::new());
219 }
220
221 let file = File::open(&self.log_path)?;
222 let reader = BufReader::new(file);
223 let mut entries = Vec::new();
224
225 for line in reader.lines() {
226 let line = line?;
227 if line.trim().is_empty() {
228 continue;
229 }
230
231 if let Ok(entry) = serde_json::from_str::<ActivityEntry>(&line) {
232 if let Some(s) = since
234 && entry.ts < s
235 {
236 continue;
237 }
238 if let Some(u) = until
239 && entry.ts > u
240 {
241 continue;
242 }
243 entries.push(entry);
244 }
245 }
246
247 Ok(entries)
248 }
249
250 pub fn log_path(&self) -> &Path {
252 &self.log_path
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use tempfile::tempdir;
260
261 fn make_test_config(enabled: bool) -> ActivityConfig {
262 ActivityConfig { enabled, retention_days: 90, log_operations: vec![] }
263 }
264
265 #[test]
266 fn test_log_new_creates_entry() {
267 let tmp = tempdir().unwrap();
268 let service = ActivityLogService::new(tmp.path(), make_test_config(true));
269
270 service
271 .log_new(
272 "task",
273 "TST-001",
274 &tmp.path().join("tasks/TST-001.md"),
275 Some("Test"),
276 )
277 .unwrap();
278
279 let content = fs::read_to_string(service.log_path()).unwrap();
280 assert!(content.contains(r#""op":"new""#));
281 assert!(content.contains(r#""type":"task""#));
282 assert!(content.contains(r#""id":"TST-001""#));
283 }
284
285 #[test]
286 fn test_log_disabled_does_nothing() {
287 let tmp = tempdir().unwrap();
288 let service = ActivityLogService::new(tmp.path(), make_test_config(false));
289
290 service
292 .log_new("task", "TST-001", &tmp.path().join("tasks/TST-001.md"), None)
293 .unwrap();
294
295 assert!(!service.log_path().exists());
296 }
297
298 #[test]
299 fn test_should_log_respects_operations_filter() {
300 let config = ActivityConfig {
301 enabled: true,
302 retention_days: 90,
303 log_operations: vec!["new".into()],
304 };
305 let tmp = tempdir().unwrap();
306 let service = ActivityLogService::new(tmp.path(), config);
307
308 assert!(service.should_log(Operation::New));
309 assert!(!service.should_log(Operation::Complete));
310 assert!(!service.should_log(Operation::Focus));
311 }
312
313 #[test]
314 fn test_read_entries() {
315 let tmp = tempdir().unwrap();
316 let service = ActivityLogService::new(tmp.path(), make_test_config(true));
317
318 service
320 .log_new("task", "TST-001", &tmp.path().join("tasks/TST-001.md"), None)
321 .unwrap();
322 service
323 .log_complete("task", "TST-001", &tmp.path().join("tasks/TST-001.md"), None)
324 .unwrap();
325
326 let entries = service.read_entries(None, None).unwrap();
327 assert_eq!(entries.len(), 2);
328 assert_eq!(entries[0].op, Operation::New);
329 assert_eq!(entries[1].op, Operation::Complete);
330 }
331
332 #[test]
333 fn test_relativize_path() {
334 let tmp = tempdir().unwrap();
335 let service = ActivityLogService::new(tmp.path(), make_test_config(true));
336
337 let abs_path = tmp.path().join("tasks/TST-001.md");
338 let rel_path = service.relativize(&abs_path);
339 assert_eq!(rel_path, PathBuf::from("tasks/TST-001.md"));
340 }
341}