use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use thiserror::Error;
use crate::config::types::{ActivityConfig, ResolvedConfig};
use crate::paths::PathResolver;
use super::types::{ActivityEntry, Operation};
#[derive(Debug, Error)]
pub enum ActivityError {
#[error("Failed to write activity log: {0}")]
WriteError(#[from] std::io::Error),
#[error("Failed to serialize entry: {0}")]
SerializeError(#[from] serde_json::Error),
#[error("Activity logging is disabled")]
Disabled,
}
type Result<T> = std::result::Result<T, ActivityError>;
pub struct ActivityLogService {
log_path: PathBuf,
config: ActivityConfig,
vault_root: PathBuf,
}
impl ActivityLogService {
pub fn new(vault_root: &Path, config: ActivityConfig) -> Self {
let resolver = PathResolver::new(vault_root);
let log_path = resolver.activity_log();
Self { log_path, config, vault_root: vault_root.to_path_buf() }
}
pub fn try_from_config(config: &ResolvedConfig) -> Option<Self> {
if config.activity.enabled {
Some(Self::new(&config.vault_root, config.activity.clone()))
} else {
None
}
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub fn should_log(&self, op: Operation) -> bool {
if !self.config.enabled {
return false;
}
if self.config.log_operations.is_empty() {
return true;
}
self.config.log_operations.contains(&op.to_string())
}
pub fn log(&self, entry: ActivityEntry) -> Result<()> {
if !self.should_log(entry.op) {
return Ok(()); }
if let Some(parent) = self.log_path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string(&entry)?;
let mut file =
OpenOptions::new().create(true).append(true).open(&self.log_path)?;
writeln!(file, "{}", json)?;
Ok(())
}
pub fn log_new(
&self,
note_type: &str,
id: &str,
path: &Path,
title: Option<&str>,
) -> Result<()> {
let rel_path = self.relativize(path);
let mut entry =
ActivityEntry::new(Operation::New, note_type, rel_path).with_id(id);
if let Some(t) = title {
entry = entry.with_meta("title", t);
}
self.log(entry)
}
pub fn log_complete(
&self,
note_type: &str,
id: &str,
path: &Path,
summary: Option<&str>,
) -> Result<()> {
let rel_path = self.relativize(path);
let mut entry =
ActivityEntry::new(Operation::Complete, note_type, rel_path).with_id(id);
if let Some(s) = summary {
entry = entry.with_meta("summary", s);
}
self.log(entry)
}
pub fn log_cancel(
&self,
note_type: &str,
id: &str,
path: &Path,
reason: Option<&str>,
) -> Result<()> {
let rel_path = self.relativize(path);
let mut entry =
ActivityEntry::new(Operation::Cancel, note_type, rel_path).with_id(id);
if let Some(r) = reason {
entry = entry.with_meta("reason", r);
}
self.log(entry)
}
pub fn log_capture(
&self,
capture_name: &str,
target_path: &Path,
section: Option<&str>,
) -> Result<()> {
let rel_path = self.relativize(target_path);
let mut entry = ActivityEntry::new(Operation::Capture, "capture", rel_path)
.with_meta("capture_name", capture_name);
if let Some(s) = section {
entry = entry.with_meta("section", s);
}
self.log(entry)
}
pub fn log_rename(
&self,
note_type: &str,
old_path: &Path,
new_path: &Path,
references_updated: usize,
) -> Result<()> {
let rel_new = self.relativize(new_path);
let rel_old = self.relativize(old_path);
let entry = ActivityEntry::new(Operation::Rename, note_type, rel_new)
.with_meta("old_path", rel_old.to_string_lossy())
.with_meta("references_updated", references_updated);
self.log(entry)
}
pub fn log_focus(
&self,
project: &str,
note: Option<&str>,
action: &str,
) -> Result<()> {
let mut entry = ActivityEntry::new(
Operation::Focus,
"focus",
PathBuf::new(), )
.with_meta("project", project)
.with_meta("action", action);
if let Some(n) = note {
entry = entry.with_meta("note", n);
}
self.log(entry)
}
fn relativize(&self, path: &Path) -> PathBuf {
path.strip_prefix(&self.vault_root).unwrap_or(path).to_path_buf()
}
pub fn rotate_if_needed(&self) -> Result<()> {
let archive_dir = PathResolver::new(&self.vault_root).activity_archive_dir();
super::rotation::rotate_log(
&self.log_path,
&archive_dir,
self.config.retention_days,
)
}
pub fn read_entries(
&self,
since: Option<DateTime<Utc>>,
until: Option<DateTime<Utc>>,
) -> Result<Vec<ActivityEntry>> {
if !self.log_path.exists() {
return Ok(Vec::new());
}
let file = File::open(&self.log_path)?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<ActivityEntry>(&line) {
if let Some(s) = since
&& entry.ts < s
{
continue;
}
if let Some(u) = until
&& entry.ts > u
{
continue;
}
entries.push(entry);
}
}
Ok(entries)
}
pub fn log_path(&self) -> &Path {
&self.log_path
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn make_test_config(enabled: bool) -> ActivityConfig {
ActivityConfig { enabled, retention_days: 90, log_operations: vec![] }
}
#[test]
fn test_log_new_creates_entry() {
let tmp = tempdir().unwrap();
let service = ActivityLogService::new(tmp.path(), make_test_config(true));
service
.log_new(
"task",
"TST-001",
&tmp.path().join("tasks/TST-001.md"),
Some("Test"),
)
.unwrap();
let content = fs::read_to_string(service.log_path()).unwrap();
assert!(content.contains(r#""op":"new""#));
assert!(content.contains(r#""type":"task""#));
assert!(content.contains(r#""id":"TST-001""#));
}
#[test]
fn test_log_disabled_does_nothing() {
let tmp = tempdir().unwrap();
let service = ActivityLogService::new(tmp.path(), make_test_config(false));
service
.log_new("task", "TST-001", &tmp.path().join("tasks/TST-001.md"), None)
.unwrap();
assert!(!service.log_path().exists());
}
#[test]
fn test_should_log_respects_operations_filter() {
let config = ActivityConfig {
enabled: true,
retention_days: 90,
log_operations: vec!["new".into()],
};
let tmp = tempdir().unwrap();
let service = ActivityLogService::new(tmp.path(), config);
assert!(service.should_log(Operation::New));
assert!(!service.should_log(Operation::Complete));
assert!(!service.should_log(Operation::Focus));
}
#[test]
fn test_read_entries() {
let tmp = tempdir().unwrap();
let service = ActivityLogService::new(tmp.path(), make_test_config(true));
service
.log_new("task", "TST-001", &tmp.path().join("tasks/TST-001.md"), None)
.unwrap();
service
.log_complete("task", "TST-001", &tmp.path().join("tasks/TST-001.md"), None)
.unwrap();
let entries = service.read_entries(None, None).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].op, Operation::New);
assert_eq!(entries[1].op, Operation::Complete);
}
#[test]
fn test_relativize_path() {
let tmp = tempdir().unwrap();
let service = ActivityLogService::new(tmp.path(), make_test_config(true));
let abs_path = tmp.path().join("tasks/TST-001.md");
let rel_path = service.relativize(&abs_path);
assert_eq!(rel_path, PathBuf::from("tasks/TST-001.md"));
}
}