Skip to main content

exspec_core/
observe_report.rs

1use serde::Serialize;
2
3/// A route with test coverage information.
4#[derive(Debug, Clone, Serialize)]
5pub struct ObserveRouteEntry {
6    pub http_method: String,
7    pub path: String,
8    pub handler: String,
9    pub file: String,
10    pub test_files: Vec<String>,
11}
12
13/// A file mapping entry for the report.
14#[derive(Debug, Clone, Serialize)]
15pub struct ObserveFileEntry {
16    pub production_file: String,
17    pub test_files: Vec<String>,
18    pub strategy: String,
19}
20
21/// Summary statistics for observe report.
22#[derive(Debug, Clone, Serialize)]
23pub struct ObserveSummary {
24    pub production_files: usize,
25    pub test_files: usize,
26    pub mapped_files: usize,
27    pub unmapped_files: usize,
28    pub routes_total: usize,
29    pub routes_covered: usize,
30}
31
32/// Full observe report.
33#[derive(Debug, Clone, Serialize)]
34pub struct ObserveReport {
35    pub summary: ObserveSummary,
36    pub file_mappings: Vec<ObserveFileEntry>,
37    pub routes: Vec<ObserveRouteEntry>,
38    pub unmapped_production_files: Vec<String>,
39}
40
41impl ObserveReport {
42    /// Format as terminal-friendly Markdown.
43    pub fn format_terminal(&self) -> String {
44        let mut out = String::new();
45
46        out.push_str("# exspec observe -- Test Coverage Map\n\n");
47
48        // Summary
49        out.push_str("## Summary\n");
50        out.push_str(&format!(
51            "- Production files: {}\n",
52            self.summary.production_files
53        ));
54        out.push_str(&format!("- Test files: {}\n", self.summary.test_files));
55        let pct = if self.summary.production_files > 0 {
56            self.summary.mapped_files as f64 / self.summary.production_files as f64 * 100.0
57        } else {
58            0.0
59        };
60        out.push_str(&format!(
61            "- Mapped: {} ({:.1}%)\n",
62            self.summary.mapped_files, pct
63        ));
64        out.push_str(&format!("- Unmapped: {}\n", self.summary.unmapped_files));
65
66        // Route Coverage
67        if self.summary.routes_total > 0 {
68            out.push_str(&format!(
69                "\n## Route Coverage ({}/{})\n",
70                self.summary.routes_covered, self.summary.routes_total
71            ));
72            out.push_str("| Route | Handler | Test File | Status |\n");
73            out.push_str("|-------|---------|-----------|--------|\n");
74            for route in &self.routes {
75                let status = if route.test_files.is_empty() {
76                    "Gap"
77                } else {
78                    "Covered"
79                };
80                let test_display = if route.test_files.is_empty() {
81                    "\u{2014}".to_string()
82                } else {
83                    route.test_files.join(", ")
84                };
85                out.push_str(&format!(
86                    "| {} {} | {} | {} | {} |\n",
87                    route.http_method, route.path, route.handler, test_display, status
88                ));
89            }
90        }
91
92        // File Mappings
93        if !self.file_mappings.is_empty() {
94            out.push_str("\n## File Mappings\n");
95            out.push_str("| Production File | Test File(s) | Strategy |\n");
96            out.push_str("|----------------|-------------|----------|\n");
97            for entry in &self.file_mappings {
98                let tests = if entry.test_files.is_empty() {
99                    "\u{2014}".to_string()
100                } else {
101                    entry.test_files.join(", ")
102                };
103                out.push_str(&format!(
104                    "| {} | {} | {} |\n",
105                    entry.production_file, tests, entry.strategy
106                ));
107            }
108        }
109
110        // Unmapped
111        if !self.unmapped_production_files.is_empty() {
112            out.push_str("\n## Unmapped Production Files\n");
113            for f in &self.unmapped_production_files {
114                out.push_str(&format!("- {f}\n"));
115            }
116        }
117
118        out
119    }
120
121    /// Format as JSON.
122    pub fn format_json(&self) -> String {
123        #[derive(Serialize)]
124        struct JsonOutput<'a> {
125            version: &'a str,
126            mode: &'a str,
127            summary: &'a ObserveSummary,
128            file_mappings: &'a [ObserveFileEntry],
129            routes: &'a [ObserveRouteEntry],
130            unmapped_production_files: &'a [String],
131        }
132
133        let output = JsonOutput {
134            version: env!("CARGO_PKG_VERSION"),
135            mode: "observe",
136            summary: &self.summary,
137            file_mappings: &self.file_mappings,
138            routes: &self.routes,
139            unmapped_production_files: &self.unmapped_production_files,
140        };
141
142        serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn sample_report() -> ObserveReport {
151        ObserveReport {
152            summary: ObserveSummary {
153                production_files: 3,
154                test_files: 2,
155                mapped_files: 2,
156                unmapped_files: 1,
157                routes_total: 3,
158                routes_covered: 2,
159            },
160            file_mappings: vec![
161                ObserveFileEntry {
162                    production_file: "src/users.controller.ts".to_string(),
163                    test_files: vec!["src/users.controller.spec.ts".to_string()],
164                    strategy: "import".to_string(),
165                },
166                ObserveFileEntry {
167                    production_file: "src/users.service.ts".to_string(),
168                    test_files: vec!["src/users.service.spec.ts".to_string()],
169                    strategy: "filename".to_string(),
170                },
171            ],
172            routes: vec![
173                ObserveRouteEntry {
174                    http_method: "GET".to_string(),
175                    path: "/users".to_string(),
176                    handler: "UsersController.findAll".to_string(),
177                    file: "src/users.controller.ts".to_string(),
178                    test_files: vec!["src/users.controller.spec.ts".to_string()],
179                },
180                ObserveRouteEntry {
181                    http_method: "POST".to_string(),
182                    path: "/users".to_string(),
183                    handler: "UsersController.create".to_string(),
184                    file: "src/users.controller.ts".to_string(),
185                    test_files: vec!["src/users.controller.spec.ts".to_string()],
186                },
187                ObserveRouteEntry {
188                    http_method: "DELETE".to_string(),
189                    path: "/users/:id".to_string(),
190                    handler: "UsersController.remove".to_string(),
191                    file: "src/utils/helpers.ts".to_string(),
192                    test_files: vec![],
193                },
194            ],
195            unmapped_production_files: vec!["src/utils/helpers.ts".to_string()],
196        }
197    }
198
199    // OB2: summary counts are accurate
200    #[test]
201    fn ob2_observe_report_summary() {
202        let report = sample_report();
203        assert_eq!(report.summary.production_files, 3);
204        assert_eq!(report.summary.test_files, 2);
205        assert_eq!(report.summary.mapped_files, 2);
206        assert_eq!(report.summary.unmapped_files, 1);
207        assert_eq!(report.summary.routes_total, 3);
208        assert_eq!(report.summary.routes_covered, 2);
209    }
210
211    // OB3: JSON output is valid and has required fields
212    #[test]
213    fn ob3_observe_json_output() {
214        let report = sample_report();
215        let json = report.format_json();
216        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
217
218        assert_eq!(parsed["mode"], "observe");
219        assert!(parsed["version"].is_string());
220        assert!(parsed["summary"].is_object());
221        assert!(parsed["file_mappings"].is_array());
222        assert!(parsed["routes"].is_array());
223        assert!(parsed["unmapped_production_files"].is_array());
224
225        assert_eq!(parsed["summary"]["production_files"], 3);
226        assert_eq!(parsed["summary"]["routes_covered"], 2);
227    }
228
229    // OB4: terminal output contains expected sections
230    #[test]
231    fn ob4_observe_terminal_output() {
232        let report = sample_report();
233        let output = report.format_terminal();
234
235        assert!(output.contains("## Summary"), "missing Summary section");
236        assert!(
237            output.contains("## Route Coverage"),
238            "missing Route Coverage section"
239        );
240        assert!(
241            output.contains("## File Mappings"),
242            "missing File Mappings section"
243        );
244        assert!(
245            output.contains("## Unmapped Production Files"),
246            "missing Unmapped section"
247        );
248    }
249
250    // OB5: covered route shows "Covered"
251    #[test]
252    fn ob5_route_coverage_covered() {
253        let report = sample_report();
254        let output = report.format_terminal();
255        // GET /users route has test files -> Covered
256        assert!(output.contains("| GET /users | UsersController.findAll |"));
257        assert!(output.contains("| Covered |"));
258    }
259
260    // OB6: gap route shows "Gap"
261    #[test]
262    fn ob6_route_coverage_gap() {
263        let report = sample_report();
264        let output = report.format_terminal();
265        // DELETE /users/:id has no test files -> Gap
266        assert!(output.contains("| DELETE /users/:id |"));
267        assert!(output.contains("| Gap |"));
268    }
269
270    // OB7: unmapped files are listed
271    #[test]
272    fn ob7_unmapped_files_listed() {
273        let report = sample_report();
274        let output = report.format_terminal();
275        assert!(output.contains("- src/utils/helpers.ts"));
276    }
277}