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