use serde::Serialize;
use sha2::{Digest, Sha256};
use std::io::Write;
use std::path::Path;
pub fn log_op(file: &Path, message: &str) {
let _ = try_log_op(file, message);
}
#[derive(Debug, Serialize)]
pub struct CycleEntry {
pub op: String,
pub file: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snapshot_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_hash: Option<String>,
}
pub fn content_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
fn git_head_hash(file: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["log", "-1", "--format=%H", "--"])
.arg(file)
.output()
.ok()?;
if output.status.success() {
let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
if hash.is_empty() { None } else { Some(hash) }
} else {
None
}
}
fn iso_timestamp() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{}", now)
}
pub fn log_cycle(file: &Path, op: &str, snapshot_content: Option<&str>, file_content: Option<&str>) {
let _ = try_log_cycle(file, op, snapshot_content, file_content);
}
fn try_log_cycle(file: &Path, op: &str, snapshot_content: Option<&str>, file_content: Option<&str>) -> Option<()> {
let canonical = file.canonicalize().ok()?;
let project_root = crate::snapshot::find_project_root(&canonical)?;
let logs_dir = project_root.join(".agent-doc/logs");
std::fs::create_dir_all(&logs_dir).ok()?;
let log_path = logs_dir.join("cycles.jsonl");
let relative = canonical
.strip_prefix(&project_root)
.unwrap_or(&canonical)
.to_string_lossy()
.to_string();
let entry = CycleEntry {
op: op.to_string(),
file: relative,
timestamp: iso_timestamp(),
commit_hash: git_head_hash(file),
snapshot_hash: snapshot_content.map(content_hash),
file_hash: file_content.map(content_hash),
};
let json = serde_json::to_string(&entry).ok()?;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok()?;
writeln!(f, "{}", json).ok()
}
fn try_log_op(file: &Path, message: &str) -> Option<()> {
let canonical = file.canonicalize().ok()?;
let project_root = crate::snapshot::find_project_root(&canonical)?;
let logs_dir = project_root.join(".agent-doc/logs");
std::fs::create_dir_all(&logs_dir).ok()?;
let log_path = logs_dir.join("ops.log");
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok()?;
writeln!(f, "[{}] {}", ts, message).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn log_op_creates_file_and_appends() {
let tmp = tempfile::TempDir::new().unwrap();
let project_root = tmp.path();
fs::create_dir_all(project_root.join(".agent-doc")).unwrap();
let doc_path = project_root.join("test.md");
fs::write(&doc_path, "test").unwrap();
log_op(&doc_path, "test_event file=test.md");
log_op(&doc_path, "second_event file=test.md");
let log_path = project_root.join(".agent-doc/logs/ops.log");
assert!(log_path.exists(), "ops.log should be created");
let content = fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2, "should have 2 log lines");
assert!(lines[0].contains("test_event"), "first line should contain message");
assert!(lines[1].contains("second_event"), "second line should contain message");
assert!(lines[0].starts_with('['), "should start with timestamp bracket");
assert!(lines[0].contains("] "), "should have ] separator after timestamp");
}
#[test]
fn log_cycle_writes_jsonl() {
let tmp = tempfile::TempDir::new().unwrap();
let project_root = tmp.path();
fs::create_dir_all(project_root.join(".agent-doc")).unwrap();
let doc_path = project_root.join("test.md");
fs::write(&doc_path, "content").unwrap();
log_cycle(&doc_path, "write_inline", Some("snapshot"), Some("content"));
let log_path = project_root.join(".agent-doc/logs/cycles.jsonl");
assert!(log_path.exists(), "cycles.jsonl should be created");
let content = fs::read_to_string(&log_path).unwrap();
let entry: serde_json::Value = serde_json::from_str(content.lines().next().unwrap()).unwrap();
assert_eq!(entry["op"], "write_inline");
assert!(entry["file"].as_str().unwrap().contains("test.md"));
assert!(entry["snapshot_hash"].is_string());
assert!(entry["file_hash"].is_string());
}
#[test]
fn log_cycle_appends_multiple() {
let tmp = tempfile::TempDir::new().unwrap();
let project_root = tmp.path();
fs::create_dir_all(project_root.join(".agent-doc")).unwrap();
let doc_path = project_root.join("test.md");
fs::write(&doc_path, "content").unwrap();
log_cycle(&doc_path, "write_inline", Some("snap1"), Some("file1"));
log_cycle(&doc_path, "commit", Some("snap2"), Some("file2"));
let log_path = project_root.join(".agent-doc/logs/cycles.jsonl");
let content = fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
let entry1: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
let entry2: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(entry1["op"], "write_inline");
assert_eq!(entry2["op"], "commit");
}
#[test]
fn log_op_no_panic_on_missing_project_root() {
let tmp = tempfile::TempDir::new().unwrap();
let doc_path = tmp.path().join("orphan.md");
fs::write(&doc_path, "test").unwrap();
log_op(&doc_path, "should_not_crash");
assert!(!tmp.path().join(".agent-doc/logs/ops.log").exists());
}
}