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    /// View session logs.
22    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        // Collect .meta.json files first; fall back to .json for legacy logs
51        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                // Skip non-json and meta files in this pass
57                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        // Sort by modification time descending (newest first), take limit
66        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            // Try .meta.json first (lightweight), fall back to full log
72            let meta_path = path.with_extension("meta.json");
73            let doc: serde_json::Value = if meta_path.exists() {
74                // Meta file: already flat summary (~200 bytes)
75                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                // Legacy fallback: read full log and extract fields
84                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    // ─── Stats ──────────────────────────────────────────────────
111
112    /// Return diagnostic info about the current configuration (mise doctor style).
113    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        // packages dir
125        if let Some(home) = dirs::home_dir() {
126            let packages = home.join(".algocline").join("packages");
127            if packages.is_dir() {
128                info["packages_dir"] = serde_json::json!(packages.display().to_string());
129            }
130        }
131
132        serde_json::to_string_pretty(&info).unwrap_or_else(|_| "{}".to_string())
133    }
134
135    /// Aggregate stats across all logged sessions.
136    ///
137    /// Scans `.meta.json` files (with `.json` fallback for legacy logs).
138    /// Optional filters: `strategy` (exact match), `days` (last N days).
139    pub fn stats(
140        &self,
141        strategy_filter: Option<&str>,
142        days: Option<u64>,
143    ) -> Result<String, String> {
144        let dir = match self.log_config.log_dir.as_deref() {
145            Some(d) if d.is_dir() => d,
146            _ => {
147                return Ok(serde_json::json!({
148                    "total_sessions": 0,
149                    "strategies": {},
150                })
151                .to_string());
152            }
153        };
154
155        let cutoff = days.map(|d| {
156            std::time::SystemTime::now()
157                .duration_since(std::time::UNIX_EPOCH)
158                .unwrap_or_default()
159                .as_millis() as u64
160                - d * 86_400_000
161        });
162
163        let entries = std::fs::read_dir(dir).map_err(|e| format!("Failed to read log dir: {e}"))?;
164
165        #[derive(Default)]
166        struct StrategyAcc {
167            count: u64,
168            sum_elapsed_ms: u64,
169            sum_llm_calls: u64,
170            sum_rounds: u64,
171            sum_prompt_chars: u64,
172            sum_response_chars: u64,
173        }
174
175        let mut acc: std::collections::HashMap<String, StrategyAcc> =
176            std::collections::HashMap::new();
177        let mut total: u64 = 0;
178
179        for entry in entries.flatten() {
180            let path = entry.path();
181            let name = match path.file_name().and_then(|n| n.to_str()) {
182                Some(n) => n.to_string(),
183                None => continue,
184            };
185
186            // Read meta from .meta.json or fall back to .json
187            let doc: serde_json::Value = if name.ends_with(".meta.json") {
188                match std::fs::read_to_string(&path)
189                    .ok()
190                    .and_then(|r| serde_json::from_str(&r).ok())
191                {
192                    Some(d) => d,
193                    None => continue,
194                }
195            } else if name.ends_with(".json") && !name.ends_with(".meta.json") {
196                // Skip full logs if meta exists
197                let meta_name =
198                    format!("{}.meta.json", name.strip_suffix(".json").unwrap_or(&name));
199                let meta_path = dir.join(meta_name);
200                if meta_path.exists() {
201                    continue;
202                }
203                // Legacy fallback
204                match std::fs::read_to_string(&path)
205                    .ok()
206                    .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
207                {
208                    Some(d) => {
209                        let stats = d.get("stats");
210                        serde_json::json!({
211                            "strategy": d.get("strategy").and_then(|v| v.as_str()),
212                            "elapsed_ms": stats.and_then(|s| s.get("elapsed_ms")),
213                            "llm_calls": stats.and_then(|s| s.get("llm_calls")),
214                            "rounds": stats.and_then(|s| s.get("rounds")),
215                            "total_prompt_chars": stats.and_then(|s| s.get("total_prompt_chars")),
216                            "total_response_chars": stats.and_then(|s| s.get("total_response_chars")),
217                        })
218                    }
219                    None => continue,
220                }
221            } else {
222                continue;
223            };
224
225            // Apply time filter via elapsed_ms proxy (file mtime would be better but
226            // meta files don't store timestamps; use mtime as approximation)
227            if let Some(cutoff_ms) = cutoff {
228                let mtime = entry
229                    .metadata()
230                    .ok()
231                    .and_then(|m| m.modified().ok())
232                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
233                    .map(|d| d.as_millis() as u64)
234                    .unwrap_or(0);
235                if mtime < cutoff_ms {
236                    continue;
237                }
238            }
239
240            let strat = doc
241                .get("strategy")
242                .and_then(|v| v.as_str())
243                .unwrap_or("unknown")
244                .to_string();
245
246            // Apply strategy filter
247            if let Some(filter) = strategy_filter {
248                if strat != filter {
249                    continue;
250                }
251            }
252
253            let elapsed = doc.get("elapsed_ms").and_then(|v| v.as_u64()).unwrap_or(0);
254            let llm = doc.get("llm_calls").and_then(|v| v.as_u64()).unwrap_or(0);
255            let rounds = doc.get("rounds").and_then(|v| v.as_u64()).unwrap_or(0);
256            let prompt_chars = doc
257                .get("total_prompt_chars")
258                .and_then(|v| v.as_u64())
259                .unwrap_or(0);
260            let response_chars = doc
261                .get("total_response_chars")
262                .and_then(|v| v.as_u64())
263                .unwrap_or(0);
264
265            let a = acc.entry(strat).or_default();
266            a.count += 1;
267            a.sum_elapsed_ms += elapsed;
268            a.sum_llm_calls += llm;
269            a.sum_rounds += rounds;
270            a.sum_prompt_chars += prompt_chars;
271            a.sum_response_chars += response_chars;
272            total += 1;
273        }
274
275        // Build response
276        let mut strategies = serde_json::Map::new();
277        for (strat, a) in &acc {
278            let c = a.count.max(1); // avoid division by zero
279            strategies.insert(
280                strat.clone(),
281                serde_json::json!({
282                    "count": a.count,
283                    "avg_elapsed_ms": (a.sum_elapsed_ms + c / 2) / c,
284                    "avg_llm_calls": (a.sum_llm_calls + c / 2) / c,
285                    "avg_rounds": (a.sum_rounds + c / 2) / c,
286                    "total_prompt_chars": a.sum_prompt_chars,
287                    "total_response_chars": a.sum_response_chars,
288                }),
289            );
290        }
291
292        Ok(serde_json::json!({
293            "total_sessions": total,
294            "strategies": strategies,
295        })
296        .to_string())
297    }
298}