1pub mod lexer;
2pub mod model;
3pub mod parser;
4pub mod report;
5pub mod resolve;
6pub mod rules;
7
8use std::path::Path;
9
10use model::Finding;
11
12fn sort_findings(findings: &mut [Finding]) {
14 findings.sort_by(|a, b| {
15 a.span
16 .file
17 .cmp(&b.span.file)
18 .then(a.span.line.cmp(&b.span.line))
19 });
20}
21
22pub fn lint_str(input: &str) -> Vec<Finding> {
24 let lines = lexer::lex(input);
25 let config = parser::parse(lines);
26 let mut findings = rules::run_all(&config);
27 sort_findings(&mut findings);
28 findings
29}
30
31pub fn lint_str_with_includes(input: &str, base_dir: &Path) -> Vec<Finding> {
33 let lines = lexer::lex(input);
34 let mut config = parser::parse(lines);
35 let mut findings = resolve::resolve_includes(&mut config, base_dir);
36 findings.extend(rules::run_all(&config));
37 sort_findings(&mut findings);
38 findings
39}
40
41pub fn lint_file(path: &Path) -> Result<Vec<Finding>, std::io::Error> {
43 let content = std::fs::read_to_string(path)?;
44 let base_dir = path.parent().unwrap_or(Path::new("."));
45 Ok(lint_str_with_includes(&content, base_dir))
46}
47
48pub fn lint_file_no_includes(path: &Path) -> Result<Vec<Finding>, std::io::Error> {
50 let content = std::fs::read_to_string(path)?;
51 Ok(lint_str(&content))
52}
53
54pub fn has_errors(findings: &[Finding]) -> bool {
56 findings
57 .iter()
58 .any(|f| f.severity == model::Severity::Error)
59}
60
61pub fn has_warnings(findings: &[Finding]) -> bool {
63 findings.iter().any(|f| {
64 matches!(
65 f.severity,
66 model::Severity::Warning | model::Severity::Error
67 )
68 })
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 #[test]
76 fn lint_str_empty_returns_empty() {
77 let findings = lint_str("");
78 assert!(findings.is_empty());
79 }
80
81 #[test]
82 fn lint_str_clean_config_no_findings() {
83 let input = "\
84Host github.com
85 User git
86 IdentityFile %d/.ssh/id_ed25519
87
88Host gitlab.com
89 User git
90";
91 let findings = lint_str(input);
92 assert!(findings.is_empty());
93 }
94
95 #[test]
96 fn lint_str_duplicate_host_found() {
97 let input = "\
98Host github.com
99 User git
100
101Host github.com
102 User git2
103";
104 let findings = lint_str(input);
105 assert!(findings.iter().any(|f| f.rule == "duplicate-host"));
106 }
107
108 #[test]
109 fn lint_str_wildcard_before_specific_warns() {
110 let input = "\
111Host *
112 ServerAliveInterval 60
113
114Host github.com
115 User git
116";
117 let findings = lint_str(input);
118 assert!(findings.iter().any(|f| f.rule == "wildcard-host-order"));
119 }
120
121 #[test]
122 fn has_errors_true_when_error_present() {
123 let findings = vec![Finding::new(
124 model::Severity::Error,
125 "test",
126 "TEST",
127 "bad",
128 model::Span::new(1),
129 )];
130 assert!(has_errors(&findings));
131 }
132
133 #[test]
134 fn has_errors_false_when_only_warnings() {
135 let findings = vec![Finding::new(
136 model::Severity::Warning,
137 "test",
138 "TEST",
139 "meh",
140 model::Span::new(1),
141 )];
142 assert!(!has_errors(&findings));
143 }
144
145 #[test]
146 fn has_warnings_true_when_warning_present() {
147 let findings = vec![Finding::new(
148 model::Severity::Warning,
149 "test",
150 "TEST",
151 "meh",
152 model::Span::new(1),
153 )];
154 assert!(has_warnings(&findings));
155 }
156
157 #[test]
158 fn has_warnings_false_when_only_info() {
159 let findings = vec![Finding::new(
160 model::Severity::Info,
161 "test",
162 "TEST",
163 "ok",
164 model::Span::new(1),
165 )];
166 assert!(!has_warnings(&findings));
167 }
168
169 #[test]
170 #[ignore]
171 fn lint_my_real_config() {
172 let home = dirs::home_dir().expect("no home dir");
173 let config_path = home.join(".ssh/config");
174 if !config_path.exists() {
175 eprintln!("~/.ssh/config not found, skipping");
176 return;
177 }
178 let findings = lint_file(&config_path).expect("failed to read config");
179 for f in &findings {
180 eprintln!(
181 " line {}: [{}] ({}) {}",
182 f.span.line, f.severity, f.rule, f.message
183 );
184 }
185 }
186}