Skip to main content

krait/commands/
check.rs

1use std::path::{Path, PathBuf};
2
3use serde_json::{json, Value};
4
5use crate::lsp::diagnostics::{DiagSeverity, DiagnosticStore};
6
7/// Handle the `krait check` command.
8///
9/// Returns diagnostics (optionally filtered to `path` and/or errors-only),
10/// sorted by severity then `file:line`. Paths in the response are relative to `project_root`.
11#[must_use]
12pub fn handle_check(
13    path: Option<&Path>,
14    store: &DiagnosticStore,
15    project_root: &Path,
16    errors_only: bool,
17) -> Value {
18    let mut entries: Vec<(PathBuf, DiagSeverity, u32, u32, Option<String>, String)> = vec![];
19
20    if let Some(filter) = path {
21        let abs = if filter.is_absolute() {
22            filter.to_path_buf()
23        } else {
24            project_root.join(filter)
25        };
26        for d in store.get(&abs) {
27            if errors_only && d.severity != DiagSeverity::Error {
28                continue;
29            }
30            entries.push((abs.clone(), d.severity, d.line, d.col, d.code, d.message));
31        }
32    } else {
33        for (file_path, diags) in store.get_all() {
34            for d in diags {
35                if errors_only && d.severity != DiagSeverity::Error {
36                    continue;
37                }
38                entries.push((
39                    file_path.clone(),
40                    d.severity,
41                    d.line,
42                    d.col,
43                    d.code,
44                    d.message,
45                ));
46            }
47        }
48    }
49
50    // Sort: severity (Error < Warning < ...) → path → line
51    entries.sort_by(|a, b| {
52        a.1.cmp(&b.1)
53            .then_with(|| a.0.cmp(&b.0))
54            .then_with(|| a.2.cmp(&b.2))
55    });
56
57    let mut errors: u64 = 0;
58    let mut warnings: u64 = 0;
59    let items: Vec<Value> = entries
60        .iter()
61        .map(|(file_path, sev, line, col, code, msg)| {
62            match sev {
63                DiagSeverity::Error => errors += 1,
64                DiagSeverity::Warning => warnings += 1,
65                _ => {}
66            }
67            let rel = file_path.strip_prefix(project_root).unwrap_or(file_path);
68            json!({
69                "severity": sev.label(),
70                "path": rel.to_string_lossy(),
71                // Convert 0-indexed LSP positions to 1-indexed display
72                "line": line + 1,
73                "col": col + 1,
74                "code": code,
75                "message": msg,
76            })
77        })
78        .collect();
79
80    let total = items.len() as u64;
81    json!({
82        "diagnostics": items,
83        "total": total,
84        "errors": errors,
85        "warnings": warnings,
86    })
87}
88
89// ── Tests ─────────────────────────────────────────────────────────────────────
90
91#[cfg(test)]
92mod tests {
93    use std::path::PathBuf;
94
95    use super::*;
96    use crate::lsp::diagnostics::{DiagSeverity, DiagnosticEntry, DiagnosticStore};
97
98    fn root() -> PathBuf {
99        PathBuf::from("/project")
100    }
101
102    fn make_store() -> DiagnosticStore {
103        let store = DiagnosticStore::new();
104        store.update(
105            PathBuf::from("/project/src/lib.rs"),
106            vec![
107                DiagnosticEntry {
108                    severity: DiagSeverity::Warning,
109                    line: 2,
110                    col: 4,
111                    code: None,
112                    message: "unused import".to_string(),
113                },
114                DiagnosticEntry {
115                    severity: DiagSeverity::Error,
116                    line: 41,
117                    col: 9,
118                    code: Some("E0308".to_string()),
119                    message: "mismatched types".to_string(),
120                },
121            ],
122        );
123        store
124    }
125
126    #[test]
127    fn check_formats_errors_first() {
128        let store = make_store();
129        let result = handle_check(None, &store, &root(), false);
130        let diags = result["diagnostics"].as_array().unwrap();
131        // First entry should be the error (E0308 on line 42)
132        assert_eq!(diags[0]["severity"], "error");
133        assert_eq!(diags[1]["severity"], "warn");
134    }
135
136    #[test]
137    fn check_empty_is_clean() {
138        let store = DiagnosticStore::new();
139        let result = handle_check(None, &store, &root(), false);
140        assert_eq!(result["total"], 0);
141        assert!(result["diagnostics"].as_array().unwrap().is_empty());
142    }
143
144    #[test]
145    fn check_filters_by_path() {
146        let store = make_store();
147        store.update(
148            PathBuf::from("/project/src/main.rs"),
149            vec![DiagnosticEntry {
150                severity: DiagSeverity::Error,
151                line: 5,
152                col: 0,
153                code: None,
154                message: "other error".to_string(),
155            }],
156        );
157        let result = handle_check(Some(Path::new("src/lib.rs")), &store, &root(), false);
158        let diags = result["diagnostics"].as_array().unwrap();
159        assert_eq!(diags.len(), 2, "should only return lib.rs diagnostics");
160        for d in diags {
161            assert_eq!(d["path"], "src/lib.rs");
162        }
163    }
164
165    #[test]
166    fn check_line_is_one_indexed() {
167        let store = make_store();
168        let result = handle_check(None, &store, &root(), false);
169        let diags = result["diagnostics"].as_array().unwrap();
170        let error = diags.iter().find(|d| d["severity"] == "error").unwrap();
171        // LSP line 41 → display line 42
172        assert_eq!(error["line"], 42);
173    }
174
175    #[test]
176    fn check_counts_errors_and_warnings() {
177        let store = make_store();
178        let result = handle_check(None, &store, &root(), false);
179        assert_eq!(result["errors"], 1);
180        assert_eq!(result["warnings"], 1);
181        assert_eq!(result["total"], 2);
182    }
183
184    #[test]
185    fn check_errors_only_suppresses_warnings() {
186        let store = make_store();
187        let result = handle_check(None, &store, &root(), true);
188        let diags = result["diagnostics"].as_array().unwrap();
189        // Only the error should remain
190        assert_eq!(diags.len(), 1);
191        assert_eq!(diags[0]["severity"], "error");
192        assert_eq!(result["total"], 1);
193    }
194
195    #[test]
196    fn check_errors_only_clean_project_is_empty() {
197        let store = DiagnosticStore::new();
198        store.update(
199            PathBuf::from("/project/src/lib.rs"),
200            vec![DiagnosticEntry {
201                severity: DiagSeverity::Warning,
202                line: 0,
203                col: 0,
204                code: None,
205                message: "unused import".to_string(),
206            }],
207        );
208        // errors_only — the single warning should be suppressed → "No diagnostics"
209        let result = handle_check(None, &store, &root(), true);
210        assert_eq!(result["total"], 0);
211        assert!(result["diagnostics"].as_array().unwrap().is_empty());
212    }
213}