Skip to main content

mdvault_core/activity/
service.rs

1//! Activity log service implementation.
2
3use 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/// Error type for activity logging.
15#[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
29/// Service for logging vault activities to JSONL file.
30pub struct ActivityLogService {
31    /// Path to the activity log file
32    log_path: PathBuf,
33
34    /// Configuration
35    config: ActivityConfig,
36
37    /// Vault root for path relativization
38    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    /// Create a new ActivityLogService for the given vault.
46    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    /// Create from ResolvedConfig.
52    /// Returns None if activity logging is disabled.
53    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    /// Check if logging is enabled.
62    pub fn is_enabled(&self) -> bool {
63        self.config.enabled
64    }
65
66    /// Check if a specific operation should be logged.
67    pub fn should_log(&self, op: Operation) -> bool {
68        if !self.config.enabled {
69            return false;
70        }
71        // Empty log_operations means log all operations
72        if self.config.log_operations.is_empty() {
73            return true;
74        }
75        self.config.log_operations.contains(&op.to_string())
76    }
77
78    /// Log an activity entry.
79    pub fn log(&self, entry: ActivityEntry) -> Result<()> {
80        if !self.should_log(entry.op) {
81            return Ok(()); // Silently skip disabled operations
82        }
83
84        // Ensure parent directory exists
85        if let Some(parent) = self.log_path.parent() {
86            fs::create_dir_all(parent)?;
87        }
88
89        // Serialize to JSON line
90        let json = serde_json::to_string(&entry)?;
91
92        // Append to file
93        let mut file =
94            OpenOptions::new().create(true).append(true).open(&self.log_path)?;
95
96        writeln!(file, "{}", json)?;
97        Ok(())
98    }
99
100    /// Log a "new" operation (note creation).
101    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    /// Log a "complete" operation (task completed).
120    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    /// Log a "cancel" operation (task cancelled).
139    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    /// Log a "capture" operation.
158    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    /// Log a "rename" operation.
176    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    /// Log a "focus" operation.
194    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(), // Focus has no path
204        )
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    /// Relativize a path to the vault root.
216    fn relativize(&self, path: &Path) -> PathBuf {
217        path.strip_prefix(&self.vault_root).unwrap_or(path).to_path_buf()
218    }
219
220    /// Perform log rotation if needed.
221    /// Should be called at startup or periodically.
222    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    /// Read entries within a date range (for querying).
231    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                // Filter by date range
252                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    /// Get the path to the log file.
270    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        // This should not create any file
310        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        // Log several entries
338        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}