1use console::style;
12use ferro_json_ui::design::{lint, Finding, Severity};
13use ferro_json_ui::spec::{Spec, SCHEMA_VERSION};
14use serde::Serialize;
15use std::path::PathBuf;
16use walkdir::WalkDir;
17
18#[derive(Serialize)]
24pub struct FileFinding {
25 pub file: String,
27 #[serde(flatten)]
29 pub finding: Finding,
30}
31
32pub(crate) fn lint_content(file: &str, content: &str) -> Vec<FileFinding> {
42 if !content.contains(SCHEMA_VERSION) {
43 return Vec::new();
44 }
45 match Spec::from_json(content) {
46 Ok(spec) => lint(&spec)
47 .into_iter()
48 .map(|finding| FileFinding {
49 file: file.to_string(),
50 finding,
51 })
52 .collect(),
53 Err(e) => vec![FileFinding {
54 file: file.to_string(),
55 finding: Finding {
56 rule: "spec-parse",
57 element_id: None,
58 severity: Severity::Warning,
59 message: format!("Failed to parse spec: {e:?}"),
60 suggestion: "Fix the spec so it parses as ferro-json-ui/v2.".into(),
61 },
62 }],
63 }
64}
65
66pub(crate) fn has_warning(findings: &[FileFinding]) -> bool {
70 findings
71 .iter()
72 .any(|f| matches!(f.finding.severity, Severity::Warning))
73}
74
75pub fn run(path: Option<String>, json: bool, deny: bool) {
85 let root = path
86 .map(PathBuf::from)
87 .unwrap_or_else(|| PathBuf::from("src/views"));
88
89 let mut all: Vec<FileFinding> = Vec::new();
90 let mut files_linted: usize = 0;
91
92 for entry in WalkDir::new(&root)
94 .into_iter()
95 .filter_map(|e| e.ok())
96 .filter(|e| {
97 e.path()
98 .extension()
99 .map(|ext| ext == "json")
100 .unwrap_or(false)
101 })
102 {
103 let file_path = entry.path();
104 let label = file_path.display().to_string();
105 let content = match std::fs::read_to_string(file_path) {
106 Ok(c) => c,
107 Err(e) => {
108 all.push(FileFinding {
109 file: label.clone(),
110 finding: Finding {
111 rule: "file-read",
112 element_id: None,
113 severity: Severity::Warning,
114 message: format!("Could not read file: {e}"),
115 suggestion: "Check file permissions.".into(),
116 },
117 });
118 continue;
119 }
120 };
121 if content.contains(SCHEMA_VERSION) {
122 files_linted += 1;
123 }
124 all.extend(lint_content(&label, &content));
125 }
126
127 if json {
128 println!(
129 "{}",
130 serde_json::to_string_pretty(&all).unwrap_or_else(|_| "[]".into())
131 );
132 } else if all.is_empty() && files_linted == 0 {
133 println!("{}", style("No JSON-UI spec files found.").yellow());
134 } else {
135 print_human(&all);
136 }
137
138 if deny && has_warning(&all) {
139 std::process::exit(1);
140 }
141}
142
143fn print_human(all: &[FileFinding]) {
145 let mut files_seen: Vec<&str> = Vec::new();
147 for ff in all {
148 let f = ff.file.as_str();
149 if !files_seen.contains(&f) {
150 files_seen.push(f);
151 }
152 }
153
154 if files_seen.is_empty() {
155 println!(
156 "{}",
157 style("No findings — all specs are clean.").green().bold()
158 );
159 return;
160 }
161
162 for file in &files_seen {
163 println!("\n{}", style(file).bold().underlined());
164 for ff in all.iter().filter(|ff| ff.file.as_str() == *file) {
165 let sev_label = match ff.finding.severity {
166 Severity::Warning => style("warning").yellow().bold(),
167 Severity::Info => style("info").cyan(),
168 };
169 println!(
170 " {} [{}] {}",
171 sev_label,
172 style(ff.finding.rule).dim(),
173 ff.finding.message
174 );
175 println!(
176 " {} {}",
177 style("→").dim(),
178 style(&ff.finding.suggestion).dim()
179 );
180 }
181 }
182
183 let warn_count = all
184 .iter()
185 .filter(|ff| matches!(ff.finding.severity, Severity::Warning))
186 .count();
187 let info_count = all
188 .iter()
189 .filter(|ff| matches!(ff.finding.severity, Severity::Info))
190 .count();
191 println!(
192 "\n{} {} warning(s), {} info finding(s) across {} file(s)",
193 style("Summary:").bold(),
194 warn_count,
195 info_count,
196 files_seen.len()
197 );
198}
199
200#[cfg(test)]
203mod tests {
204 use super::*;
205
206 const CLEAN: &str = r#"{"$schema":"ferro-json-ui/v2","root":"t","layout":"auth","design":{"intent":"focus"},"elements":{"t":{"type":"Text","props":{"content":"hi"}}}}"#;
211
212 const WARN: &str = r#"{"$schema":"ferro-json-ui/v2","root":"t","layout":"auth","design":{"intent":"totally-made-up"},"elements":{"t":{"type":"Text","props":{"content":"hi"}}}}"#;
214
215 #[test]
216 fn lint_content_clean_zero_findings() {
217 let findings = lint_content("clean.json", CLEAN);
218 assert!(
219 findings.is_empty(),
220 "clean spec should produce no findings, got: {:#?}",
221 findings
222 .iter()
223 .map(|f| (f.finding.rule, &f.finding.message))
224 .collect::<Vec<_>>()
225 );
226 }
227
228 #[test]
229 fn lint_content_warn_one_warning_finding() {
230 let findings = lint_content("warn.json", WARN);
231 assert_eq!(
232 findings.len(),
233 1,
234 "expected exactly 1 finding, got: {:#?}",
235 findings
236 .iter()
237 .map(|f| (f.finding.rule, &f.finding.message))
238 .collect::<Vec<_>>()
239 );
240 assert_eq!(
241 findings[0].finding.severity,
242 Severity::Warning,
243 "finding must be warning-level"
244 );
245 assert_eq!(findings[0].file, "warn.json", "file field must match");
246 }
247
248 #[test]
249 fn lint_content_skip_non_marker_file() {
250 let findings = lint_content("skip.json", r#"{"hello":1}"#);
251 assert!(
252 findings.is_empty(),
253 "non-marker file must be silently skipped"
254 );
255 }
256
257 #[test]
258 fn lint_content_bad_parse_emits_spec_parse_warning() {
259 let findings = lint_content(
261 "bad.json",
262 r#"{"$schema":"ferro-json-ui/v2","root":"missing","elements":{}}"#,
263 );
264 assert_eq!(
265 findings.len(),
266 1,
267 "expected exactly 1 spec-parse finding, got: {:#?}",
268 findings
269 .iter()
270 .map(|f| (f.finding.rule, &f.finding.message))
271 .collect::<Vec<_>>()
272 );
273 assert_eq!(findings[0].finding.severity, Severity::Warning);
274 assert_eq!(findings[0].finding.rule, "spec-parse");
275 }
276
277 #[test]
278 fn has_warning_true_when_warn_level_present() {
279 let findings = lint_content("warn.json", WARN);
280 assert!(
281 has_warning(&findings),
282 "has_warning must be true for a warning-level finding"
283 );
284 }
285
286 #[test]
287 fn has_warning_false_for_clean_spec() {
288 let findings = lint_content("clean.json", CLEAN);
289 assert!(
290 !has_warning(&findings),
291 "has_warning must be false when there are no findings"
292 );
293 }
294
295 #[test]
296 fn has_warning_false_for_skipped_file() {
297 let findings = lint_content("skip.json", r#"{"hello":1}"#);
298 assert!(
299 !has_warning(&findings),
300 "has_warning must be false for silently skipped files"
301 );
302 }
303
304 #[test]
305 fn has_warning_true_for_file_read_finding() {
306 let findings = vec![FileFinding {
309 file: "unreadable.json".into(),
310 finding: Finding {
311 rule: "file-read",
312 element_id: None,
313 severity: Severity::Warning,
314 message: "Could not read file: permission denied (os error 13)".into(),
315 suggestion: "Check file permissions.".into(),
316 },
317 }];
318 assert!(
319 has_warning(&findings),
320 "file-read finding must be Warning severity so --deny triggers"
321 );
322 }
323}