algocline_app/service/
logging.rs1use super::path::ContainedPath;
2use super::transcript::append_note;
3use super::AppService;
4
5impl AppService {
6 pub async fn add_note(
8 &self,
9 session_id: &str,
10 content: &str,
11 title: Option<&str>,
12 ) -> Result<String, String> {
13 let count = append_note(self.require_log_dir()?, session_id, content, title)?;
14 Ok(serde_json::json!({
15 "session_id": session_id,
16 "notes_count": count,
17 })
18 .to_string())
19 }
20
21 pub async fn log_view(
23 &self,
24 session_id: Option<&str>,
25 limit: Option<usize>,
26 ) -> Result<String, String> {
27 match session_id {
28 Some(sid) => self.log_read(sid),
29 None => self.log_list(limit.unwrap_or(50)),
30 }
31 }
32
33 fn log_read(&self, session_id: &str) -> Result<String, String> {
34 let log_dir = self.require_log_dir()?;
35 let path = ContainedPath::child(log_dir, &format!("{session_id}.json"))?;
36 if !path.as_ref().exists() {
37 return Err(format!("Log file not found for session '{session_id}'"));
38 }
39 std::fs::read_to_string(&path).map_err(|e| format!("Failed to read log: {e}"))
40 }
41
42 pub(super) fn log_list(&self, limit: usize) -> Result<String, String> {
43 let dir = match self.log_config.log_dir.as_deref() {
44 Some(d) if d.is_dir() => d,
45 _ => return Ok(serde_json::json!({ "sessions": [] }).to_string()),
46 };
47
48 let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
49
50 let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries
52 .flatten()
53 .filter_map(|entry| {
54 let path = entry.path();
55 let name = path.file_name()?.to_str()?;
56 if !name.ends_with(".json") || name.ends_with(".meta.json") {
58 return None;
59 }
60 let mtime = entry.metadata().ok()?.modified().ok()?;
61 Some((path, mtime))
62 })
63 .collect();
64
65 files.sort_by(|a, b| b.1.cmp(&a.1));
67 files.truncate(limit);
68
69 let mut sessions = Vec::new();
70 for (path, _) in &files {
71 let meta_path = path.with_extension("meta.json");
73 let doc: serde_json::Value = if meta_path.exists() {
74 match std::fs::read_to_string(&meta_path)
76 .ok()
77 .and_then(|r| serde_json::from_str(&r).ok())
78 {
79 Some(d) => d,
80 None => continue,
81 }
82 } else {
83 let raw = match std::fs::read_to_string(path) {
85 Ok(r) => r,
86 Err(_) => continue,
87 };
88 match serde_json::from_str::<serde_json::Value>(&raw) {
89 Ok(d) => {
90 let stats = d.get("stats");
91 serde_json::json!({
92 "session_id": d.get("session_id").and_then(|v| v.as_str()).unwrap_or("unknown"),
93 "task_hint": d.get("task_hint").and_then(|v| v.as_str()),
94 "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
95 "rounds": stats.and_then(|s| s.get("rounds")),
96 "llm_calls": stats.and_then(|s| s.get("llm_calls")),
97 "notes_count": d.get("notes").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0),
98 })
99 }
100 Err(_) => continue,
101 }
102 };
103
104 sessions.push(doc);
105 }
106
107 Ok(serde_json::json!({ "sessions": sessions }).to_string())
108 }
109
110 pub fn info(&self) -> String {
114 let mut info = serde_json::json!({
115 "version": env!("CARGO_PKG_VERSION"),
116 "log_dir": {
117 "resolved": self.log_config.log_dir.as_ref().map(|p| p.display().to_string()),
118 "source": self.log_config.log_dir_source.to_string(),
119 },
120 "log_enabled": self.log_config.log_enabled,
121 "tracing": if self.log_config.log_dir.is_some() { "file + stderr" } else { "stderr only" },
122 });
123
124 let search_paths_json: Vec<serde_json::Value> = self
126 .search_paths
127 .iter()
128 .map(|sp| {
129 serde_json::json!({
130 "path": sp.path.display().to_string(),
131 "source": sp.source.to_string(),
132 })
133 })
134 .collect();
135 info["search_paths"] = serde_json::json!(search_paths_json);
136
137 if let Some(home) = dirs::home_dir() {
139 let packages = home.join(".algocline").join("packages");
140 if packages.is_dir() {
141 info["packages_dir"] = serde_json::json!(packages.display().to_string());
142 }
143 }
144
145 serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string())
146 }
147
148 pub fn stats(
153 &self,
154 strategy_filter: Option<&str>,
155 days: Option<u64>,
156 ) -> Result<String, String> {
157 let dir = match self.log_config.log_dir.as_deref() {
158 Some(d) if d.is_dir() => d,
159 _ => {
160 return Ok(serde_json::json!({
161 "total_sessions": 0,
162 "strategies": {},
163 })
164 .to_string());
165 }
166 };
167
168 let cutoff = days.map(|d| {
169 std::time::SystemTime::now()
170 .duration_since(std::time::UNIX_EPOCH)
171 .unwrap_or_default()
172 .as_millis() as u64
173 - d * 86_400_000
174 });
175
176 let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
177
178 #[derive(Default)]
179 struct StrategyAcc {
180 count: u64,
181 sum_elapsed_ms: u64,
182 sum_llm_calls: u64,
183 sum_rounds: u64,
184 sum_prompt_chars: u64,
185 sum_response_chars: u64,
186 }
187
188 let mut acc: std::collections::HashMap<String, StrategyAcc> =
189 std::collections::HashMap::new();
190 let mut total: u64 = 0;
191
192 for entry in entries.flatten() {
193 let path = entry.path();
194 let name = match path.file_name().and_then(|n| n.to_str()) {
195 Some(n) => n.to_string(),
196 None => continue,
197 };
198
199 let doc: serde_json::Value = if name.ends_with(".meta.json") {
201 match std::fs::read_to_string(&path)
202 .ok()
203 .and_then(|r| serde_json::from_str(&r).ok())
204 {
205 Some(d) => d,
206 None => continue,
207 }
208 } else if name.ends_with(".json") && !name.ends_with(".meta.json") {
209 let meta_name =
211 format!("{}.meta.json", name.strip_suffix(".json").unwrap_or(&name));
212 let meta_path = dir.join(meta_name);
213 if meta_path.exists() {
214 continue;
215 }
216 match std::fs::read_to_string(&path)
218 .ok()
219 .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
220 {
221 Some(d) => {
222 let stats = d.get("stats");
223 serde_json::json!({
224 "strategy": d.get("strategy").and_then(|v| v.as_str()),
225 "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
226 "llm_calls": stats.and_then(|s| s.get("llm_calls")),
227 "rounds": stats.and_then(|s| s.get("rounds")),
228 "total_prompt_chars": stats.and_then(|s| s.get("total_prompt_chars")),
229 "total_response_chars": stats.and_then(|s| s.get("total_response_chars")),
230 })
231 }
232 None => continue,
233 }
234 } else {
235 continue;
236 };
237
238 if let Some(cutoff_ms) = cutoff {
241 let mtime = entry
242 .metadata()
243 .ok()
244 .and_then(|m| m.modified().ok())
245 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
246 .map(|d| d.as_millis() as u64)
247 .unwrap_or(0);
248 if mtime < cutoff_ms {
249 continue;
250 }
251 }
252
253 let strat = doc
254 .get("strategy")
255 .and_then(|v| v.as_str())
256 .unwrap_or("unknown")
257 .to_string();
258
259 if let Some(filter) = strategy_filter {
261 if strat != filter {
262 continue;
263 }
264 }
265
266 let elapsed = doc.get("elapsed_ms").and_then(|v| v.as_u64()).unwrap_or(0);
267 let llm = doc.get("llm_calls").and_then(|v| v.as_u64()).unwrap_or(0);
268 let rounds = doc.get("rounds").and_then(|v| v.as_u64()).unwrap_or(0);
269 let prompt_chars = doc
270 .get("total_prompt_chars")
271 .and_then(|v| v.as_u64())
272 .unwrap_or(0);
273 let response_chars = doc
274 .get("total_response_chars")
275 .and_then(|v| v.as_u64())
276 .unwrap_or(0);
277
278 let a = acc.entry(strat).or_default();
279 a.count += 1;
280 a.sum_elapsed_ms += elapsed;
281 a.sum_llm_calls += llm;
282 a.sum_rounds += rounds;
283 a.sum_prompt_chars += prompt_chars;
284 a.sum_response_chars += response_chars;
285 total += 1;
286 }
287
288 let mut strategies = serde_json::Map::new();
290 for (strat, a) in &acc {
291 let c = a.count.max(1); strategies.insert(
293 strat.clone(),
294 serde_json::json!({
295 "count": a.count,
296 "avg_elapsed_ms": (a.sum_elapsed_ms + c / 2) / c,
297 "avg_llm_calls": (a.sum_llm_calls + c / 2) / c,
298 "avg_rounds": (a.sum_rounds + c / 2) / c,
299 "total_prompt_chars": a.sum_prompt_chars,
300 "total_response_chars": a.sum_response_chars,
301 }),
302 );
303 }
304
305 Ok(serde_json::json!({
306 "total_sessions": total,
307 "strategies": strategies,
308 })
309 .to_string())
310 }
311}