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/// Lint command options
17#[derive(Debug, Clone)]
18pub struct LintOptions {
19    /// Exit with error if any critical issues found
20    pub fail_on_critical: bool,
21    /// Exit with error if any warning or critical issues found
22    pub fail_on_warning:  bool,
23}
24
25/// Lint output response
26#[derive(Debug, Serialize)]
27pub struct LintResponse {
28    /// Overall design score (0-100)
29    pub overall_score:   u8,
30    /// Severity counts
31    pub severity_counts: SeverityCounts,
32    /// Category scores
33    pub categories:      CategoryScores,
34}
35
36/// Severity counts in audit
37#[derive(Debug, Serialize)]
38pub struct SeverityCounts {
39    /// Critical issues
40    pub critical: usize,
41    /// Warning issues
42    pub warning:  usize,
43    /// Info issues
44    pub info:     usize,
45}
46
47/// Category scores
48#[derive(Debug, Serialize)]
49pub struct CategoryScores {
50    /// Federation audit score
51    pub federation:    u8,
52    /// Cost audit score
53    pub cost:          u8,
54    /// Cache audit score
55    pub cache:         u8,
56    /// Authorization audit score
57    pub authorization: u8,
58    /// Compilation audit score
59    pub compilation:   u8,
60}
61
62/// Run lint command on a schema
63pub fn run(schema_path: &str, opts: LintOptions) -> Result<CommandResult> {
64    // Check if file exists
65    if !Path::new(schema_path).exists() {
66        return Ok(CommandResult::error(
67            "lint",
68            &format!("Schema file not found: {schema_path}"),
69            "FILE_NOT_FOUND",
70        ));
71    }
72
73    // Read schema file
74    let schema_json = fs::read_to_string(schema_path)?;
75
76    // Parse as JSON to validate it
77    let _schema: serde_json::Value = serde_json::from_str(&schema_json)?;
78
79    // Run design audit
80    let audit = DesignAudit::from_schema_json(&schema_json)?;
81
82    // Check for fail conditions if enabled
83    if opts.fail_on_critical
84        && audit.severity_count(fraiseql_core::design::IssueSeverity::Critical) > 0
85    {
86        return Ok(CommandResult::error(
87            "lint",
88            "Design audit failed: critical issues found",
89            "DESIGN_AUDIT_FAILED",
90        ));
91    }
92
93    if opts.fail_on_warning
94        && audit.severity_count(fraiseql_core::design::IssueSeverity::Warning) > 0
95    {
96        return Ok(CommandResult::error(
97            "lint",
98            "Design audit failed: warning issues found",
99            "DESIGN_AUDIT_FAILED",
100        ));
101    }
102
103    // Calculate category scores
104    let fed_score = if audit.federation_issues.is_empty() {
105        100
106    } else {
107        let count = u32::try_from(audit.federation_issues.len()).unwrap_or(u32::MAX);
108        (100u32 - (count * 10)).clamp(0, 100) as u8
109    };
110
111    let cost_score = if audit.cost_warnings.is_empty() {
112        100
113    } else {
114        let count = u32::try_from(audit.cost_warnings.len()).unwrap_or(u32::MAX);
115        (100u32 - (count * 8)).clamp(0, 100) as u8
116    };
117
118    let cache_score = if audit.cache_issues.is_empty() {
119        100
120    } else {
121        let count = u32::try_from(audit.cache_issues.len()).unwrap_or(u32::MAX);
122        (100u32 - (count * 6)).clamp(0, 100) as u8
123    };
124
125    let auth_score = if audit.auth_issues.is_empty() {
126        100
127    } else {
128        let count = u32::try_from(audit.auth_issues.len()).unwrap_or(u32::MAX);
129        (100u32 - (count * 12)).clamp(0, 100) as u8
130    };
131
132    let comp_score = if audit.schema_issues.is_empty() {
133        100
134    } else {
135        let count = u32::try_from(audit.schema_issues.len()).unwrap_or(u32::MAX);
136        (100u32 - (count * 10)).clamp(0, 100) as u8
137    };
138
139    let severity_counts = SeverityCounts {
140        critical: audit.severity_count(fraiseql_core::design::IssueSeverity::Critical),
141        warning:  audit.severity_count(fraiseql_core::design::IssueSeverity::Warning),
142        info:     audit.severity_count(fraiseql_core::design::IssueSeverity::Info),
143    };
144
145    let response = LintResponse {
146        overall_score: audit.score(),
147        severity_counts,
148        categories: CategoryScores {
149            federation:    fed_score,
150            cost:          cost_score,
151            cache:         cache_score,
152            authorization: auth_score,
153            compilation:   comp_score,
154        },
155    };
156
157    Ok(CommandResult::success("lint", serde_json::to_value(&response)?))
158}
159
160#[cfg(test)]
161mod tests {
162    use std::io::Write;
163
164    use tempfile::NamedTempFile;
165
166    use super::*;
167
168    fn default_opts() -> LintOptions {
169        LintOptions {
170            fail_on_critical: false,
171            fail_on_warning:  false,
172        }
173    }
174
175    #[test]
176    fn test_lint_valid_schema() {
177        let schema_json = r#"{
178            "types": [
179                {
180                    "name": "Query",
181                    "fields": [
182                        {"name": "users", "type": "[User!]"}
183                    ]
184                },
185                {
186                    "name": "User",
187                    "fields": [
188                        {"name": "id", "type": "ID", "isPrimaryKey": true},
189                        {"name": "name", "type": "String"}
190                    ]
191                }
192            ]
193        }"#;
194
195        let mut file = NamedTempFile::new().unwrap();
196        file.write_all(schema_json.as_bytes()).unwrap();
197        let path = file.path().to_str().unwrap();
198
199        let result = run(path, default_opts());
200        assert!(result.is_ok());
201
202        let cmd_result = result.unwrap();
203        assert_eq!(cmd_result.status, "success");
204        assert_eq!(cmd_result.command, "lint");
205        assert!(cmd_result.data.is_some());
206    }
207
208    #[test]
209    fn test_lint_file_not_found() {
210        let result = run("nonexistent_schema.json", default_opts());
211        assert!(result.is_ok());
212
213        let cmd_result = result.unwrap();
214        assert_eq!(cmd_result.status, "error");
215        assert_eq!(cmd_result.code, Some("FILE_NOT_FOUND".to_string()));
216    }
217
218    #[test]
219    fn test_lint_returns_score() {
220        let schema_json = r#"{"types": []}"#;
221
222        let mut file = NamedTempFile::new().unwrap();
223        file.write_all(schema_json.as_bytes()).unwrap();
224        let path = file.path().to_str().unwrap();
225
226        let result = run(path, default_opts());
227        assert!(result.is_ok());
228
229        let cmd_result = result.unwrap();
230        if let Some(data) = &cmd_result.data {
231            assert!(data.get("overall_score").is_some());
232            assert!(data.get("severity_counts").is_some());
233            assert!(data.get("categories").is_some());
234        }
235    }
236}