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