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 "capture" operation.
139    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    /// Log a "rename" operation.
157    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    /// Log a "focus" operation.
175    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(), // Focus has no path
185        )
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    /// Relativize a path to the vault root.
197    fn relativize(&self, path: &Path) -> PathBuf {
198        path.strip_prefix(&self.vault_root).unwrap_or(path).to_path_buf()
199    }
200
201    /// Perform log rotation if needed.
202    /// Should be called at startup or periodically.
203    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    /// Read entries within a date range (for querying).
212    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                // Filter by date range
233                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    /// Get the path to the log file.
251    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        // This should not create any file
291        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        // Log several entries
319        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}