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
21pub 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
46pub 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
67pub 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
78pub 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}