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)]
18#[allow(dead_code)]
19pub struct LintOptions {
20 pub federation: bool,
22 pub cost: bool,
24 pub cache: bool,
26 pub auth: bool,
28 pub compilation: bool,
30 pub fail_on_critical: bool,
32 pub fail_on_warning: bool,
34 pub verbose: bool,
36}
37
38#[derive(Debug, Serialize)]
40pub struct LintResponse {
41 pub overall_score: u8,
43 pub severity_counts: SeverityCounts,
45 pub categories: CategoryScores,
47}
48
49#[derive(Debug, Serialize)]
51pub struct SeverityCounts {
52 pub critical: usize,
54 pub warning: usize,
56 pub info: usize,
58}
59
60#[derive(Debug, Serialize)]
62pub struct CategoryScores {
63 pub federation: u8,
65 pub cost: u8,
67 pub cache: u8,
69 pub authorization: u8,
71 pub compilation: u8,
73}
74
75pub fn run(schema_path: &str, opts: LintOptions) -> Result<CommandResult> {
77 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 let schema_json = fs::read_to_string(schema_path)?;
88
89 let _schema: serde_json::Value = serde_json::from_str(&schema_json)?;
91
92 let audit = DesignAudit::from_schema_json(&schema_json)?;
94
95 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 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}