Skip to main content

algocline_app/service/
logging.rs

1use super::path::ContainedPath;
2use super::transcript::append_note;
3use super::AppService;
4
5impl AppService {
6    /// Append a note to a session's log file.
7    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    /// Default max response size for detail mode (100 KB).
22    const DEFAULT_MAX_CHARS: usize = 100_000;
23
24    /// View session logs.
25    pub async fn log_view(
26        &self,
27        session_id: Option<&str>,
28        limit: Option<usize>,
29        max_chars: Option<usize>,
30    ) -> Result<String, String> {
31        match session_id {
32            Some(sid) => self.log_read(sid, max_chars.unwrap_or(Self::DEFAULT_MAX_CHARS)),
33            None => self.log_list(limit.unwrap_or(50)),
34        }
35    }
36
37    fn log_read(&self, session_id: &str, max_chars: usize) -> Result<String, String> {
38        let log_dir = self.require_log_dir()?;
39        let path = ContainedPath::child(log_dir, &format!("{session_id}.json"))?;
40        if !path.as_ref().exists() {
41            return Err(format!("Log file not found for session '{session_id}'"));
42        }
43        let raw = std::fs::read_to_string(&path).map_err(|e| format!("Failed to read log: {e}"))?;
44
45        // 0 means unlimited
46        if max_chars == 0 || raw.len() <= max_chars {
47            return Ok(raw);
48        }
49
50        // Parse and truncate transcript (oldest rounds first) to fit within max_chars
51        let mut doc: serde_json::Value =
52            serde_json::from_str(&raw).map_err(|e| format!("Failed to parse log: {e}"))?;
53
54        let original_rounds = doc
55            .get("transcript")
56            .and_then(|t| t.as_array())
57            .map(|a| a.len())
58            .unwrap_or(0);
59
60        if original_rounds == 0 {
61            // No transcript to truncate; return as-is
62            return Ok(raw);
63        }
64
65        // Binary-search: keep the maximum number of newest rounds that fit
66        let transcript = doc
67            .get("transcript")
68            .and_then(|t| t.as_array())
69            .cloned()
70            .unwrap_or_default();
71
72        let mut kept = original_rounds;
73        loop {
74            if kept == 0 {
75                // Even with empty transcript it might still be too large (unlikely)
76                doc["transcript"] = serde_json::json!([]);
77                break;
78            }
79            // Keep the newest `kept` rounds
80            let slice = &transcript[original_rounds - kept..];
81            doc["transcript"] = serde_json::Value::Array(slice.to_vec());
82            let serialized =
83                serde_json::to_string(&doc).map_err(|e| format!("Failed to serialize: {e}"))?;
84            if serialized.len() <= max_chars {
85                break;
86            }
87            // Halve for speed, then linear scan
88            if kept > 8 {
89                kept /= 2;
90            } else {
91                kept -= 1;
92            }
93        }
94
95        let returned_rounds = doc
96            .get("transcript")
97            .and_then(|t| t.as_array())
98            .map(|a| a.len())
99            .unwrap_or(0);
100
101        doc["truncated"] = serde_json::json!(true);
102        doc["original_rounds"] = serde_json::json!(original_rounds);
103        doc["returned_rounds"] = serde_json::json!(returned_rounds);
104
105        serde_json::to_string_pretty(&doc).map_err(|e| format!("Failed to serialize: {e}"))
106    }
107
108    pub(super) fn log_list(&self, limit: usize) -> Result<String, String> {
109        let dir = match self.log_config.log_dir.as_deref() {
110            Some(d) if d.is_dir() => d,
111            _ => return Ok(serde_json::json!({ "sessions": [] }).to_string()),
112        };
113
114        let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
115
116        // Collect .meta.json files first; fall back to .json for legacy logs
117        let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = entries
118            .flatten()
119            .filter_map(|entry| {
120                let path = entry.path();
121                let name = path.file_name()?.to_str()?;
122                // Skip non-json and meta files in this pass
123                if !name.ends_with(".json") || name.ends_with(".meta.json") {
124                    return None;
125                }
126                let mtime = entry.metadata().ok()?.modified().ok()?;
127                Some((path, mtime))
128            })
129            .collect();
130
131        // Sort by modification time descending (newest first), take limit
132        files.sort_by(|a, b| b.1.cmp(&a.1));
133        files.truncate(limit);
134
135        let mut sessions = Vec::new();
136        for (path, _) in &files {
137            // Try .meta.json first (lightweight), fall back to full log
138            let meta_path = path.with_extension("meta.json");
139            let doc: serde_json::Value = if meta_path.exists() {
140                // Meta file: already flat summary (~200 bytes)
141                match std::fs::read_to_string(&meta_path)
142                    .ok()
143                    .and_then(|r| serde_json::from_str(&r).ok())
144                {
145                    Some(d) => d,
146                    None => continue,
147                }
148            } else {
149                // Legacy fallback: read full log and extract fields
150                let raw = match std::fs::read_to_string(path) {
151                    Ok(r) => r,
152                    Err(_) => continue,
153                };
154                match serde_json::from_str::<serde_json::Value>(&raw) {
155                    Ok(d) => {
156                        let stats = d.get("stats");
157                        serde_json::json!({
158                            "session_id": d.get("session_id").and_then(|v| v.as_str()).unwrap_or("unknown"),
159                            "task_hint": d.get("task_hint").and_then(|v| v.as_str()),
160                            "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
161                            "rounds": stats.and_then(|s| s.get("rounds")),
162                            "llm_calls": stats.and_then(|s| s.get("llm_calls")),
163                            "notes_count": d.get("notes").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0),
164                        })
165                    }
166                    Err(_) => continue,
167                }
168            };
169
170            sessions.push(doc);
171        }
172
173        Ok(serde_json::json!({ "sessions": sessions }).to_string())
174    }
175
176    // ─── Stats ──────────────────────────────────────────────────
177
178    /// Return diagnostic info about the current configuration (mise doctor style).
179    pub fn info(&self) -> String {
180        let mut info = serde_json::json!({
181            "version": env!("CARGO_PKG_VERSION"),
182            "log_dir": {
183                "resolved": self.log_config.log_dir.as_ref().map(|p| p.display().to_string()),
184                "source": self.log_config.log_dir_source.to_string(),
185            },
186            "log_enabled": self.log_config.log_enabled,
187            "tracing": if self.log_config.log_dir.is_some() { "file + stderr" } else { "stderr only" },
188        });
189
190        // search paths (package resolution chain, priority order)
191        let search_paths_json: Vec<serde_json::Value> = self
192            .search_paths
193            .iter()
194            .map(|sp| {
195                serde_json::json!({
196                    "path": sp.path.display().to_string(),
197                    "source": sp.source.to_string(),
198                })
199            })
200            .collect();
201        info["search_paths"] = serde_json::json!(search_paths_json);
202
203        // packages dir (kept for backward compatibility)
204        if let Some(home) = dirs::home_dir() {
205            let packages = home.join(".algocline").join("packages");
206            if packages.is_dir() {
207                info["packages_dir"] = serde_json::json!(packages.display().to_string());
208            }
209        }
210
211        serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string())
212    }
213
214    /// Aggregate stats across all logged sessions.
215    ///
216    /// Scans `.meta.json` files (with `.json` fallback for legacy logs).
217    /// Optional filters: `strategy` (exact match), `days` (last N days).
218    pub fn stats(
219        &self,
220        strategy_filter: Option<&str>,
221        days: Option<u64>,
222    ) -> Result<String, String> {
223        let dir = match self.log_config.log_dir.as_deref() {
224            Some(d) if d.is_dir() => d,
225            _ => {
226                return Ok(serde_json::json!({
227                    "total_sessions": 0,
228                    "strategies": {},
229                })
230                .to_string());
231            }
232        };
233
234        let cutoff = days.map(|d| {
235            std::time::SystemTime::now()
236                .duration_since(std::time::UNIX_EPOCH)
237                .unwrap_or_default()
238                .as_millis() as u64
239                - d * 86_400_000
240        });
241
242        let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
243
244        #[derive(Default)]
245        struct StrategyAcc {
246            count: u64,
247            sum_elapsed_ms: u64,
248            sum_llm_calls: u64,
249            sum_rounds: u64,
250            sum_prompt_chars: u64,
251            sum_response_chars: u64,
252        }
253
254        let mut acc: std::collections::HashMap<String, StrategyAcc> =
255            std::collections::HashMap::new();
256        let mut total: u64 = 0;
257
258        for entry in entries.flatten() {
259            let path = entry.path();
260            let name = match path.file_name().and_then(|n| n.to_str()) {
261                Some(n) => n.to_string(),
262                None => continue,
263            };
264
265            // Read meta from .meta.json or fall back to .json
266            let doc: serde_json::Value = if name.ends_with(".meta.json") {
267                match std::fs::read_to_string(&path)
268                    .ok()
269                    .and_then(|r| serde_json::from_str(&r).ok())
270                {
271                    Some(d) => d,
272                    None => continue,
273                }
274            } else if name.ends_with(".json") && !name.ends_with(".meta.json") {
275                // Skip full logs if meta exists
276                let meta_name =
277                    format!("{}.meta.json", name.strip_suffix(".json").unwrap_or(&name));
278                let meta_path = dir.join(meta_name);
279                if meta_path.exists() {
280                    continue;
281                }
282                // Legacy fallback
283                match std::fs::read_to_string(&path)
284                    .ok()
285                    .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
286                {
287                    Some(d) => {
288                        let stats = d.get("stats");
289                        serde_json::json!({
290                            "strategy": d.get("strategy").and_then(|v| v.as_str()),
291                            "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
292                            "llm_calls": stats.and_then(|s| s.get("llm_calls")),
293                            "rounds": stats.and_then(|s| s.get("rounds")),
294                            "total_prompt_chars": stats.and_then(|s| s.get("total_prompt_chars")),
295                            "total_response_chars": stats.and_then(|s| s.get("total_response_chars")),
296                        })
297                    }
298                    None => continue,
299                }
300            } else {
301                continue;
302            };
303
304            // Apply time filter via elapsed_ms proxy (file mtime would be better but
305            // meta files don't store timestamps; use mtime as approximation)
306            if let Some(cutoff_ms) = cutoff {
307                let mtime = entry
308                    .metadata()
309                    .ok()
310                    .and_then(|m| m.modified().ok())
311                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
312                    .map(|d| d.as_millis() as u64)
313                    .unwrap_or(0);
314                if mtime < cutoff_ms {
315                    continue;
316                }
317            }
318
319            let strat = doc
320                .get("strategy")
321                .and_then(|v| v.as_str())
322                .unwrap_or("unknown")
323                .to_string();
324
325            // Apply strategy filter
326            if let Some(filter) = strategy_filter {
327                if strat != filter {
328                    continue;
329                }
330            }
331
332            let elapsed = doc.get("elapsed_ms").and_then(|v| v.as_u64()).unwrap_or(0);
333            let llm = doc.get("llm_calls").and_then(|v| v.as_u64()).unwrap_or(0);
334            let rounds = doc.get("rounds").and_then(|v| v.as_u64()).unwrap_or(0);
335            let prompt_chars = doc
336                .get("total_prompt_chars")
337                .and_then(|v| v.as_u64())
338                .unwrap_or(0);
339            let response_chars = doc
340                .get("total_response_chars")
341                .and_then(|v| v.as_u64())
342                .unwrap_or(0);
343
344            let a = acc.entry(strat).or_default();
345            a.count += 1;
346            a.sum_elapsed_ms += elapsed;
347            a.sum_llm_calls += llm;
348            a.sum_rounds += rounds;
349            a.sum_prompt_chars += prompt_chars;
350            a.sum_response_chars += response_chars;
351            total += 1;
352        }
353
354        // Build response
355        let mut strategies = serde_json::Map::new();
356        for (strat, a) in &acc {
357            let c = a.count.max(1); // avoid division by zero
358            strategies.insert(
359                strat.clone(),
360                serde_json::json!({
361                    "count": a.count,
362                    "avg_elapsed_ms": (a.sum_elapsed_ms + c / 2) / c,
363                    "avg_llm_calls": (a.sum_llm_calls + c / 2) / c,
364                    "avg_rounds": (a.sum_rounds + c / 2) / c,
365                    "total_prompt_chars": a.sum_prompt_chars,
366                    "total_response_chars": a.sum_response_chars,
367                }),
368            );
369        }
370
371        Ok(serde_json::json!({
372            "total_sessions": total,
373            "strategies": strategies,
374        })
375        .to_string())
376    }
377}