1use std::ffi::OsString;
4use std::path::PathBuf;
5
6use rootcause::report;
7use ytil_sys::pico_args::Arguments;
8
9pub use self::output::RslOutput;
10use self::output::ViolationOutputFormat;
11
12mod ast;
13mod engine;
14mod output;
15mod rules;
16
17pub fn run(mut cli_args: Arguments) -> rootcause::Result<RslOutput> {
25 if cli_args.contains("--help") {
26 print!("{}", crate::cmds::Help::Rsl.text());
27 return Ok(RslOutput::new(Vec::new(), ViolationOutputFormat::Compact));
28 }
29
30 let opts = match RslOpts::try_from(cli_args.finish()) {
31 Ok(opts) => opts,
32 Err(error) => {
33 eprintln!("{}", crate::cmds::Help::Rsl.text());
34 return Err(error);
35 }
36 };
37 let violations = crate::cmds::rsl::engine::check_paths(&opts.paths)?;
38 let format = if opts.debug {
39 ViolationOutputFormat::Debug
40 } else {
41 ViolationOutputFormat::Compact
42 };
43
44 Ok(RslOutput::new(violations, format))
45}
46
47#[derive(Debug)]
48struct RslOpts {
49 debug: bool,
50 paths: Vec<PathBuf>,
51}
52
53impl TryFrom<Vec<OsString>> for RslOpts {
54 type Error = rootcause::Report;
55
56 fn try_from(raw: Vec<OsString>) -> Result<Self, Self::Error> {
57 let mut before_separator = Vec::new();
58 let mut after_separator = Vec::new();
59 let mut separator_seen = false;
60
61 for argument in raw {
62 if separator_seen {
63 after_separator.push(argument);
64 } else if argument == "--" {
65 separator_seen = true;
66 } else {
67 before_separator.push(argument);
68 }
69 }
70
71 let mut cli_args = Arguments::from_vec(before_separator);
72 let debug = cli_args.contains("--debug");
73 let mut paths = cli_args.finish();
74 if let Some(option) = paths.iter().find(|path| path.to_string_lossy().starts_with('-')) {
75 return Err(report!("unknown rsl option").attach(format!("option={}", option.to_string_lossy())));
76 }
77 paths.extend(after_separator);
78
79 if paths.is_empty() {
80 return Err(report!("expected one or more Rust source files"));
81 }
82
83 Ok(Self {
84 debug,
85 paths: paths.into_iter().map(PathBuf::from).collect(),
86 })
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use std::ffi::OsString;
93 use std::fmt::Display;
94 use std::path::PathBuf;
95
96 use tempfile::TempDir;
97 use test_that::prelude::*;
98
99 use super::*;
100
101 #[test]
102 fn test_rsl_when_multiple_files_are_clean_returns_no_output() {
103 let directory = require(tempfile::tempdir());
104 let first = require(write_source(
105 &directory,
106 "first.rs",
107 r"
108 use std::fmt;
109 fn first() {}
110 ",
111 ));
112 let second = require(write_source(
113 &directory,
114 "second.rs",
115 r"
116 mod external;
117 mod inline {
118 const VALUE: usize = 1;
119 }
120 ",
121 ));
122
123 assert_that!(
124 run_rsl(vec![first.into_os_string(), second.into_os_string()]),
125 ok(eq(""))
126 );
127 }
128
129 #[test]
130 fn test_rsl_when_multiple_files_have_violations_preserves_input_file_order() {
131 let directory = require(tempfile::tempdir());
132 let first = require(write_source(
133 &directory,
134 "first.rs",
135 r"
136 fn first() {}
137 const FIRST: usize = 1;
138 ",
139 ));
140 let second = require(write_source(
141 &directory,
142 "second.rs",
143 r"
144 fn second() {}
145 const SECOND: usize = 2;
146 ",
147 ));
148
149 let output = require(run_rsl(vec![
150 first.clone().into_os_string(),
151 second.clone().into_os_string(),
152 ]));
153
154 assert_that!(
155 output,
156 eq(format!(
157 "{}:3:13,move `const` after `items`\n{}:3:13,move `const` after `items`\n",
158 first.display(),
159 second.display(),
160 ))
161 );
162 }
163
164 #[test]
165 fn test_rsl_when_file_has_violation_returns_compact_violations() {
166 let directory = require(tempfile::tempdir());
167 let source = require(write_source(
168 &directory,
169 "sample.rs",
170 r"
171 fn run() {}
172 const VALUE: usize = 1;
173 ",
174 ));
175
176 let output = require(run_rsl(vec![source.clone().into_os_string()]));
177 let expected_file = source.to_string_lossy().into_owned();
178
179 assert_that!(output, eq(format!("{expected_file}:3:13,move `const` after `items`\n")));
180 }
181
182 #[test]
183 fn test_rsl_when_function_qualification_is_invalid_omits_rule_codes_by_default() {
184 let directory = require(tempfile::tempdir());
185 let source = require(write_source(
186 &directory,
187 "sample.rs",
188 r#"
189 use tempfile::tempdir;
190 fn main() {
191 tempdir();
192 std::fs::read_to_string("foo");
193 }
194 "#,
195 ));
196
197 let expected_file = source.to_string_lossy().into_owned();
198 let output = require(run_rsl(vec![source.into_os_string()]));
199
200 assert_that!(
201 output,
202 eq(format!(
203 "{expected_file}:4:17,replace `tempdir` with `tempfile::tempdir`\n{expected_file}:5:17,replace `std::fs::read_to_string` with `fs::read_to_string`; add `use std::fs;`\n"
204 ))
205 );
206 }
207
208 #[test]
209 fn test_rsl_when_debug_flag_is_passed_includes_rule_codes() {
210 let directory = require(tempfile::tempdir());
211 let source = require(write_source(
212 &directory,
213 "sample.rs",
214 r#"
215 use tempfile::tempdir;
216 fn main() {
217 tempdir();
218 std::fs::read_to_string("foo");
219 }
220 "#,
221 ));
222
223 let expected_file = source.to_string_lossy().into_owned();
224 let output = require(run_rsl(vec![OsString::from("--debug"), source.into_os_string()]));
225
226 assert_that!(
227 output,
228 eq(format!(
229 "{expected_file}:4:17,unqualified_call,replace `tempdir` with `tempfile::tempdir`\n{expected_file}:5:17,overqualified_call,replace `std::fs::read_to_string` with `fs::read_to_string`; add `use std::fs;`\n"
230 ))
231 );
232 }
233
234 #[test]
235 fn test_rsl_when_relative_call_uses_super_reports_relative_path() {
236 let directory = require(tempfile::tempdir());
237 let source = require(write_source(
238 &directory,
239 "sample.rs",
240 r"
241 mod parent {
242 mod child {
243 fn run() {
244 super::helper();
245 }
246 }
247 fn helper() {}
248 }
249 ",
250 ));
251
252 let expected_file = source.to_string_lossy().into_owned();
253 let output = require(run_rsl(vec![source.into_os_string()]));
254
255 assert_that!(output, eq(format!("{expected_file}:5:25,use a crate-absolute path\n")));
256 }
257
258 #[test]
259 fn test_rsl_when_qualified_result_paths_are_allowed_returns_no_output() {
260 let directory = require(tempfile::tempdir());
261 let source = require(write_source(
262 &directory,
263 "sample.rs",
264 r"
265 fn inspect(_: std::fmt::Result) -> rootcause::Result<()> {
266 panic!()
267 }
268 ",
269 ));
270
271 assert_that!(run_rsl(vec![source.into_os_string()]), ok(eq(String::new())));
272 }
273
274 #[test]
275 fn test_rsl_when_json_option_is_supplied_returns_usage_error() {
276 let directory = require(tempfile::tempdir());
277 let source = require(write_source(&directory, "sample.rs", "fn main() {}"));
278
279 assert_that!(
280 run_rsl(vec![OsString::from("--json"), source.into_os_string()]),
281 err(anything())
282 );
283 }
284
285 #[test]
286 fn test_rsl_when_source_is_malformed_returns_parse_error() {
287 let directory = require(tempfile::tempdir());
288 let source = require(write_source(
289 &directory,
290 "broken.rs",
291 r"
292 fn missing( {
293 ",
294 ));
295
296 assert_that!(
297 run_rsl(vec![source.into_os_string()]),
298 err(displays_as(contains_substring("could not parse Rust source")))
299 );
300 }
301
302 #[test]
303 fn test_rsl_when_file_is_missing_returns_read_error() {
304 let directory = require(tempfile::tempdir());
305 let missing = directory.path().join("missing.rs");
306
307 assert_that!(
308 run_rsl(vec![missing.into_os_string()]),
309 err(displays_as(contains_substring("could not read Rust source")))
310 );
311 }
312
313 #[test]
314 fn test_rsl_when_no_file_is_supplied_returns_usage_error() {
315 assert_that!(
316 run_rsl(Vec::new()),
317 err(displays_as(contains_substring(
318 "expected one or more Rust source files"
319 )))
320 );
321 }
322
323 fn write_source(directory: &TempDir, name: &str, source: &str) -> std::io::Result<PathBuf> {
324 let path = directory.path().join(name);
325 std::fs::write(&path, source).map(|()| path)
326 }
327
328 fn run_rsl(arguments: impl IntoIterator<Item = OsString>) -> rootcause::Result<String> {
329 let output = crate::cmds::rsl::run(Arguments::from_vec(arguments.into_iter().collect()))?;
330 if output.is_empty() {
331 return Ok(String::new());
332 }
333
334 Ok(format!("{}\n", output.render()))
335 }
336
337 fn require<T, E: Display>(result: Result<T, E>) -> T {
338 match result {
339 Ok(value) => value,
340 Err(error) => {
341 panic!("test setup failed: {error}");
342 }
343 }
344 }
345}