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_cancel(
140 &self,
141 note_type: &str,
142 id: &str,
143 path: &Path,
144 reason: Option<&str>,
145 ) -> Result<()> {
146 let rel_path = self.relativize(path);
147 let mut entry =
148 ActivityEntry::new(Operation::Cancel, note_type, rel_path).with_id(id);
149
150 if let Some(r) = reason {
151 entry = entry.with_meta("reason", r);
152 }
153
154 self.log(entry)
155 }
156
157 pub fn log_capture(
159 &self,
160 capture_name: &str,
161 target_path: &Path,
162 section: Option<&str>,
163 ) -> Result<()> {
164 let rel_path = self.relativize(target_path);
165 let mut entry = ActivityEntry::new(Operation::Capture, "capture", rel_path)
166 .with_meta("capture_name", capture_name);
167
168 if let Some(s) = section {
169 entry = entry.with_meta("section", s);
170 }
171
172 self.log(entry)
173 }
174
175 pub fn log_rename(
177 &self,
178 note_type: &str,
179 old_path: &Path,
180 new_path: &Path,
181 references_updated: usize,
182 ) -> Result<()> {
183 let rel_new = self.relativize(new_path);
184 let rel_old = self.relativize(old_path);
185
186 let entry = ActivityEntry::new(Operation::Rename, note_type, rel_new)
187 .with_meta("old_path", rel_old.to_string_lossy())
188 .with_meta("references_updated", references_updated);
189
190 self.log(entry)
191 }
192
193 pub fn log_focus(
195 &self,
196 project: &str,
197 note: Option<&str>,
198 action: &str,
199 ) -> Result<()> {
200 let mut entry = ActivityEntry::new(
201 Operation::Focus,
202 "focus",
203 PathBuf::new(), )
205 .with_meta("project", project)
206 .with_meta("action", action);
207
208 if let Some(n) = note {
209 entry = entry.with_meta("note", n);
210 }
211
212 self.log(entry)
213 }
214
215 fn relativize(&self, path: &Path) -> PathBuf {
217 path.strip_prefix(&self.vault_root).unwrap_or(path).to_path_buf()
218 }
219
220 pub fn rotate_if_needed(&self) -> Result<()> {
223 super::rotation::rotate_log(
224 &self.log_path,
225 &self.vault_root.join(Self::ARCHIVE_DIR),
226 self.config.retention_days,
227 )
228 }
229
230 pub fn read_entries(
232 &self,
233 since: Option<DateTime<Utc>>,
234 until: Option<DateTime<Utc>>,
235 ) -> Result<Vec<ActivityEntry>> {
236 if !self.log_path.exists() {
237 return Ok(Vec::new());
238 }
239
240 let file = File::open(&self.log_path)?;
241 let reader = BufReader::new(file);
242 let mut entries = Vec::new();
243
244 for line in reader.lines() {
245 let line = line?;
246 if line.trim().is_empty() {
247 continue;
248 }
249
250 if let Ok(entry) = serde_json::from_str::<ActivityEntry>(&line) {
251 if let Some(s) = since
253 && entry.ts < s
254 {
255 continue;
256 }
257 if let Some(u) = until
258 && entry.ts > u
259 {
260 continue;
261 }
262 entries.push(entry);
263 }
264 }
265
266 Ok(entries)
267 }
268
269 pub fn log_path(&self) -> &Path {
271 &self.log_path
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use tempfile::tempdir;
279
280 fn make_test_config(enabled: bool) -> ActivityConfig {
281 ActivityConfig { enabled, retention_days: 90, log_operations: vec![] }
282 }
283
284 #[test]
285 fn test_log_new_creates_entry() {
286 let tmp = tempdir().unwrap();
287 let service = ActivityLogService::new(tmp.path(), make_test_config(true));
288
289 service
290 .log_new(
291 "task",
292 "TST-001",
293 &tmp.path().join("tasks/TST-001.md"),
294 Some("Test"),
295 )
296 .unwrap();
297
298 let content = fs::read_to_string(service.log_path()).unwrap();
299 assert!(content.contains(r#""op":"new""#));
300 assert!(content.contains(r#""type":"task""#));
301 assert!(content.contains(r#""id":"TST-001""#));
302 }
303
304 #[test]
305 fn test_log_disabled_does_nothing() {
306 let tmp = tempdir().unwrap();
307 let service = ActivityLogService::new(tmp.path(), make_test_config(false));
308
309 service
311 .log_new("task", "TST-001", &tmp.path().join("tasks/TST-001.md"), None)
312 .unwrap();
313
314 assert!(!service.log_path().exists());
315 }
316
317 #[test]
318 fn test_should_log_respects_operations_filter() {
319 let config = ActivityConfig {
320 enabled: true,
321 retention_days: 90,
322 log_operations: vec!["new".into()],
323 };
324 let tmp = tempdir().unwrap();
325 let service = ActivityLogService::new(tmp.path(), config);
326
327 assert!(service.should_log(Operation::New));
328 assert!(!service.should_log(Operation::Complete));
329 assert!(!service.should_log(Operation::Focus));
330 }
331
332 #[test]
333 fn test_read_entries() {
334 let tmp = tempdir().unwrap();
335 let service = ActivityLogService::new(tmp.path(), make_test_config(true));
336
337 service
339 .log_new("task", "TST-001", &tmp.path().join("tasks/TST-001.md"), None)
340 .unwrap();
341 service
342 .log_complete("task", "TST-001", &tmp.path().join("tasks/TST-001.md"), None)
343 .unwrap();
344
345 let entries = service.read_entries(None, None).unwrap();
346 assert_eq!(entries.len(), 2);
347 assert_eq!(entries[0].op, Operation::New);
348 assert_eq!(entries[1].op, Operation::Complete);
349 }
350
351 #[test]
352 fn test_relativize_path() {
353 let tmp = tempdir().unwrap();
354 let service = ActivityLogService::new(tmp.path(), make_test_config(true));
355
356 let abs_path = tmp.path().join("tasks/TST-001.md");
357 let rel_path = service.relativize(&abs_path);
358 assert_eq!(rel_path, PathBuf::from("tasks/TST-001.md"));
359 }
360}