Skip to main content

lean_ctx/core/
journal.rs

1use std::io::Write;
2use std::path::PathBuf;
3
4use chrono::{Local, Utc};
5
6use super::data_dir::lean_ctx_data_dir;
7
8fn journal_path() -> PathBuf {
9    lean_ctx_data_dir()
10        .unwrap_or_else(|_| PathBuf::from(".lean-ctx"))
11        .join("journal.md")
12}
13
14fn is_enabled() -> bool {
15    if let Ok(v) = std::env::var("LEAN_CTX_JOURNAL") {
16        return !matches!(v.trim(), "0" | "false" | "off");
17    }
18    super::config::Config::load().journal_enabled
19}
20
21/// Append a human-readable entry to the activity journal.
22pub fn log(category: &str, message: &str) {
23    if !is_enabled() {
24        return;
25    }
26    let path = journal_path();
27    let timestamp = Local::now().format("%Y-%m-%d %H:%M");
28
29    let entry = format!("- **{timestamp}** [{category}] {message}\n");
30
31    let needs_header = !path.exists();
32    let file = std::fs::OpenOptions::new()
33        .create(true)
34        .append(true)
35        .open(&path);
36
37    if let Ok(mut f) = file {
38        if needs_header {
39            let date = Utc::now().format("%Y-%m-%d");
40            let _ = writeln!(f, "# lean-ctx Activity Journal\n\n## {date}\n");
41        }
42        let _ = f.write_all(entry.as_bytes());
43    }
44}
45
46/// Insert a day separator if the last entry was on a different date.
47pub fn maybe_day_separator() {
48    if !is_enabled() {
49        return;
50    }
51    let path = journal_path();
52    if !path.exists() {
53        return;
54    }
55
56    let today = Local::now().format("%Y-%m-%d").to_string();
57    let content = std::fs::read_to_string(&path).unwrap_or_default();
58    let header = format!("## {today}");
59    if !content.contains(&header) {
60        let file = std::fs::OpenOptions::new().append(true).open(&path);
61        if let Ok(mut f) = file {
62            let _ = writeln!(f, "\n{header}\n");
63        }
64    }
65}
66
67/// Log a tool call to the journal.
68pub fn log_tool_call(tool_name: &str, summary: &str) {
69    if matches!(
70        tool_name,
71        "ctx_session" | "ctx_knowledge" | "ctx_context" | "ctx_radar"
72    ) {
73        return;
74    }
75    log("tool", &format!("`{tool_name}` — {summary}"));
76}
77
78/// Return the journal content for display.
79pub fn read_journal(tail_lines: usize) -> String {
80    let path = journal_path();
81    if !path.exists() {
82        return "No journal entries yet.".to_string();
83    }
84    let content = std::fs::read_to_string(&path).unwrap_or_default();
85    if tail_lines == 0 {
86        return content;
87    }
88    let lines: Vec<&str> = content.lines().collect();
89    let start = lines.len().saturating_sub(tail_lines);
90    lines[start..].join("\n")
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn journal_log_creates_file() {
99        let _lock = crate::core::data_dir::test_env_lock();
100        let dir = tempfile::tempdir().unwrap();
101        std::env::set_var("LEAN_CTX_DATA_DIR", dir.path().as_os_str());
102        std::env::set_var("LEAN_CTX_JOURNAL", "1");
103
104        log("test", "hello world");
105
106        let path = dir.path().join("journal.md");
107        assert!(path.exists(), "journal.md should be created");
108        let content = std::fs::read_to_string(&path).unwrap();
109        assert!(content.contains("[test] hello world"));
110        assert!(content.contains("# lean-ctx Activity Journal"));
111
112        std::env::remove_var("LEAN_CTX_JOURNAL");
113    }
114
115    #[test]
116    fn read_journal_tail() {
117        let _lock = crate::core::data_dir::test_env_lock();
118        let dir = tempfile::tempdir().unwrap();
119        std::env::set_var("LEAN_CTX_DATA_DIR", dir.path().as_os_str());
120        std::env::set_var("LEAN_CTX_JOURNAL", "1");
121
122        for i in 0..5 {
123            log("test", &format!("entry {i}"));
124        }
125
126        let tail = read_journal(2);
127        assert!(tail.contains("entry 4"), "should contain last entry");
128        assert!(
129            !tail.contains("Activity Journal"),
130            "should not contain header"
131        );
132
133        std::env::remove_var("LEAN_CTX_JOURNAL");
134    }
135}