Skip to main content

lean_ctx/core/
journal.rs

1use crate::core::policy::diagnostics;
2use std::io::{Read, Seek, SeekFrom, Write};
3
4use chrono::{Local, Utc};
5
6/// The activity journal is diagnostic state, never an unbounded hot-path
7/// workload. Archives preserve history while the active file stays cheap to
8/// inspect and update.
9const MAX_ACTIVE_JOURNAL_BYTES: u64 = 4 * 1024 * 1024;
10const JOURNAL_TAIL_BYTES: u64 = 8 * 1024;
11fn journal_target() -> Option<diagnostics::Target> {
12    diagnostics::target(crate::core::paths::state_dir().ok()?.join("journal.md"))
13}
14
15fn active_journal_needs_rotation(path: &std::path::Path) -> bool {
16    path.metadata()
17        .map(|metadata| metadata.len() >= MAX_ACTIVE_JOURNAL_BYTES)
18        .unwrap_or(false)
19}
20
21fn rotate_active_journal_if_needed(path: &std::path::Path) {
22    if !active_journal_needs_rotation(path) {
23        return;
24    }
25    let archive_name = format!("journal-{}.md", Utc::now().format("%Y%m%dT%H%M%S%6fZ"));
26    let archive = path.with_file_name(archive_name);
27    if std::fs::rename(path, archive).is_ok() {
28        let _ = std::fs::remove_file(path.with_extension("day"));
29    }
30}
31
32fn write_day_marker(path: &std::path::Path, today: &str) {
33    let marker = path.with_extension("day");
34    // Replace a marker entry rather than following a link to another file.
35    let Some(parent) = marker.parent() else {
36        return;
37    };
38    if let Ok(mut file) = tempfile::NamedTempFile::new_in(parent) {
39        if file.write_all(today.as_bytes()).is_ok() {
40            let _ = file.persist(marker);
41        }
42    }
43}
44
45fn journal_tail_contains(path: &std::path::Path, needle: &str) -> bool {
46    let Ok(mut file) = std::fs::File::open(path) else {
47        return false;
48    };
49    let start = file
50        .metadata()
51        .map(|metadata| metadata.len().saturating_sub(JOURNAL_TAIL_BYTES))
52        .unwrap_or(0);
53    if file.seek(SeekFrom::Start(start)).is_err() {
54        return false;
55    }
56    let mut tail = Vec::new();
57    file.read_to_end(&mut tail).is_ok() && String::from_utf8_lossy(&tail).contains(needle)
58}
59
60fn is_enabled() -> bool {
61    if let Ok(v) = std::env::var("LEAN_CTX_JOURNAL") {
62        return !matches!(v.trim(), "0" | "false" | "off");
63    }
64    super::config::Config::load().journal_enabled
65}
66
67/// Append a human-readable entry to the activity journal.
68pub fn log(category: &str, message: &str) {
69    log_inner(category, message, None);
70}
71
72fn log_inner(category: &str, message: &str, tool: Option<&str>) {
73    if !is_enabled() {
74        return;
75    }
76    let Some(target) = journal_target() else {
77        return;
78    };
79    target.with_lock(|| {
80        let Some(fields) = diagnostics::fields(
81            &[
82                ("category", category),
83                ("message", message),
84                ("tool", tool.unwrap_or_default()),
85            ],
86            None,
87        ) else {
88            return;
89        };
90        let Some(safe) = target.inspect(tool, &fields) else {
91            return;
92        };
93        let category = safe["category"].as_str().unwrap_or_default();
94        let message = safe["message"].as_str().unwrap_or_default();
95        let message = if tool.is_some() {
96            format!(
97                "`{}` — {}",
98                safe["tool"].as_str().unwrap_or_default(),
99                message.lines().next().unwrap_or_default()
100            )
101        } else {
102            message.to_owned()
103        };
104        let path = &target.path;
105        rotate_active_journal_if_needed(path);
106        let timestamp = Local::now().format("%Y-%m-%d %H:%M");
107        let entry = format!("- **{timestamp}** [{category}] {message}\n");
108        let Some(serde_json::Value::String(entry)) =
109            target.inspect(tool, &serde_json::Value::String(entry))
110        else {
111            return;
112        };
113        let needs_header = !path.exists();
114        let file = diagnostics::open_append(path);
115
116        if let Ok(mut f) = file {
117            if needs_header {
118                let date = Local::now().format("%Y-%m-%d").to_string();
119                let _ = writeln!(f, "# lean-ctx Activity Journal\n\n## {date}\n");
120                write_day_marker(path, &date);
121            }
122            let _ = f.write_all(entry.as_bytes());
123        }
124    });
125}
126
127/// Insert a day separator if the last entry was on a different date.
128pub fn maybe_day_separator() {
129    if !is_enabled() {
130        return;
131    }
132    let Some(target) = journal_target() else {
133        return;
134    };
135    target.with_lock(|| {
136        let path = &target.path;
137        if !path.exists() {
138            return;
139        }
140
141        let today = Local::now().format("%Y-%m-%d").to_string();
142        if crate::core::policy::files::read(&path.with_extension("day"), false)
143            .ok()
144            .flatten()
145            .is_some_and(|stored| stored.trim() == today)
146        {
147            return;
148        }
149
150        let header = format!("## {today}");
151        if !journal_tail_contains(path, &header)
152            && let Ok(mut f) = diagnostics::open_append(path)
153        {
154            let _ = writeln!(f, "\n{header}\n");
155        }
156        write_day_marker(path, &today);
157    });
158}
159
160/// Log a tool call to the journal.
161pub fn log_tool_call(tool_name: &str, summary: &str) {
162    if matches!(
163        tool_name,
164        "ctx_session" | "ctx_knowledge" | "ctx_context" | "ctx_radar"
165    ) {
166        return;
167    }
168    log_inner("tool", summary, Some(tool_name));
169}
170
171/// Return the journal content for display.
172pub fn read_journal(tail_lines: usize) -> String {
173    let Some(target) = journal_target() else {
174        return "Journal unavailable under current policy.".into();
175    };
176    let path = &target.path;
177    if !path.exists() {
178        return "No journal entries yet.".to_string();
179    }
180    let Some(content) = target.read() else {
181        return "Journal withheld by current policy or storage checks.".into();
182    };
183    if tail_lines == 0 {
184        return content;
185    }
186    let lines: Vec<&str> = content.lines().collect();
187    let start = lines.len().saturating_sub(tail_lines);
188    lines[start..].join("\n")
189}
190
191#[cfg(test)]
192pub mod tests {
193    use super::*;
194
195    #[test]
196    fn journal_log_creates_file() {
197        // `journal.md` is STATE (GH #408); isolated_data_dir collapses all four
198        // category dirs onto one temp dir so the write/read pair stays valid.
199        let iso = crate::core::data_dir::isolated_data_dir();
200        crate::test_env::set_var("LEAN_CTX_JOURNAL", "1");
201
202        log("test", "hello world");
203
204        let path = iso.path().join("journal.md");
205        assert!(path.exists(), "journal.md should be created");
206        let content = std::fs::read_to_string(&path).unwrap();
207        assert!(content.contains("[test] hello world"));
208        assert!(content.contains("# lean-ctx Activity Journal"));
209
210        crate::test_env::remove_var("LEAN_CTX_JOURNAL");
211    }
212
213    #[test]
214    fn read_journal_tail() {
215        let _iso = crate::core::data_dir::isolated_data_dir();
216        crate::test_env::set_var("LEAN_CTX_JOURNAL", "1");
217
218        for i in 0..5 {
219            log("test", &format!("entry {i}"));
220        }
221
222        let tail = read_journal(2);
223        assert!(tail.contains("entry 4"), "should contain last entry");
224        assert!(
225            !tail.contains("Activity Journal"),
226            "should not contain header"
227        );
228
229        crate::test_env::remove_var("LEAN_CTX_JOURNAL");
230    }
231
232    #[test]
233    fn oversized_active_journal_rotates_before_append() {
234        let iso = crate::core::data_dir::isolated_data_dir();
235        crate::test_env::set_var("LEAN_CTX_JOURNAL", "1");
236        let journal = iso.path().join("journal.md");
237        std::fs::write(
238            &journal,
239            vec![b'x'; (MAX_ACTIVE_JOURNAL_BYTES + 1) as usize],
240        )
241        .expect("write oversized journal");
242
243        log("test", "after rotation");
244
245        assert!(
246            std::fs::read_to_string(&journal)
247                .expect("new active journal")
248                .contains("after rotation")
249        );
250        assert!(
251            std::fs::read_dir(iso.path())
252                .expect("journal directory")
253                .flatten()
254                .any(|entry| entry.file_name().to_string_lossy().starts_with("journal-"))
255        );
256        crate::test_env::remove_var("LEAN_CTX_JOURNAL");
257    }
258
259    #[test]
260    fn day_separator_uses_small_tail_and_persists_marker() {
261        let iso = crate::core::data_dir::isolated_data_dir();
262        crate::test_env::set_var("LEAN_CTX_JOURNAL", "1");
263        let today = Local::now().format("%Y-%m-%d").to_string();
264        let journal = iso.path().join("journal.md");
265        std::fs::write(&journal, format!("{}\n## {today}\n", "x".repeat(16 * 1024)))
266            .expect("write journal");
267
268        maybe_day_separator();
269
270        assert_eq!(
271            std::fs::read_to_string(iso.path().join("journal.day"))
272                .expect("day marker")
273                .trim(),
274            today
275        );
276        crate::test_env::remove_var("LEAN_CTX_JOURNAL");
277    }
278}