1use serde::Serialize;
2
3#[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#[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#[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#[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 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 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 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 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 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 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 #[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 #[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 #[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 #[test]
252 fn ob5_route_coverage_covered() {
253 let report = sample_report();
254 let output = report.format_terminal();
255 assert!(output.contains("| GET /users | UsersController.findAll |"));
257 assert!(output.contains("| Covered |"));
258 }
259
260 #[test]
262 fn ob6_route_coverage_gap() {
263 let report = sample_report();
264 let output = report.format_terminal();
265 assert!(output.contains("| DELETE /users/:id |"));
267 assert!(output.contains("| Gap |"));
268 }
269
270 #[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}