1use std::path::Path;
4
5use anyhow::{Context, Result};
6
7use crate::types::{SessionMetrics, SessionSummary};
8
9const METRICS_FILE: &str = "session_metrics.json";
11
12pub fn save_metrics(metrics: &SessionMetrics, session_dir: &Path) -> Result<()> {
15 let path = session_dir.join(METRICS_FILE);
16 let json = serde_json::to_string_pretty(metrics)?;
17 std::fs::write(&path, json)?;
18 tracing::debug!(path = %path.display(), turns = metrics.total_turns, "session_metrics saved");
19 Ok(())
20}
21
22pub fn load_metrics(session_dir: &Path) -> Result<SessionMetrics> {
24 let path = session_dir.join(METRICS_FILE);
25 let content = std::fs::read_to_string(&path)
26 .with_context(|| format!("Failed to read metrics file: {}", path.display()))?;
27 let metrics: SessionMetrics = serde_json::from_str(&content)
28 .with_context(|| format!("Failed to parse metrics file: {}", path.display()))?;
29 Ok(metrics)
30}
31
32pub fn try_load_metrics(session_dir: &Path) -> Option<SessionMetrics> {
34 load_metrics(session_dir).ok()
35}
36
37pub fn list_all_metrics(base_dir: &Path) -> Result<Vec<SessionSummary>> {
40 let sessions_dir = base_dir.join("sessions");
41 if !sessions_dir.exists() {
42 return Ok(Vec::new());
43 }
44
45 let mut summaries = Vec::new();
46 for entry in std::fs::read_dir(&sessions_dir)? {
47 let entry = entry?;
48 let path = entry.path();
49 if !path.is_dir() {
50 continue;
51 }
52
53 if let Some(metrics) = try_load_metrics(&path) {
54 let product = metrics
55 .custom
56 .get("product")
57 .and_then(|v| v.as_str())
58 .map(|s| s.to_string());
59
60 summaries.push(SessionSummary {
61 session_id: metrics.session_id,
62 node_id: metrics.node_id,
63 created_at: metrics.created_at,
64 model: metrics.model,
65 total_turns: metrics.total_turns,
66 total_chars: metrics.total_chars,
67 outcome: metrics.outcome,
68 product,
69 });
70 }
71 }
72
73 summaries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
75 Ok(summaries)
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use crate::types::{SessionMetrics, SessionOutcome, TurnMetrics, TurnOutcome};
82 use serde_json::Value;
83 use std::fs;
84
85 fn temp_dir(name: &str) -> std::path::PathBuf {
87 let dir = std::env::temp_dir().join(format!(
88 "phi_telemetry_test_{}_{}",
89 name,
90 std::time::SystemTime::now()
91 .duration_since(std::time::UNIX_EPOCH)
92 .unwrap()
93 .as_nanos()
94 ));
95 fs::create_dir_all(&dir).unwrap();
96 dir
97 }
98
99 fn sample_metrics(session_id: &str, model: &str) -> SessionMetrics {
101 let mut m = SessionMetrics::new(
102 session_id.to_string(),
103 "node-1".to_string(),
104 model.to_string(),
105 );
106 let turn = TurnMetrics::new(
107 1,
108 "2026-08-01T12:00:00Z".to_string(),
109 1000,
110 model.to_string(),
111 "test input".to_string(),
112 TurnOutcome::Completed,
113 );
114 m.append_turn(turn);
115 m
116 }
117
118 #[test]
121 fn save_metrics_creates_file() {
122 let dir = temp_dir("save_creates");
123 let m = sample_metrics("s1", "gpt-4o");
124 save_metrics(&m, &dir).unwrap();
125
126 let path = dir.join(METRICS_FILE);
127 assert!(path.exists());
128
129 let content = fs::read_to_string(&path).unwrap();
131 let parsed: SessionMetrics = serde_json::from_str(&content).unwrap();
132 assert_eq!(parsed.session_id, "s1");
133
134 let _ = fs::remove_dir_all(&dir);
135 }
136
137 #[test]
138 fn save_metrics_overwrites_existing() {
139 let dir = temp_dir("save_overwrite");
140 let m1 = sample_metrics("s1", "gpt-4o");
141 save_metrics(&m1, &dir).unwrap();
142
143 let mut m2 = sample_metrics("s2", "claude-sonnet");
144 m2.total_turns = 5;
145 save_metrics(&m2, &dir).unwrap();
146
147 let loaded = load_metrics(&dir).unwrap();
148 assert_eq!(loaded.session_id, "s2");
149 assert_eq!(loaded.total_turns, 5);
150
151 let _ = fs::remove_dir_all(&dir);
152 }
153
154 #[test]
157 fn load_metrics_roundtrip() {
158 let dir = temp_dir("load_roundtrip");
159 let original = sample_metrics("s1", "gpt-4o");
160 save_metrics(&original, &dir).unwrap();
161
162 let loaded = load_metrics(&dir).unwrap();
163 assert_eq!(loaded.session_id, "s1");
164 assert_eq!(loaded.model, "gpt-4o");
165 assert_eq!(loaded.total_turns, 1);
166 assert_eq!(loaded.turns.len(), 1);
167 assert_eq!(loaded.node_id, "node-1");
168
169 let _ = fs::remove_dir_all(&dir);
170 }
171
172 #[test]
173 fn load_metrics_file_not_found() {
174 let dir = temp_dir("load_not_found");
175 let result = load_metrics(&dir);
176 assert!(result.is_err());
177 assert!(
178 result
179 .unwrap_err()
180 .to_string()
181 .contains("Failed to read metrics file")
182 );
183
184 let _ = fs::remove_dir_all(&dir);
185 }
186
187 #[test]
188 fn load_metrics_invalid_json() {
189 let dir = temp_dir("load_invalid");
190 fs::write(dir.join(METRICS_FILE), "not valid json").unwrap();
191
192 let result = load_metrics(&dir);
193 assert!(result.is_err());
194 assert!(
195 result
196 .unwrap_err()
197 .to_string()
198 .contains("Failed to parse metrics file")
199 );
200
201 let _ = fs::remove_dir_all(&dir);
202 }
203
204 #[test]
207 fn try_load_metrics_returns_some_when_exists() {
208 let dir = temp_dir("try_some");
209 let m = sample_metrics("s1", "gpt-4o");
210 save_metrics(&m, &dir).unwrap();
211
212 let result = try_load_metrics(&dir);
213 assert!(result.is_some());
214 assert_eq!(result.unwrap().session_id, "s1");
215
216 let _ = fs::remove_dir_all(&dir);
217 }
218
219 #[test]
220 fn try_load_metrics_returns_none_when_missing() {
221 let dir = temp_dir("try_none");
222 let result = try_load_metrics(&dir);
223 assert!(result.is_none());
224
225 let _ = fs::remove_dir_all(&dir);
226 }
227
228 #[test]
229 fn try_load_metrics_returns_none_on_invalid_json() {
230 let dir = temp_dir("try_invalid");
231 fs::write(dir.join(METRICS_FILE), "not json").unwrap();
232
233 let result = try_load_metrics(&dir);
234 assert!(result.is_none());
235
236 let _ = fs::remove_dir_all(&dir);
237 }
238
239 #[test]
242 fn list_all_metrics_empty_when_no_sessions_dir() {
243 let dir = temp_dir("list_no_sessions");
244 let result = list_all_metrics(&dir).unwrap();
245 assert!(result.is_empty());
246
247 let _ = fs::remove_dir_all(&dir);
248 }
249
250 #[test]
251 fn list_all_metrics_returns_summaries_sorted_by_date() {
252 let base = temp_dir("list_sorted");
253 let sessions_dir = base.join("sessions");
254 fs::create_dir_all(&sessions_dir).unwrap();
255
256 let dir1 = sessions_dir.join("s1");
258 fs::create_dir_all(&dir1).unwrap();
259 let mut m1 = sample_metrics("s1", "gpt-4o");
260 m1.created_at = "2026-01-01T00:00:00Z".to_string();
261 save_metrics(&m1, &dir1).unwrap();
262
263 let dir2 = sessions_dir.join("s2");
264 fs::create_dir_all(&dir2).unwrap();
265 let mut m2 = sample_metrics("s2", "claude-sonnet");
266 m2.created_at = "2026-08-01T00:00:00Z".to_string();
267 save_metrics(&m2, &dir2).unwrap();
268
269 let summaries = list_all_metrics(&base).unwrap();
270 assert_eq!(summaries.len(), 2);
271 assert_eq!(summaries[0].session_id, "s2");
273 assert_eq!(summaries[1].session_id, "s1");
274 assert_eq!(summaries[0].model, "claude-sonnet");
275 assert_eq!(summaries[0].total_turns, 1);
276
277 let _ = fs::remove_dir_all(&base);
278 }
279
280 #[test]
281 fn list_all_metrics_skips_non_session_dirs() {
282 let base = temp_dir("list_skip");
283 let sessions_dir = base.join("sessions");
284 fs::create_dir_all(&sessions_dir).unwrap();
285
286 let empty_dir = sessions_dir.join("empty_session");
288 fs::create_dir_all(&empty_dir).unwrap();
289
290 fs::write(sessions_dir.join("not_a_dir.txt"), "data").unwrap();
292
293 let valid_dir = sessions_dir.join("valid");
295 fs::create_dir_all(&valid_dir).unwrap();
296 save_metrics(&sample_metrics("valid", "gpt-4o"), &valid_dir).unwrap();
297
298 let summaries = list_all_metrics(&base).unwrap();
299 assert_eq!(summaries.len(), 1);
300 assert_eq!(summaries[0].session_id, "valid");
301
302 let _ = fs::remove_dir_all(&base);
303 }
304
305 #[test]
306 fn list_all_metrics_extracts_product_from_custom() {
307 let base = temp_dir("list_product");
308 let sessions_dir = base.join("sessions");
309 let sdir = sessions_dir.join("s1");
310 fs::create_dir_all(&sdir).unwrap();
311
312 let mut m = sample_metrics("s1", "gpt-4o");
313 if let Value::Object(ref mut map) = m.custom {
314 map.insert("product".to_string(), Value::String("phi-bard".to_string()));
315 }
316 save_metrics(&m, &sdir).unwrap();
317
318 let summaries = list_all_metrics(&base).unwrap();
319 assert_eq!(summaries.len(), 1);
320 assert_eq!(summaries[0].product, Some("phi-bard".to_string()));
321
322 let _ = fs::remove_dir_all(&base);
323 }
324
325 #[test]
326 fn list_all_metrics_no_product_when_custom_empty() {
327 let base = temp_dir("list_no_product");
328 let sessions_dir = base.join("sessions");
329 let sdir = sessions_dir.join("s1");
330 fs::create_dir_all(&sdir).unwrap();
331
332 let m = sample_metrics("s1", "gpt-4o");
333 save_metrics(&m, &sdir).unwrap();
334
335 let summaries = list_all_metrics(&base).unwrap();
336 assert_eq!(summaries[0].product, None);
337
338 let _ = fs::remove_dir_all(&base);
339 }
340
341 #[test]
342 fn list_all_metrics_summary_fields_are_correct() {
343 let base = temp_dir("list_fields");
344 let sessions_dir = base.join("sessions");
345 let sdir = sessions_dir.join("s1");
346 fs::create_dir_all(&sdir).unwrap();
347
348 let mut m = sample_metrics("s1", "gpt-4o");
349 m.node_id = "node-42".to_string();
350 m.created_at = "2026-06-15T10:00:00Z".to_string();
351 m.total_chars = 5000;
352 m.outcome = SessionOutcome::Completed;
353 save_metrics(&m, &sdir).unwrap();
354
355 let summaries = list_all_metrics(&base).unwrap();
356 let s = &summaries[0];
357 assert_eq!(s.session_id, "s1");
358 assert_eq!(s.node_id, "node-42");
359 assert_eq!(s.created_at, "2026-06-15T10:00:00Z");
360 assert_eq!(s.model, "gpt-4o");
361 assert_eq!(s.total_turns, 1);
362 assert_eq!(s.total_chars, 5000);
363 assert_eq!(s.outcome, SessionOutcome::Completed);
364
365 let _ = fs::remove_dir_all(&base);
366 }
367}