1use serde::Serialize;
2
3pub const ROUTE_STATUS_COVERED: &str = "covered";
5pub const ROUTE_STATUS_GAP: &str = "gap";
6pub const ROUTE_STATUS_UNMAPPABLE: &str = "unmappable";
7
8#[derive(Debug, Clone, Serialize)]
10pub struct ObserveRouteEntry {
11 pub http_method: String,
12 pub path: String,
13 pub handler: String,
14 pub file: String,
15 pub test_files: Vec<String>,
16 pub status: String, pub gap_reasons: Vec<String>, }
19
20#[derive(Debug, Clone, Serialize)]
22pub struct ObserveFileEntry {
23 pub production_file: String,
24 pub test_files: Vec<String>,
25 pub strategy: String,
26}
27
28#[derive(Debug, Clone, Serialize)]
30pub struct ObserveSummary {
31 pub production_files: usize,
32 pub test_files: usize,
33 pub mapped_files: usize,
34 pub unmapped_files: usize,
35 pub routes_total: usize,
36 pub routes_covered: usize,
37 pub routes_gap: usize,
38 pub routes_unmappable: usize,
39}
40
41#[derive(Debug, Clone, Serialize)]
43pub struct ObserveReport {
44 pub summary: ObserveSummary,
45 pub file_mappings: Vec<ObserveFileEntry>,
46 pub routes: Vec<ObserveRouteEntry>,
47 pub unmapped_production_files: Vec<String>,
48}
49
50impl ObserveReport {
51 pub fn format_terminal(&self) -> String {
53 let mut out = String::new();
54
55 out.push_str("# exspec observe -- Test Coverage Map\n\n");
56
57 out.push_str("## Summary\n");
59 out.push_str(&format!(
60 "- Production files: {}\n",
61 self.summary.production_files
62 ));
63 out.push_str(&format!("- Test files: {}\n", self.summary.test_files));
64 let pct = if self.summary.production_files > 0 {
65 self.summary.mapped_files as f64 / self.summary.production_files as f64 * 100.0
66 } else {
67 0.0
68 };
69 out.push_str(&format!(
70 "- Mapped: {} ({:.1}%)\n",
71 self.summary.mapped_files, pct
72 ));
73 out.push_str(&format!("- Unmapped: {}\n", self.summary.unmapped_files));
74
75 if self.summary.routes_total > 0 {
77 out.push_str(&format!(
78 "\n## Route Coverage: {} total, {} covered, {} gap, {} unmappable\n",
79 self.summary.routes_total,
80 self.summary.routes_covered,
81 self.summary.routes_gap,
82 self.summary.routes_unmappable,
83 ));
84 out.push_str(&format!(
85 "Routes: {} total, {} covered, {} gap, {} unmappable\n",
86 self.summary.routes_total,
87 self.summary.routes_covered,
88 self.summary.routes_gap,
89 self.summary.routes_unmappable,
90 ));
91 out.push_str("| Route | Handler | Test File | Status |\n");
92 out.push_str("|-------|---------|-----------|--------|\n");
93 for route in &self.routes {
94 let status = if route.status.is_empty() {
95 if route.test_files.is_empty() {
96 "Gap"
97 } else {
98 "Covered"
99 }
100 } else {
101 match route.status.as_str() {
102 "covered" => "Covered",
103 "unmappable" => "Unmappable",
104 _ => "Gap",
105 }
106 };
107 let test_display = if route.test_files.is_empty() {
108 "\u{2014}".to_string()
109 } else {
110 route.test_files.join(", ")
111 };
112 out.push_str(&format!(
113 "| {} {} | {} | {} | {} |\n",
114 route.http_method, route.path, route.handler, test_display, status
115 ));
116 }
117 }
118
119 if !self.file_mappings.is_empty() {
121 out.push_str("\n## File Mappings\n");
122 out.push_str("| Production File | Test File(s) | Strategy |\n");
123 out.push_str("|----------------|-------------|----------|\n");
124 for entry in &self.file_mappings {
125 let tests = if entry.test_files.is_empty() {
126 "\u{2014}".to_string()
127 } else {
128 entry.test_files.join(", ")
129 };
130 out.push_str(&format!(
131 "| {} | {} | {} |\n",
132 entry.production_file, tests, entry.strategy
133 ));
134 }
135 }
136
137 if !self.unmapped_production_files.is_empty() {
139 out.push_str("\n## Unmapped Production Files\n");
140 for f in &self.unmapped_production_files {
141 out.push_str(&format!("- {f}\n"));
142 }
143 }
144
145 out
146 }
147
148 pub fn format_json(&self) -> String {
150 #[derive(Serialize)]
151 struct JsonOutput<'a> {
152 version: &'a str,
153 mode: &'a str,
154 summary: &'a ObserveSummary,
155 file_mappings: &'a [ObserveFileEntry],
156 routes: &'a [ObserveRouteEntry],
157 unmapped_production_files: &'a [String],
158 }
159
160 let output = JsonOutput {
161 version: env!("CARGO_PKG_VERSION"),
162 mode: "observe",
163 summary: &self.summary,
164 file_mappings: &self.file_mappings,
165 routes: &self.routes,
166 unmapped_production_files: &self.unmapped_production_files,
167 };
168
169 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
170 }
171
172 pub fn format_ai_prompt(&self) -> String {
174 let mut out = String::new();
175
176 out.push_str("## Route Coverage Summary\n\n");
177 out.push_str(&format!("- Total routes: {}\n", self.summary.routes_total));
178 out.push_str(&format!("- Covered: {}\n", self.summary.routes_covered));
179 out.push_str(&format!("- Gap: {}\n", self.summary.routes_gap));
180 out.push_str(&format!(
181 "- Unmappable: {}\n",
182 self.summary.routes_unmappable
183 ));
184
185 let gap_routes: Vec<&ObserveRouteEntry> =
186 self.routes.iter().filter(|r| r.status == "gap").collect();
187
188 if gap_routes.is_empty() {
189 out.push_str("\nAll mappable routes have test coverage.\n");
190 } else {
191 out.push_str("\n## Route Coverage Gaps\n\n");
192 out.push_str("The following API routes have no test coverage:\n");
193 for route in &gap_routes {
194 out.push_str(&format!(
195 "- {} {} -> {}\n",
196 route.http_method, route.path, route.handler
197 ));
198 }
199 out.push_str("\nConsider writing tests for these endpoints.\n");
200 }
201
202 out
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 fn sample_report() -> ObserveReport {
211 ObserveReport {
212 summary: ObserveSummary {
213 production_files: 3,
214 test_files: 2,
215 mapped_files: 2,
216 unmapped_files: 1,
217 routes_total: 3,
218 routes_covered: 2,
219 routes_gap: 1,
220 routes_unmappable: 0,
221 },
222 file_mappings: vec![
223 ObserveFileEntry {
224 production_file: "src/users.controller.ts".to_string(),
225 test_files: vec!["src/users.controller.spec.ts".to_string()],
226 strategy: "import".to_string(),
227 },
228 ObserveFileEntry {
229 production_file: "src/users.service.ts".to_string(),
230 test_files: vec!["src/users.service.spec.ts".to_string()],
231 strategy: "filename".to_string(),
232 },
233 ],
234 routes: vec![
235 ObserveRouteEntry {
236 http_method: "GET".to_string(),
237 path: "/users".to_string(),
238 handler: "UsersController.findAll".to_string(),
239 file: "src/users.controller.ts".to_string(),
240 test_files: vec!["src/users.controller.spec.ts".to_string()],
241 status: "covered".to_string(),
242 gap_reasons: vec![],
243 },
244 ObserveRouteEntry {
245 http_method: "POST".to_string(),
246 path: "/users".to_string(),
247 handler: "UsersController.create".to_string(),
248 file: "src/users.controller.ts".to_string(),
249 test_files: vec!["src/users.controller.spec.ts".to_string()],
250 status: "covered".to_string(),
251 gap_reasons: vec![],
252 },
253 ObserveRouteEntry {
254 http_method: "DELETE".to_string(),
255 path: "/users/:id".to_string(),
256 handler: "UsersController.remove".to_string(),
257 file: "src/utils/helpers.ts".to_string(),
258 test_files: vec![],
259 status: "gap".to_string(),
260 gap_reasons: vec!["no_test_mapping".to_string()],
261 },
262 ],
263 unmapped_production_files: vec!["src/utils/helpers.ts".to_string()],
264 }
265 }
266
267 #[test]
269 fn ob2_observe_report_summary() {
270 let report = sample_report();
271 assert_eq!(report.summary.production_files, 3);
272 assert_eq!(report.summary.test_files, 2);
273 assert_eq!(report.summary.mapped_files, 2);
274 assert_eq!(report.summary.unmapped_files, 1);
275 assert_eq!(report.summary.routes_total, 3);
276 assert_eq!(report.summary.routes_covered, 2);
277 assert_eq!(report.summary.routes_gap, 1);
278 assert_eq!(report.summary.routes_unmappable, 0);
279 }
280
281 #[test]
283 fn ob3_observe_json_output() {
284 let report = sample_report();
285 let json = report.format_json();
286 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
287
288 assert_eq!(parsed["mode"], "observe");
289 assert!(parsed["version"].is_string());
290 assert!(parsed["summary"].is_object());
291 assert!(parsed["file_mappings"].is_array());
292 assert!(parsed["routes"].is_array());
293 assert!(parsed["unmapped_production_files"].is_array());
294
295 assert_eq!(parsed["summary"]["production_files"], 3);
296 assert_eq!(parsed["summary"]["routes_covered"], 2);
297 assert_eq!(parsed["summary"]["routes_gap"], 1);
298 assert_eq!(parsed["summary"]["routes_unmappable"], 0);
299
300 let routes = parsed["routes"].as_array().unwrap();
302 assert_eq!(routes[0]["status"], "covered");
303 assert_eq!(routes[2]["status"], "gap");
304 assert_eq!(routes[2]["gap_reasons"][0], "no_test_mapping");
305 }
306
307 #[test]
309 fn ob4_observe_terminal_output() {
310 let report = sample_report();
311 let output = report.format_terminal();
312
313 assert!(output.contains("## Summary"), "missing Summary section");
314 assert!(
315 output.contains("## Route Coverage:"),
316 "missing Route Coverage section"
317 );
318 assert!(output.contains("3 total"), "missing routes_total in header");
319 assert!(
320 output.contains("2 covered"),
321 "missing routes_covered in header"
322 );
323 assert!(output.contains("1 gap"), "missing routes_gap in header");
324 assert!(
325 output.contains("0 unmappable"),
326 "missing routes_unmappable in header"
327 );
328 assert!(
329 output.contains("## File Mappings"),
330 "missing File Mappings section"
331 );
332 assert!(
333 output.contains("## Unmapped Production Files"),
334 "missing Unmapped section"
335 );
336 }
337
338 #[test]
340 fn ob5_route_coverage_covered() {
341 let report = sample_report();
342 let output = report.format_terminal();
343 assert!(output.contains("| GET /users | UsersController.findAll |"));
345 assert!(output.contains("| Covered |"));
346 }
347
348 #[test]
350 fn ob6_route_coverage_gap() {
351 let report = sample_report();
352 let output = report.format_terminal();
353 assert!(output.contains("| DELETE /users/:id |"));
355 assert!(output.contains("| Gap |"));
356 }
357
358 #[test]
360 fn ob7_unmapped_files_listed() {
361 let report = sample_report();
362 let output = report.format_terminal();
363 assert!(output.contains("- src/utils/helpers.ts"));
364 }
365
366 #[test]
368 fn tc01_covered_route_has_status_covered_and_empty_gap_reasons() {
369 let route = ObserveRouteEntry {
371 http_method: "GET".to_string(),
372 path: "/api/items".to_string(),
373 handler: "ItemsController.index".to_string(),
374 file: "src/items.controller.ts".to_string(),
375 test_files: vec!["src/items.controller.spec.ts".to_string()],
376 status: "covered".to_string(),
377 gap_reasons: vec![],
378 };
379
380 assert_eq!(route.status, ROUTE_STATUS_COVERED);
382 assert!(route.gap_reasons.is_empty());
383 }
384
385 #[test]
387 fn tc02_gap_route_has_status_gap_and_no_test_mapping_reason() {
388 let route = ObserveRouteEntry {
390 http_method: "POST".to_string(),
391 path: "/api/items".to_string(),
392 handler: "ItemsController.store".to_string(),
393 file: "src/items.controller.ts".to_string(),
394 test_files: vec![],
395 status: "gap".to_string(),
396 gap_reasons: vec!["no_test_mapping".to_string()],
397 };
398
399 assert_eq!(route.status, ROUTE_STATUS_GAP);
401 assert_eq!(route.gap_reasons, vec!["no_test_mapping"]);
402 }
403
404 #[test]
406 fn tc03_unmappable_route_has_status_unmappable_and_empty_gap_reasons() {
407 let route = ObserveRouteEntry {
409 http_method: "GET".to_string(),
410 path: "/health".to_string(),
411 handler: "".to_string(),
412 file: "src/app.ts".to_string(),
413 test_files: vec![],
414 status: "unmappable".to_string(),
415 gap_reasons: vec![],
416 };
417
418 assert_eq!(route.status, ROUTE_STATUS_UNMAPPABLE);
420 assert!(route.gap_reasons.is_empty());
421 }
422
423 #[test]
425 fn tc04_terminal_output_contains_routes_summary_line() {
426 let report = sample_report();
428
429 let output = report.format_terminal();
431
432 assert!(
434 output.contains("Routes: 3 total, 2 covered, 1 gap, 0 unmappable"),
435 "terminal output must contain 'Routes: X total, Y covered, Z gap, W unmappable' summary line, got:\n{output}"
436 );
437 }
438
439 #[test]
441 fn tc05_json_output_contains_status_and_gap_reasons_per_route() {
442 let report = sample_report();
444
445 let json = report.format_json();
447 let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
448
449 let routes = parsed["routes"].as_array().expect("routes is array");
451 for route in routes {
452 assert!(
453 route.get("status").is_some(),
454 "route missing 'status' field: {route}"
455 );
456 assert!(
457 route.get("gap_reasons").is_some(),
458 "route missing 'gap_reasons' field: {route}"
459 );
460 }
461
462 assert_eq!(routes[0]["status"], ROUTE_STATUS_COVERED);
464 assert_eq!(routes[0]["gap_reasons"].as_array().unwrap().len(), 0);
465 assert_eq!(routes[2]["status"], ROUTE_STATUS_GAP);
466 assert_eq!(routes[2]["gap_reasons"][0], "no_test_mapping");
467 }
468
469 #[test]
471 fn tc06_ai_prompt_lists_gap_routes_with_handler_info() {
472 let report = sample_report();
474
475 let output = report.format_ai_prompt();
477
478 assert!(
480 output.contains("DELETE /users/:id"),
481 "ai prompt must mention gap route path, got:\n{output}"
482 );
483 assert!(
484 output.contains("UsersController.remove"),
485 "ai prompt must mention gap route handler, got:\n{output}"
486 );
487 assert!(
488 output.contains("## Route Coverage Gaps"),
489 "ai prompt must have Route Coverage Gaps section, got:\n{output}"
490 );
491 }
492}