1use algocline_engine::card;
20use serde_json::{json, Value as Json};
21
22use super::AppService;
23
24#[derive(Debug, Default, Clone, Copy)]
30struct QueryFilter {
31 pub min_calls: Option<u64>,
32 pub max_calls: Option<u64>,
33 pub min_ms: Option<f64>,
34 pub max_ms: Option<f64>,
35 pub completed: Option<bool>,
36 pub offset: usize,
37 pub limit: Option<usize>,
38}
39
40impl AppService {
41 #[allow(clippy::too_many_arguments)]
68 pub fn trace_query(
69 &self,
70 pkg: Option<&str>,
71 min_calls: Option<u64>,
72 max_calls: Option<u64>,
73 min_ms: Option<f64>,
74 max_ms: Option<f64>,
75 completed: Option<bool>,
76 offset: Option<usize>,
77 limit: Option<usize>,
78 ) -> Result<String, String> {
79 let filter = QueryFilter {
80 min_calls,
81 max_calls,
82 min_ms,
83 max_ms,
84 completed,
85 offset: offset.unwrap_or(0),
86 limit,
87 };
88 let summaries = self.card_store.list(pkg)?;
89
90 let mut matched: usize = 0;
91 let mut out: Vec<Json> = Vec::new();
92
93 'cards: for summary in summaries {
94 let samples = self
95 .card_store
96 .read_samples(&summary.card_id, card::SamplesQuery::default())?;
97
98 for (idx, row) in samples.iter().enumerate() {
99 let trace = match row.get("trace").and_then(|t| t.as_object()) {
100 Some(t) => t,
101 None => continue,
102 };
103 let total_calls = match trace.get("total_calls").and_then(|v| v.as_u64()) {
104 Some(v) => v,
105 None => continue,
106 };
107 let total_ms = trace
108 .get("total_trace_ms")
109 .and_then(|v| v.as_f64())
110 .unwrap_or(0.0);
111 let completed = trace
112 .get("completed")
113 .and_then(|v| v.as_bool())
114 .unwrap_or(false);
115
116 if let Some(min) = filter.min_calls {
117 if total_calls < min {
118 continue;
119 }
120 }
121 if let Some(max) = filter.max_calls {
122 if total_calls > max {
123 continue;
124 }
125 }
126 if let Some(min) = filter.min_ms {
127 if total_ms < min {
128 continue;
129 }
130 }
131 if let Some(max) = filter.max_ms {
132 if total_ms > max {
133 continue;
134 }
135 }
136 if let Some(want) = filter.completed {
137 if completed != want {
138 continue;
139 }
140 }
141
142 if matched < filter.offset {
143 matched += 1;
144 continue;
145 }
146 if let Some(lim) = filter.limit {
147 if out.len() >= lim {
148 break 'cards;
149 }
150 }
151 matched += 1;
152
153 let mut hit = serde_json::Map::new();
154 hit.insert("card_id".into(), json!(summary.card_id));
155 hit.insert("pkg".into(), json!(summary.pkg));
156 hit.insert("sample_index".into(), json!(idx));
157 hit.insert(
158 "trace".into(),
159 json!({
160 "total_calls": total_calls,
161 "total_trace_ms": total_ms,
162 "completed": completed,
163 }),
164 );
165 if let Some(case) = row.get("case") {
166 hit.insert("case".into(), case.clone());
167 }
168 out.push(Json::Object(hit));
169 }
170 }
171
172 Ok(Json::Array(out).to_string())
173 }
174
175 pub fn trace_diff(
201 &self,
202 a_card_id: &str,
203 a_sample_index: Option<usize>,
204 b_card_id: &str,
205 b_sample_index: Option<usize>,
206 ) -> Result<String, String> {
207 let (a_trace, a_index) = self.load_trace(a_card_id, a_sample_index)?;
208 let (b_trace, b_index) = self.load_trace(b_card_id, b_sample_index)?;
209
210 let a_calls = a_trace.total_calls as i64;
211 let b_calls = b_trace.total_calls as i64;
212 let calls_delta = b_calls - a_calls;
213 let ms_delta = b_trace.total_trace_ms - a_trace.total_trace_ms;
214
215 let a_avg = if a_trace.total_calls > 0 {
216 a_trace.total_trace_ms / (a_trace.total_calls as f64)
217 } else {
218 0.0
219 };
220 let b_avg = if b_trace.total_calls > 0 {
221 b_trace.total_trace_ms / (b_trace.total_calls as f64)
222 } else {
223 0.0
224 };
225 let ms_per_call_delta = b_avg - a_avg;
226
227 let response = json!({
228 "a_ref": { "card_id": a_card_id, "sample_index": a_index },
229 "b_ref": { "card_id": b_card_id, "sample_index": b_index },
230 "a_trace": {
231 "total_calls": a_trace.total_calls,
232 "total_trace_ms": a_trace.total_trace_ms,
233 "completed": a_trace.completed,
234 },
235 "b_trace": {
236 "total_calls": b_trace.total_calls,
237 "total_trace_ms": b_trace.total_trace_ms,
238 "completed": b_trace.completed,
239 },
240 "delta": {
241 "calls_delta": calls_delta,
242 "ms_delta": ms_delta,
243 "ms_per_call_delta": ms_per_call_delta,
244 "completed_a": a_trace.completed,
245 "completed_b": b_trace.completed,
246 },
247 });
248
249 Ok(response.to_string())
250 }
251
252 fn load_trace(
254 &self,
255 card_id: &str,
256 sample_index: Option<usize>,
257 ) -> Result<(TraceRow, usize), String> {
258 let idx = sample_index.unwrap_or(0);
259
260 match self.card_store.get(card_id)? {
265 Some(_) => (),
266 None => return Err(format!("card '{card_id}' not found")),
267 }
268
269 let samples = self
270 .card_store
271 .read_samples(card_id, card::SamplesQuery::default())?;
272 if idx >= samples.len() {
273 return Err(format!(
274 "card '{card_id}' sample index {idx} out of range (len={})",
275 samples.len()
276 ));
277 }
278 let row = &samples[idx];
279 let trace = row
280 .get("trace")
281 .and_then(|t| t.as_object())
282 .ok_or_else(|| format!("card '{card_id}' sample {idx} has no trace"))?;
283
284 let total_calls = trace
285 .get("total_calls")
286 .and_then(|v| v.as_u64())
287 .ok_or_else(|| format!("card '{card_id}' sample {idx} trace missing total_calls"))?;
288 let total_trace_ms = trace
289 .get("total_trace_ms")
290 .and_then(|v| v.as_f64())
291 .unwrap_or(0.0);
292 let completed = trace
293 .get("completed")
294 .and_then(|v| v.as_bool())
295 .unwrap_or(false);
296
297 Ok((
298 TraceRow {
299 total_calls,
300 total_trace_ms,
301 completed,
302 },
303 idx,
304 ))
305 }
306}
307
308struct TraceRow {
310 total_calls: u64,
311 total_trace_ms: f64,
312 completed: bool,
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use algocline_engine::card::FileCardStore;
319 use std::io::Write;
320 use tempfile::TempDir;
321
322 #[derive(Debug, Default)]
327 struct TestQueryParams {
328 pkg: Option<String>,
329 min_calls: Option<u64>,
330 max_calls: Option<u64>,
331 min_ms: Option<f64>,
332 max_ms: Option<f64>,
333 completed: Option<bool>,
334 offset: Option<usize>,
335 limit: Option<usize>,
336 }
337
338 fn write_card(root: &std::path::Path, pkg: &str, card_id: &str, samples: &[Json]) {
339 let pkg_dir = root.join(pkg);
340 std::fs::create_dir_all(&pkg_dir).unwrap();
341 let toml_path = pkg_dir.join(format!("{card_id}.toml"));
342 let body = format!(
343 "pkg = \"{pkg}\"\ncard_id = \"{card_id}\"\n[metadata]\ncreated_at = \"2026-07-09T00:00:00Z\"\n"
344 );
345 std::fs::write(&toml_path, body).unwrap();
346
347 let jsonl_path = pkg_dir.join(format!("{card_id}.samples.jsonl"));
348 let mut f = std::fs::File::create(&jsonl_path).unwrap();
349 for row in samples {
350 writeln!(f, "{}", row).unwrap();
351 }
352 }
353
354 fn traced_row(seq: u64, calls: u64, ms: f64, completed: bool) -> Json {
355 json!({
356 "case": { "name": format!("case-{seq}") },
357 "trace": {
358 "total_calls": calls,
359 "total_trace_ms": ms,
360 "completed": completed,
361 },
362 })
363 }
364
365 fn untraced_row(seq: u64) -> Json {
366 json!({
367 "case": { "name": format!("plain-{seq}") },
368 "response": { "text": "hello" },
369 })
370 }
371
372 fn run_query(store: &FileCardStore, params: TestQueryParams) -> Vec<Json> {
376 let summaries = store.list(params.pkg.as_deref()).unwrap();
377 let offset = params.offset.unwrap_or(0);
378 let mut matched = 0usize;
379 let mut out: Vec<Json> = Vec::new();
380
381 'cards: for summary in summaries {
382 let samples = store
383 .read_samples(&summary.card_id, card::SamplesQuery::default())
384 .unwrap();
385 for (idx, row) in samples.iter().enumerate() {
386 let trace = match row.get("trace").and_then(|t| t.as_object()) {
387 Some(t) => t,
388 None => continue,
389 };
390 let calls = trace.get("total_calls").and_then(|v| v.as_u64()).unwrap();
391 let ms = trace
392 .get("total_trace_ms")
393 .and_then(|v| v.as_f64())
394 .unwrap_or(0.0);
395 let completed = trace
396 .get("completed")
397 .and_then(|v| v.as_bool())
398 .unwrap_or(false);
399 if let Some(min) = params.min_calls {
400 if calls < min {
401 continue;
402 }
403 }
404 if let Some(max) = params.max_calls {
405 if calls > max {
406 continue;
407 }
408 }
409 if let Some(min) = params.min_ms {
410 if ms < min {
411 continue;
412 }
413 }
414 if let Some(max) = params.max_ms {
415 if ms > max {
416 continue;
417 }
418 }
419 if let Some(want) = params.completed {
420 if completed != want {
421 continue;
422 }
423 }
424 if matched < offset {
425 matched += 1;
426 continue;
427 }
428 if let Some(lim) = params.limit {
429 if out.len() >= lim {
430 break 'cards;
431 }
432 }
433 matched += 1;
434 out.push(json!({
435 "card_id": summary.card_id,
436 "pkg": summary.pkg,
437 "sample_index": idx,
438 "trace": {
439 "total_calls": calls,
440 "total_trace_ms": ms,
441 "completed": completed,
442 },
443 "case": row.get("case").cloned().unwrap_or(Json::Null),
444 }));
445 }
446 }
447 out
448 }
449
450 #[test]
451 fn query_skips_untraced_rows() {
452 let tmp = TempDir::new().unwrap();
453 write_card(
454 tmp.path(),
455 "pkg_a",
456 "card_a1",
457 &[
458 traced_row(1, 3, 150.0, true),
459 untraced_row(2),
460 traced_row(3, 5, 200.0, true),
461 ],
462 );
463
464 let store = FileCardStore::new(tmp.path().to_path_buf());
465 let hits = run_query(&store, TestQueryParams::default());
466 assert_eq!(hits.len(), 2, "only traced rows should surface");
467 assert_eq!(hits[0]["sample_index"], 0);
468 assert_eq!(hits[1]["sample_index"], 2);
469 }
470
471 #[test]
472 fn query_filters_by_min_and_max_calls() {
473 let tmp = TempDir::new().unwrap();
474 write_card(
475 tmp.path(),
476 "pkg_a",
477 "card_a1",
478 &[
479 traced_row(1, 3, 100.0, true),
480 traced_row(2, 8, 200.0, true),
481 traced_row(3, 12, 400.0, true),
482 ],
483 );
484 let store = FileCardStore::new(tmp.path().to_path_buf());
485
486 let hits = run_query(
487 &store,
488 TestQueryParams {
489 min_calls: Some(5),
490 max_calls: Some(10),
491 ..Default::default()
492 },
493 );
494 assert_eq!(hits.len(), 1);
495 assert_eq!(hits[0]["trace"]["total_calls"], 8);
496 }
497
498 #[test]
499 fn query_filters_by_completed_flag() {
500 let tmp = TempDir::new().unwrap();
501 write_card(
502 tmp.path(),
503 "pkg_a",
504 "card_a1",
505 &[
506 traced_row(1, 3, 100.0, true),
507 traced_row(2, 3, 100.0, false),
508 ],
509 );
510 let store = FileCardStore::new(tmp.path().to_path_buf());
511
512 let hits = run_query(
513 &store,
514 TestQueryParams {
515 completed: Some(false),
516 ..Default::default()
517 },
518 );
519 assert_eq!(hits.len(), 1);
520 assert_eq!(hits[0]["trace"]["completed"], false);
521 }
522
523 #[test]
524 fn query_pkg_filter_prunes_early() {
525 let tmp = TempDir::new().unwrap();
526 write_card(
527 tmp.path(),
528 "pkg_a",
529 "card_a1",
530 &[traced_row(1, 3, 100.0, true)],
531 );
532 write_card(
533 tmp.path(),
534 "pkg_b",
535 "card_b1",
536 &[traced_row(1, 7, 200.0, true)],
537 );
538 let store = FileCardStore::new(tmp.path().to_path_buf());
539
540 let hits = run_query(
541 &store,
542 TestQueryParams {
543 pkg: Some("pkg_b".to_string()),
544 ..Default::default()
545 },
546 );
547 assert_eq!(hits.len(), 1);
548 assert_eq!(hits[0]["pkg"], "pkg_b");
549 }
550
551 #[test]
552 fn query_offset_and_limit() {
553 let tmp = TempDir::new().unwrap();
554 write_card(
555 tmp.path(),
556 "pkg_a",
557 "card_a1",
558 &[
559 traced_row(1, 1, 10.0, true),
560 traced_row(2, 2, 20.0, true),
561 traced_row(3, 3, 30.0, true),
562 traced_row(4, 4, 40.0, true),
563 ],
564 );
565 let store = FileCardStore::new(tmp.path().to_path_buf());
566
567 let hits = run_query(
568 &store,
569 TestQueryParams {
570 offset: Some(1),
571 limit: Some(2),
572 ..Default::default()
573 },
574 );
575 assert_eq!(hits.len(), 2);
576 assert_eq!(hits[0]["trace"]["total_calls"], 2);
577 assert_eq!(hits[1]["trace"]["total_calls"], 3);
578 }
579
580 #[test]
584 fn delta_math_matches_expected_signs() {
585 let a_calls: i64 = 5;
589 let b_calls: i64 = 8;
590 assert_eq!(b_calls - a_calls, 3);
591 let a_ms: f64 = 500.0;
592 let b_ms: f64 = 1200.0;
593 assert!((b_ms - a_ms - 700.0).abs() < 1e-6);
594 let a_avg = a_ms / (a_calls as f64);
595 let b_avg = b_ms / (b_calls as f64);
596 assert!((b_avg - a_avg - (150.0 - 100.0)).abs() < 1e-6);
597 }
598}