Skip to main content

fraiseql_cli/commands/
lint.rs

1//! Lint command - Design quality analysis for schemas
2//!
3//! Usage: fraiseql lint schema.json [--federation] [--cost] [--cache] [--auth] [--compilation]
4//!        fraiseql lint schema.json --format=json
5//!        fraiseql lint schema.json --fail-on-critical
6//!        fraiseql lint schema.json --verbose --fail-on-warning
7
8use std::{fs, path::Path};
9
10use anyhow::Result;
11use fraiseql_core::design::DesignAudit;
12use serde::Serialize;
13
14use crate::output::CommandResult;
15
16/// Category filter for the lint command.
17///
18/// When all fields are `false` (the default) every category is included.
19/// When any field is `true` only the selected categories are included in the
20/// output; unselected categories report a score of 100 with zero issues.
21#[derive(Debug, Clone, Default)]
22pub struct LintCategoryFilter {
23    /// Include only federation audit results
24    pub federation:  bool,
25    /// Include only cost audit results
26    pub cost:        bool,
27    /// Include only cache audit results
28    pub cache:       bool,
29    /// Include only auth audit results
30    pub auth:        bool,
31    /// Include only compilation audit results
32    pub compilation: bool,
33}
34
35impl LintCategoryFilter {
36    /// Returns `true` when no specific category was selected (i.e. show all).
37    pub fn is_all(&self) -> bool {
38        !self.federation && !self.cost && !self.cache && !self.auth && !self.compilation
39    }
40}
41
42/// Lint command options
43#[derive(Debug, Clone)]
44pub struct LintOptions {
45    /// Exit with error if any critical issues found
46    pub fail_on_critical: bool,
47    /// Exit with error if any warning or critical issues found
48    pub fail_on_warning:  bool,
49    /// Category filter (empty = show all)
50    pub filter:           LintCategoryFilter,
51}
52
53/// Lint output response
54#[derive(Debug, Serialize)]
55pub struct LintResponse {
56    /// Overall design score (0-100)
57    pub overall_score:   u8,
58    /// Severity counts
59    pub severity_counts: SeverityCounts,
60    /// Category scores
61    pub categories:      CategoryScores,
62}
63
64/// Severity counts in audit
65#[derive(Debug, Serialize)]
66pub struct SeverityCounts {
67    /// Critical issues
68    pub critical: usize,
69    /// Warning issues
70    pub warning:  usize,
71    /// Info issues
72    pub info:     usize,
73}
74
75/// Category scores
76#[derive(Debug, Serialize)]
77pub struct CategoryScores {
78    /// Federation audit score
79    pub federation:    u8,
80    /// Cost audit score
81    pub cost:          u8,
82    /// Cache audit score
83    pub cache:         u8,
84    /// Authorization audit score
85    pub authorization: u8,
86    /// Compilation audit score
87    pub compilation:   u8,
88}
89
90/// Run lint command on a schema
91///
92/// # Errors
93///
94/// Returns an error if the schema file does not exist, cannot be read, cannot be
95/// parsed as JSON, or if design audit initialization fails.
96pub fn run(schema_path: &str, opts: LintOptions) -> Result<CommandResult> {
97    // Check if file exists
98    if !Path::new(schema_path).exists() {
99        return Err(anyhow::anyhow!("Schema file not found: {schema_path}"));
100    }
101
102    // Read schema file
103    let schema_json = fs::read_to_string(schema_path)?;
104
105    // Parse as JSON to validate it
106    let _schema: serde_json::Value = serde_json::from_str(&schema_json)?;
107
108    // Run design audit
109    let audit = DesignAudit::from_schema_json(&schema_json)?;
110
111    let f = &opts.filter;
112    let show_all = f.is_all();
113
114    // When category flags are given, treat unselected categories as empty so
115    // they don't affect severity counts or scores.
116    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    // Check for fail conditions if enabled (only considering visible categories).
143    let visible_critical = if show_all {
144        audit.severity_count(fraiseql_core::design::IssueSeverity::Critical)
145    } else {
146        // Approximate: re-count by iterating visible issue buckets.
147        // The DesignAudit API exposes per-category issue lists; sum critical
148        // issues only from selected categories.
149        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    // Calculate category scores from visible issue counts.
249    let score_from_count = |count: usize, penalty: u32| -> u8 {
250        let n = u32::try_from(count).unwrap_or(u32::MAX);
251        // saturating_sub produces a value in 0..=100, which always fits in u8.
252        #[allow(clippy::cast_possible_truncation)] // Reason: result is clamped to ≤100, fits u8
253        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 // approximate; info counts not filtered per-category in this pass
290        },
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)] // Reason: test code, panics are acceptable
309#[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}