Skip to main content

exspec_core/
observe_report.rs

1use serde::Serialize;
2
3/// Status constants for route coverage.
4pub const ROUTE_STATUS_COVERED: &str = "covered";
5pub const ROUTE_STATUS_GAP: &str = "gap";
6pub const ROUTE_STATUS_UNMAPPABLE: &str = "unmappable";
7
8/// A route with test coverage information.
9#[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,           // "covered" | "gap" | "unmappable"
17    pub gap_reasons: Vec<String>, // e.g. ["no_test_mapping"]
18}
19
20/// A file mapping entry for the report.
21#[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/// Summary statistics for observe report.
29#[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/// Full observe report.
42#[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    /// Format as terminal-friendly Markdown.
52    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        // Summary
58        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        // Route Coverage
76        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        // File Mappings
120        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        // Unmapped
138        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    /// Format as JSON.
149    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    /// Format as AI-friendly prompt for test generation guidance.
173    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    // OB2: summary counts are accurate
268    #[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    // OB3: JSON output is valid and has required fields
282    #[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        // Route entries have status and gap_reasons
301        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    // OB4: terminal output contains expected sections
308    #[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    // OB5: covered route shows "Covered"
339    #[test]
340    fn ob5_route_coverage_covered() {
341        let report = sample_report();
342        let output = report.format_terminal();
343        // GET /users route has test files -> Covered
344        assert!(output.contains("| GET /users | UsersController.findAll |"));
345        assert!(output.contains("| Covered |"));
346    }
347
348    // OB6: gap route shows "Gap"
349    #[test]
350    fn ob6_route_coverage_gap() {
351        let report = sample_report();
352        let output = report.format_terminal();
353        // DELETE /users/:id has no test files -> Gap
354        assert!(output.contains("| DELETE /users/:id |"));
355        assert!(output.contains("| Gap |"));
356    }
357
358    // OB7: unmapped files are listed
359    #[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    // TC-01: Given route with test_files, When report built, Then status="covered" and gap_reasons=[]
367    #[test]
368    fn tc01_covered_route_has_status_covered_and_empty_gap_reasons() {
369        // Given: a route entry with test_files populated
370        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        // When / Then
381        assert_eq!(route.status, ROUTE_STATUS_COVERED);
382        assert!(route.gap_reasons.is_empty());
383    }
384
385    // TC-02: Given route with handler but no test_files, When report built, Then status="gap" and gap_reasons=["no_test_mapping"]
386    #[test]
387    fn tc02_gap_route_has_status_gap_and_no_test_mapping_reason() {
388        // Given: a route entry with a handler but no test_files
389        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        // When / Then
400        assert_eq!(route.status, ROUTE_STATUS_GAP);
401        assert_eq!(route.gap_reasons, vec!["no_test_mapping"]);
402    }
403
404    // TC-03: Given route with empty handler, When report built, Then status="unmappable" and gap_reasons=[]
405    #[test]
406    fn tc03_unmappable_route_has_status_unmappable_and_empty_gap_reasons() {
407        // Given: a route entry with an empty handler (e.g. closure)
408        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        // When / Then
419        assert_eq!(route.status, ROUTE_STATUS_UNMAPPABLE);
420        assert!(route.gap_reasons.is_empty());
421    }
422
423    // TC-04: Terminal output contains "Routes: X total, Y covered, Z gap, W unmappable" summary line
424    #[test]
425    fn tc04_terminal_output_contains_routes_summary_line() {
426        // Given: a report with route coverage data
427        let report = sample_report();
428
429        // When
430        let output = report.format_terminal();
431
432        // Then: output must contain a standalone summary line in this exact format
433        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    // TC-05: JSON output contains status and gap_reasons fields for each route
440    #[test]
441    fn tc05_json_output_contains_status_and_gap_reasons_per_route() {
442        // Given
443        let report = sample_report();
444
445        // When
446        let json = report.format_json();
447        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
448
449        // Then: every route entry must have status and gap_reasons
450        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        // Verify specific values
463        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    // TC-06: AI prompt output lists gap routes with handler info
470    #[test]
471    fn tc06_ai_prompt_lists_gap_routes_with_handler_info() {
472        // Given
473        let report = sample_report();
474
475        // When
476        let output = report.format_ai_prompt();
477
478        // Then: output must list the gap route with its handler
479        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}