fraiseql_cli/commands/
lint.rs1use std::{fs, path::Path};
9
10use anyhow::Result;
11use fraiseql_core::design::DesignAudit;
12use serde::Serialize;
13
14use crate::output::CommandResult;
15
16#[derive(Debug, Clone, Default)]
22pub struct LintCategoryFilter {
23 pub federation: bool,
25 pub cost: bool,
27 pub cache: bool,
29 pub auth: bool,
31 pub compilation: bool,
33}
34
35impl LintCategoryFilter {
36 pub fn is_all(&self) -> bool {
38 !self.federation && !self.cost && !self.cache && !self.auth && !self.compilation
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct LintOptions {
45 pub fail_on_critical: bool,
47 pub fail_on_warning: bool,
49 pub filter: LintCategoryFilter,
51}
52
53#[derive(Debug, Serialize)]
55pub struct LintResponse {
56 pub overall_score: u8,
58 pub severity_counts: SeverityCounts,
60 pub categories: CategoryScores,
62}
63
64#[derive(Debug, Serialize)]
66pub struct SeverityCounts {
67 pub critical: usize,
69 pub warning: usize,
71 pub info: usize,
73}
74
75#[derive(Debug, Serialize)]
77pub struct CategoryScores {
78 pub federation: u8,
80 pub cost: u8,
82 pub cache: u8,
84 pub authorization: u8,
86 pub compilation: u8,
88}
89
90pub fn run(schema_path: &str, opts: LintOptions) -> Result<CommandResult> {
97 if !Path::new(schema_path).exists() {
99 return Err(anyhow::anyhow!("Schema file not found: {schema_path}"));
100 }
101
102 let schema_json = fs::read_to_string(schema_path)?;
104
105 let _schema: serde_json::Value = serde_json::from_str(&schema_json)?;
107
108 let audit = DesignAudit::from_schema_json(&schema_json)?;
110
111 let f = &opts.filter;
112 let show_all = f.is_all();
113
114 let fed_issues = if show_all || f.federation {
117 audit.federation_issues.len()
118 } else {
119 0
120 };
121 let cost_issues = if show_all || f.cost {
122 audit.cost_warnings.len()
123 } else {
124 0
125 };
126 let cache_issues = if show_all || f.cache {
127 audit.cache_issues.len()
128 } else {
129 0
130 };
131 let auth_issues = if show_all || f.auth {
132 audit.auth_issues.len()
133 } else {
134 0
135 };
136 let comp_issues = if show_all || f.compilation {
137 audit.schema_issues.len()
138 } else {
139 0
140 };
141
142 let visible_critical = if show_all {
144 audit.severity_count(fraiseql_core::design::IssueSeverity::Critical)
145 } else {
146 use fraiseql_core::design::IssueSeverity;
150 let mut n = 0;
151 if f.federation {
152 n += audit
153 .federation_issues
154 .iter()
155 .filter(|i| i.severity == IssueSeverity::Critical)
156 .count();
157 }
158 if f.cost {
159 n += audit
160 .cost_warnings
161 .iter()
162 .filter(|i| i.severity == IssueSeverity::Critical)
163 .count();
164 }
165 if f.cache {
166 n += audit
167 .cache_issues
168 .iter()
169 .filter(|i| i.severity == IssueSeverity::Critical)
170 .count();
171 }
172 if f.auth {
173 n += audit
174 .auth_issues
175 .iter()
176 .filter(|i| i.severity == IssueSeverity::Critical)
177 .count();
178 }
179 if f.compilation {
180 n += audit
181 .schema_issues
182 .iter()
183 .filter(|i| i.severity == IssueSeverity::Critical)
184 .count();
185 }
186 n
187 };
188
189 if opts.fail_on_critical && visible_critical > 0 {
190 return Ok(CommandResult::error(
191 "lint",
192 "Design audit failed: critical issues found",
193 "DESIGN_AUDIT_FAILED",
194 ));
195 }
196
197 let visible_warning = if show_all {
198 audit.severity_count(fraiseql_core::design::IssueSeverity::Warning)
199 } else {
200 use fraiseql_core::design::IssueSeverity;
201 let mut n = 0;
202 if f.federation {
203 n += audit
204 .federation_issues
205 .iter()
206 .filter(|i| i.severity == IssueSeverity::Warning)
207 .count();
208 }
209 if f.cost {
210 n += audit
211 .cost_warnings
212 .iter()
213 .filter(|i| i.severity == IssueSeverity::Warning)
214 .count();
215 }
216 if f.cache {
217 n += audit
218 .cache_issues
219 .iter()
220 .filter(|i| i.severity == IssueSeverity::Warning)
221 .count();
222 }
223 if f.auth {
224 n += audit
225 .auth_issues
226 .iter()
227 .filter(|i| i.severity == IssueSeverity::Warning)
228 .count();
229 }
230 if f.compilation {
231 n += audit
232 .schema_issues
233 .iter()
234 .filter(|i| i.severity == IssueSeverity::Warning)
235 .count();
236 }
237 n
238 };
239
240 if opts.fail_on_warning && visible_warning > 0 {
241 return Ok(CommandResult::error(
242 "lint",
243 "Design audit failed: warning issues found",
244 "DESIGN_AUDIT_FAILED",
245 ));
246 }
247
248 let score_from_count = |count: usize, penalty: u32| -> u8 {
250 let n = u32::try_from(count).unwrap_or(u32::MAX);
251 #[allow(clippy::cast_possible_truncation)] let score = 100u32.saturating_sub(n * penalty) as u8;
254 score
255 };
256
257 let fed_score = if fed_issues == 0 {
258 100
259 } else {
260 score_from_count(fed_issues, 10)
261 };
262 let cost_score = if cost_issues == 0 {
263 100
264 } else {
265 score_from_count(cost_issues, 8)
266 };
267 let cache_score = if cache_issues == 0 {
268 100
269 } else {
270 score_from_count(cache_issues, 6)
271 };
272 let auth_score = if auth_issues == 0 {
273 100
274 } else {
275 score_from_count(auth_issues, 12)
276 };
277 let comp_score = if comp_issues == 0 {
278 100
279 } else {
280 score_from_count(comp_issues, 10)
281 };
282
283 let severity_counts = SeverityCounts {
284 critical: visible_critical,
285 warning: visible_warning,
286 info: if show_all {
287 audit.severity_count(fraiseql_core::design::IssueSeverity::Info)
288 } else {
289 0 },
291 };
292
293 let response = LintResponse {
294 overall_score: audit.score(),
295 severity_counts,
296 categories: CategoryScores {
297 federation: fed_score,
298 cost: cost_score,
299 cache: cache_score,
300 authorization: auth_score,
301 compilation: comp_score,
302 },
303 };
304
305 Ok(CommandResult::success("lint", serde_json::to_value(&response)?))
306}
307
308#[allow(clippy::unwrap_used)] #[cfg(test)]
310mod tests {
311 use std::io::Write;
312
313 use tempfile::NamedTempFile;
314
315 use super::*;
316
317 fn default_opts() -> LintOptions {
318 LintOptions {
319 fail_on_critical: false,
320 fail_on_warning: false,
321 filter: LintCategoryFilter::default(),
322 }
323 }
324
325 #[test]
326 fn test_lint_valid_schema() {
327 let schema_json = r#"{
328 "types": [
329 {
330 "name": "Query",
331 "fields": [
332 {"name": "users", "type": "[User!]"}
333 ]
334 },
335 {
336 "name": "User",
337 "fields": [
338 {"name": "id", "type": "ID", "isPrimaryKey": true},
339 {"name": "name", "type": "String"}
340 ]
341 }
342 ]
343 }"#;
344
345 let mut file = NamedTempFile::new().unwrap();
346 file.write_all(schema_json.as_bytes()).unwrap();
347 let path = file.path().to_str().unwrap();
348
349 let result = run(path, default_opts());
350 let cmd_result = result.unwrap_or_else(|e| panic!("expected Ok from lint run: {e:?}"));
351 assert_eq!(cmd_result.status, "success");
352 assert_eq!(cmd_result.command, "lint");
353 assert!(cmd_result.data.is_some());
354 }
355
356 #[test]
357 fn test_lint_file_not_found() {
358 let result = run("nonexistent_schema.json", default_opts());
359 assert!(result.is_err(), "file-not-found must return Err");
360 }
361
362 #[test]
363 fn test_lint_returns_score() {
364 let schema_json = r#"{"types": []}"#;
365
366 let mut file = NamedTempFile::new().unwrap();
367 file.write_all(schema_json.as_bytes()).unwrap();
368 let path = file.path().to_str().unwrap();
369
370 let result = run(path, default_opts());
371 let cmd_result = result.unwrap_or_else(|e| panic!("expected Ok from lint run: {e:?}"));
372 if let Some(data) = &cmd_result.data {
373 assert!(data.get("overall_score").is_some());
374 assert!(data.get("severity_counts").is_some());
375 assert!(data.get("categories").is_some());
376 }
377 }
378}