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)]
18pub struct LintOptions {
19 pub fail_on_critical: bool,
21 pub fail_on_warning: bool,
23}
24
25#[derive(Debug, Serialize)]
27pub struct LintResponse {
28 pub overall_score: u8,
30 pub severity_counts: SeverityCounts,
32 pub categories: CategoryScores,
34}
35
36#[derive(Debug, Serialize)]
38pub struct SeverityCounts {
39 pub critical: usize,
41 pub warning: usize,
43 pub info: usize,
45}
46
47#[derive(Debug, Serialize)]
49pub struct CategoryScores {
50 pub federation: u8,
52 pub cost: u8,
54 pub cache: u8,
56 pub authorization: u8,
58 pub compilation: u8,
60}
61
62pub fn run(schema_path: &str, opts: LintOptions) -> Result<CommandResult> {
64 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 let schema_json = fs::read_to_string(schema_path)?;
75
76 let _schema: serde_json::Value = serde_json::from_str(&schema_json)?;
78
79 let audit = DesignAudit::from_schema_json(&schema_json)?;
81
82 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 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}