Skip to main content

ferro_cli/commands/
design_lint.rs

1//! design:lint command — walk JSON-UI spec files and report design-pattern findings.
2//!
3//! Discovers `*.json` files under a directory (default `src/views`), skips files
4//! without the `ferro-json-ui/v2` schema marker, and runs `ferro_json_ui::design::lint`
5//! on every matching spec. Findings are grouped by file in human-readable mode or
6//! emitted as a flat JSON array via `--json`.
7//!
8//! Exit code is 0 unless `--deny` is set and at least one warning-level finding exists.
9//! Info findings never cause a non-zero exit.
10
11use 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/// One finding tagged with the file it came from.
19///
20/// This flat shape is the stable `--json` contract consumed by downstream CI
21/// (gestiscilo Phase 232). The `file` field identifies the source file;
22/// all other fields come directly from [`Finding`] via `#[serde(flatten)]`.
23#[derive(Serialize)]
24pub struct FileFinding {
25    /// Path to the source file, as discovered by the walker.
26    pub file: String,
27    /// The finding from the design lint engine.
28    #[serde(flatten)]
29    pub finding: Finding,
30}
31
32/// Lint the JSON content of a single named file.
33///
34/// Returns an empty vec if the content does not contain the `ferro-json-ui/v2`
35/// schema marker (silently skipped — non-ferro JSON files are ignored).
36///
37/// Returns one `spec-parse` warning-level finding if the marker is present but
38/// `Spec::from_json` fails (malformed spec flagged without panicking).
39///
40/// Otherwise returns the findings produced by `ferro_json_ui::design::lint`.
41pub(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
66/// Returns `true` if any finding in the slice has warning-level severity.
67///
68/// Used to drive the `--deny` CI gate: info findings never fail.
69pub(crate) fn has_warning(findings: &[FileFinding]) -> bool {
70    findings
71        .iter()
72        .any(|f| matches!(f.finding.severity, Severity::Warning))
73}
74
75/// Main entry point for the `design:lint` command.
76///
77/// Walks `*.json` files under `path` (default `src/views`) without following
78/// symlinks (confining the walk to the given root), lints each ferro-json-ui
79/// spec, and prints findings grouped by file in human-readable mode.
80///
81/// `--json` emits a flat JSON array of [`FileFinding`] suitable for programmatic
82/// consumption. `--deny` causes a non-zero exit when any warning-level finding
83/// exists (info findings never fail).
84pub 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    // WalkDir default: follow_links = false (symlinks not traversed — T-252-01).
93    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
143/// Print findings in human-readable form, grouped by file.
144fn print_human(all: &[FileFinding]) {
145    // Collect files in encounter order (preserve walker order).
146    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// ── Unit tests ────────────────────────────────────────────────────────────────
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// Minimal conforming spec: auth layout + focus intent + Text element.
207    ///
208    /// Auth layout is exempt from page-header and breadcrumb-on-subpages.
209    /// Focus intent is valid. No composition violations expected.
210    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    /// Same structure as CLEAN but with an unknown intent — produces 1 Warning.
213    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        // Marker present but root "missing" does not exist in the elements map.
260        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        // file-read findings must be Warning severity so --deny trips.
307        // Regression guard for WR-03: I/O errors must not be silently swallowed.
308        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}