1use crate::core::policy::diagnostics;
2use std::io::{Read, Seek, SeekFrom, Write};
3
4use chrono::{Local, Utc};
5
6const 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 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
67pub 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
127pub 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
160pub 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
171pub 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 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}