rust-llm-tidy-cli 0.1.1

CLI for reordering and linting Rust source code. Intended for LLM use.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
//! Configuration: YAML config file parsing, glob compilation, and runtime
//! per-file policy computation for `rust-llm-tidy`.
//!
//! A config file (`.rust-llm-tidy.yml`) lets users exclude files from all
//! processing, whitelist or blacklist specific lint/fix rules per path, and run
//! external post-processing commands (e.g. `rustfmt`) on every processed file.
//!
//! All patterns are globs relative to the config file's directory and are
//! compiled with `literal_separator(true)`, so `*` does not cross `/` and
//! `**` recurses across directories. Files outside the config directory never
//! match (the prefix strip fails).
//!
//! # Hard-fail policy
//!
//! Any config error - bad YAML, bad glob syntax, unknown rule name, or a
//! pattern matching zero files - causes [`load_and_compile`] to return `Err`,
//! which the CLI propagates as a non-zero exit on every command. The
//! `--validate` flag exists for CI to check the config without processing
//! files.

use anyhow::{Context, anyhow, bail};
use glob::glob as fs_glob;
use globset::{GlobBuilder, GlobSet};
use rust_llm_tidy_lint::check::LINT_CODES;
use serde::Deserialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};

/// Fix/operation names that can be disabled/excluded. Kept in sync with the
/// per-file dispatch in `main.rs` (`fix_file`, `reorder_file`, `vis_file`,
/// `check_file`). `lints` gates the lint pass.
pub const KNOWN_FIX_OPS: &[&str] = &["tables", "fences", "links", "reorder", "vis", "lints"];

/// A loaded and validated config, ready to answer `policy_for` queries.
#[derive(Debug)]
pub struct CompiledConfig {
    /// Canonicalized directory of the config file. Patterns are resolved
    /// relative to this.
    config_dir: PathBuf,
    /// Matches `exclude_files` patterns.
    exclude_files_set: GlobSet,
    /// One group per `include` entry (whitelist mode).
    include_groups: Vec<CompiledRuleGroup>,
    /// One group per `exclude` entry (blacklist mode).
    exclude_groups: Vec<CompiledRuleGroup>,
    /// Stored so the CLI can run the post-processing pass without re-parsing.
    post_process: Vec<PostProcessStep>,
}

/// Raw serde view of `.rust-llm-tidy.yml`. Paths/globs are relative to the
/// config file's directory.
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)] // Reject hallucinated config keys at parse time.
pub struct Config {
    /// Whitelist: for matched paths, run ONLY these rules. Mutually exclusive
    /// with `exclude` (both present -> config-load error). Empty/absent = not
    /// whitelist mode.
    #[serde(default)]
    pub include: Vec<RuleGroup>,
    /// Blacklist: for matched paths, never run these rules. Mutually exclusive
    /// with `include`.
    #[serde(default)]
    pub exclude: Vec<RuleGroup>,
    /// Skip ALL processing for files matching any pattern (was `exclude`).
    #[serde(default)]
    pub exclude_files: Vec<String>,
    /// External commands run on every processed file after rust-llm-tidy.
    #[serde(default)]
    pub post_process: Vec<PostProcessStep>,
}

/// Runtime policy for a single file: whether to skip it entirely, which ops are
/// enabled, and (for blacklist/default mode) which rules are disabled.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FilePolicy {
    /// Matched by an `exclude_files` pattern.
    pub skip: bool,
    /// Ops/rules enabled for this file (whitelist mode) or `None` for the
    /// blacklist/default mode (caller disables via `disabled`).
    pub enabled: Option<HashSet<String>>,
    /// Union of `rules` from all matched `exclude` groups (blacklist/default
    /// mode). Empty in whitelist mode.
    pub disabled: HashSet<String>,
}

/// One entry under `include` or `exclude`: a list of path globs and the rule
/// names to (include|exclude) for files they match. An omitted `paths` matches
/// every file (implied `["**"]`).
#[derive(Debug, Deserialize, Default, Clone)]
#[serde(deny_unknown_fields)] // Reject hallucinated config keys at parse time.
pub struct RuleGroup {
    #[serde(default)]
    pub paths: Vec<String>,
    #[serde(default)]
    pub rules: Vec<String>,
}

/// One external post-processing step. The processed file path is appended as
/// the last argument by the CLI's `run_post_process` (see `main.rs`).
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)] // Reject hallucinated config keys at parse time.
pub struct PostProcessStep {
    pub command: String,
    #[serde(default)]
    pub args: Vec<String>,
    /// Empty = run on every file regardless of extension.
    #[serde(default)]
    pub extensions: Vec<String>,
}

/// A compiled `include`/`exclude` group: one glob set plus its rule names.
#[derive(Debug)]
struct CompiledRuleGroup {
    set: GlobSet,
    rules: Vec<String>,
}

impl CompiledConfig {
    /// Borrow the post-processing steps so the CLI can run them after the
    /// per-file loop.
    pub fn post_process_steps(&self) -> &[PostProcessStep] {
        &self.post_process
    }

    /// Test-only accessor for the canonicalized config directory. Used by the
    /// unit tests to reconstruct canonical paths matching `policy_for`.
    #[cfg(test)]
    pub fn config_dir_canonical_for_test(&self) -> &Path {
        &self.config_dir
    }

    /// Compute the [`FilePolicy`] for `file`.
    ///
    /// `file` is canonicalized, the `config_dir` prefix is stripped, and the
    /// relative path is tested against every compiled glob set. A file outside
    /// `config_dir` (prefix strip fails) returns an empty policy.
    pub fn policy_for(&self, file: &Path) -> FilePolicy {
        let Ok(canon) = file.canonicalize() else {
            return FilePolicy::default();
        };
        let Some(rel) = canon.strip_prefix(&self.config_dir).ok() else {
            return FilePolicy::default();
        };
        let rel_str = rel.to_string_lossy();
        let mut policy = FilePolicy::default();
        if self.exclude_files_set.is_match(&*rel_str) {
            policy.skip = true;
        }
        let matched_include: HashSet<String> = self
            .include_groups
            .iter()
            .filter(|g| g.set.is_match(&*rel_str))
            .flat_map(|g| g.rules.iter().cloned())
            .collect();
        let matched_exclude: HashSet<String> = self
            .exclude_groups
            .iter()
            .filter(|g| g.set.is_match(&*rel_str))
            .flat_map(|g| g.rules.iter().cloned())
            .collect();
        if !self.include_groups.is_empty() {
            // Whitelist mode: a file matching NO include group runs nothing.
            policy.enabled = Some(matched_include);
        } else {
            // Blacklist/default mode: disable matched_exclude rules.
            policy.disabled = matched_exclude;
            policy.enabled = None;
        }
        policy
    }
}

/// Resolve the config file path.
///
/// - `no_config == true` -> `None`.
/// - Explicit `arg` -> that path (used as-is).
/// - Else walk up from `std::env::current_dir()` towards the filesystem root.
///   At each level checked (including the starting dir), look for
///   `.rust-llm-tidy.yml`; the first one found wins. Stop at the first ancestor
///   that contains a `.git` entry (the repo root) if no config appeared there;
///   if no `.git` is found, continue to the filesystem root. Returns `None`
///   when no config file is found.
///
/// # Arguments
///
/// - `arg`: an explicit config path from `--config`, or `None` to use
///   auto-discovery.
/// - `no_config`: when `true`, disables discovery and loading entirely and
///   returns `None`.
pub fn discover_config_path(arg: Option<&Path>, no_config: bool) -> Option<PathBuf> {
    if no_config {
        return None;
    }
    if let Some(p) = arg {
        return Some(p.to_path_buf());
    }
    let cwd = std::env::current_dir().ok()?;
    let mut dir: &Path = &cwd;
    loop {
        let candidate = dir.join(".rust-llm-tidy.yml");
        if candidate.is_file() {
            return Some(candidate);
        }
        if dir.join(".git").exists() {
            // Reached the repo root without finding a config; stop walking up.
            return None;
        }
        dir = dir.parent()?;
    }
}

/// Read, parse, validate, and compile the config at `path`.
///
/// Steps:
/// 1. Read the file and `serde_yml::from_str` it (YAML error -> `Err`).
/// 2. Canonicalize the config directory (patterns resolve relative to it).
/// 3. Reject `include` + `exclude` co-presence (xor).
/// 4. Validate every rule name against `known_rules()`; compile each group's
///    patterns into a `GlobSet` with `literal_separator(true)`. An
///    empty/missing `paths` in a group is treated as `["**"]`.
/// 5. Compile the `exclude_files` patterns into one `GlobSet`.
/// 6. Semantic check: expand each pattern via `glob::glob()` joined with
///    `config_dir`; a pattern yielding zero results is stale -> `Err`.
///
/// # Arguments
///
/// - `path`: the path to the `.rust-llm-tidy.yml` config file to load and
///   compile.
///
/// # Errors
///
/// Returns an error if:
/// - The file cannot be read or parsed as YAML.
/// - The config path has no parent directory.
/// - The config directory cannot be canonicalized.
/// - `include` and `exclude` are both non-empty.
/// - Any rule name is not in [`known_rules()`].
/// - Any glob pattern has invalid syntax.
/// - Any pattern matches zero files under the config directory.
///
/// On success, returns a [`CompiledConfig`] ready for `policy_for`.
pub fn load_and_compile(path: &Path) -> anyhow::Result<CompiledConfig> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config {}", path.display()))?;
    let config: Config = serde_yml::from_str(&raw)
        .with_context(|| format!("failed to parse YAML config {}", path.display()))?;

    let config_parent = path
        .parent()
        .with_context(|| format!("config path {} has no parent", path.display()))?;
    let config_dir = if config_parent.as_os_str().is_empty() {
        Path::new(".")
    } else {
        config_parent
    }
    .canonicalize()
    .with_context(|| format!("failed to canonicalize config dir {}", path.display()))?;

    // XOR: include + exclude both present -> error.
    if !config.include.is_empty() && !config.exclude.is_empty() {
        bail!("cannot use `include` (whitelist) and `exclude` (blacklist) together; pick one");
    }

    let valid = known_rules();

    // Validate rule names + compile include groups.
    let mut include_groups: Vec<CompiledRuleGroup> = Vec::with_capacity(config.include.len());
    for rule in &config.include {
        for r in &rule.rules {
            if !valid.contains(&r.as_str()) {
                bail!(
                    "unknown rule `{r}` in include.rules; valid rules: {}",
                    valid.join(", ")
                );
            }
        }
        let paths = if rule.paths.is_empty() {
            vec!["**".to_string()]
        } else {
            rule.paths.clone()
        };
        let set = compile_glob_set(&paths, &config_dir)?;
        include_groups.push(CompiledRuleGroup {
            set,
            rules: rule.rules.clone(),
        });
    }

    // Validate rule names + compile exclude groups.
    let mut exclude_groups: Vec<CompiledRuleGroup> = Vec::with_capacity(config.exclude.len());
    for rule in &config.exclude {
        for r in &rule.rules {
            if !valid.contains(&r.as_str()) {
                bail!(
                    "unknown rule `{r}` in exclude.rules; valid rules: {}",
                    valid.join(", ")
                );
            }
        }
        let paths = if rule.paths.is_empty() {
            vec!["**".to_string()]
        } else {
            rule.paths.clone()
        };
        let set = compile_glob_set(&paths, &config_dir)?;
        exclude_groups.push(CompiledRuleGroup {
            set,
            rules: rule.rules.clone(),
        });
    }

    let exclude_files_set = compile_glob_set(&config.exclude_files, &config_dir)?;

    // Semantic check: every pattern must match at least one file when expanded
    // against the filesystem from `config_dir`.
    for pat in &config.exclude_files {
        check_pattern_matches(&config_dir, pat)?;
    }
    for group in &config.include {
        for pat in &group.paths {
            check_pattern_matches(&config_dir, pat)?;
        }
    }
    for group in &config.exclude {
        for pat in &group.paths {
            check_pattern_matches(&config_dir, pat)?;
        }
    }

    Ok(CompiledConfig {
        config_dir,
        exclude_files_set,
        include_groups,
        exclude_groups,
        post_process: config.post_process,
    })
}

/// Return every rule name accepted by `include.rules`, `exclude.rules`,
/// `--include`, and `--exclude`: lint codes followed by fix/operation names.
/// The CLI validates rule names against this list.
pub fn known_rules() -> Vec<&'static str> {
    let mut rules: Vec<&'static str> = LINT_CODES.to_vec();
    rules.extend_from_slice(KNOWN_FIX_OPS);
    rules
}

/// Expand `pattern` joined with `config_dir` via `glob::glob()` and require at
/// least one match. Descends only the pattern's prefix subtree, so cost scales
/// with the number/depth of patterns, not repo size.
fn check_pattern_matches(config_dir: &Path, pattern: &str) -> anyhow::Result<()> {
    let full = config_dir.join(pattern);
    let full_str = full.to_string_lossy().into_owned();
    let mut matches = fs_glob(&full_str)
        .map_err(|e| anyhow!("invalid glob pattern `{pattern}`: {e}"))?
        .filter_map(Result::ok);
    if matches.next().is_none() {
        bail!(
            "config pattern `{pattern}` matched no files under {}",
            config_dir.display()
        );
    }
    Ok(())
}

/// Build a `GlobSet` from `patterns`, each compiled with `literal_separator(true)`.
fn compile_glob_set(patterns: &[String], _config_dir: &Path) -> anyhow::Result<GlobSet> {
    let mut builder = GlobSet::builder();
    for p in patterns {
        let g = GlobBuilder::new(p)
            .literal_separator(true)
            .build()
            .with_context(|| format!("invalid glob pattern `{p}`"))?;
        builder.add(g);
    }
    builder
        .build()
        .map_err(|e| anyhow!("failed to build glob set: {e}"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    static COMPILE_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

    /// Write a YAML config and a sibling matching file under a temp dir, then
    /// load+compile. Returns the `CompiledConfig`. The temp dir is NOT cleaned
    /// up here so callers can exercise `policy_for` on existing files.
    fn compile(yaml: &str, files: &[(&str, &str)]) -> CompiledConfig {
        let dir = std::env::temp_dir().join(format!(
            "rlt-cfg-unit-{}-{}",
            std::process::id(),
            COMPILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed,),
        ));
        std::fs::create_dir_all(&dir).unwrap();
        for (name, body) in files {
            let p = dir.join(name);
            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
            let mut f = std::fs::File::create(&p).unwrap();
            f.write_all(body.as_bytes()).unwrap();
        }
        let cfg_path = dir.join(".rust-llm-tidy.yml");
        std::fs::write(&cfg_path, yaml).unwrap();
        let compiled = load_and_compile(&cfg_path).expect("config should compile");
        compiled
    }

    #[test]
    fn empty_config_compiles_to_no_op() {
        let cc = compile(
            "exclude_files: []\n",
            &[("src/lib.rs", "pub fn example() {}\n")],
        );
        // Use a file that actually exists inside the config dir so
        // canonicalize succeeds and the no-pattern-match path is exercised.
        let dir = cc.config_dir_canonical_for_test();
        let policy = cc.policy_for(&dir.join("src").join("lib.rs"));
        assert!(!policy.skip);
        assert!(policy.disabled.is_empty());
        assert_eq!(policy.enabled, None);
    }

    #[test]
    fn bad_glob_syntax_is_rejected() {
        // `[` opens an unclosed character class across both `globset` and
        // `glob`, so this fails at compile time.
        let dir = std::env::temp_dir().join(format!("rlt-cfg-bad-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        // The pattern must be invalid regardless of matching files.
        std::fs::write(dir.join("a.rs"), "pub fn x() {}\n").unwrap();
        let cfg_path = dir.join(".rust-llm-tidy.yml");
        std::fs::write(&cfg_path, "exclude_files:\n  - \"[unclosed\"\n").unwrap();
        let err = load_and_compile(&cfg_path).unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("invalid glob pattern") || msg.contains("glob"),
            "bad glob syntax should surface as an error: {msg}"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn unknown_rule_is_rejected() {
        let dir = std::env::temp_dir().join(format!("rlt-cfg-rule-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("lib.rs"), "pub fn x() {}\n").unwrap();
        let cfg_path = dir.join(".rust-llm-tidy.yml");
        std::fs::write(
            &cfg_path,
            "exclude:\n  - paths: [\"lib.rs\"]\n    rules: [\"BOGUS\"]\n",
        )
        .unwrap();
        let err = load_and_compile(&cfg_path).unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("unknown rule") && msg.contains("BOGUS"),
            "unknown rule should be reported: {msg}"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn non_matching_pattern_is_rejected() {
        let dir = std::env::temp_dir().join(format!("rlt-cfg-nomatch-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let cfg_path = dir.join(".rust-llm-tidy.yml");
        std::fs::write(&cfg_path, "exclude_files:\n  - \"nope/**\"\n").unwrap();
        let err = load_and_compile(&cfg_path).unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("matched no files"),
            "non-matching pattern should be reported: {msg}"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn policy_for_matches_relative_path() {
        let cc = compile(
            "exclude_files:\n  - \"src/lib.rs\"\nexclude:\n  - paths: [\"src/lib.rs\"]\n    rules: [\"links\"]\n",
            &[("src/lib.rs", "pub fn example() {}\n")],
        );
        // Re-open the same path the compile helper used to canonicalize.
        let dir = cc.config_dir_canonical_for_test();
        let lib = dir.join("src").join("lib.rs");
        let policy = cc.policy_for(&lib);
        assert!(policy.skip, "exclude_files should mark the file skipped");
        assert!(
            policy.disabled.contains("links"),
            "exclude should disable `links`: {policy:?}"
        );
    }

    #[test]
    fn file_outside_config_dir_returns_empty_policy() {
        let cc = compile(
            "exclude_files:\n  - \"**\"\n",
            &[("src/lib.rs", "pub fn example() {}\n")],
        );
        // A file that exists but is outside the config dir yields an empty
        // policy via the strip_prefix failure path (canonicalize succeeds).
        let outside_dir =
            std::env::temp_dir().join(format!("rlt-cfg-outside-dir-{}", std::process::id()));
        std::fs::create_dir_all(&outside_dir).unwrap();
        let outside = outside_dir.join("outside.rs");
        std::fs::write(&outside, "pub fn x() {}\n").unwrap();
        let policy = cc.policy_for(&outside);
        assert!(!policy.skip);
        assert!(policy.disabled.is_empty());
        let _ = std::fs::remove_dir_all(&outside_dir);
    }

    #[test]
    fn literal_separator_star_does_not_cross_slash() {
        // `*.rs` must match a file directly under the config dir, but NOT a
        // file nested under a subdirectory (because `*` does not cross `/`).
        let cc = compile(
            "exclude_files:\n  - \"*.rs\"\n",
            &[
                ("top.rs", "pub fn top() {}\n"),
                ("sub/nested.rs", "pub fn nested() {}\n"),
            ],
        );
        let dir = cc.config_dir_canonical_for_test();
        let top = dir.join("top.rs");
        let nested = dir.join("sub").join("nested.rs");
        assert!(
            cc.policy_for(&top).skip,
            "*.rs should match a top-level .rs file"
        );
        assert!(
            !cc.policy_for(&nested).skip,
            "*.rs must NOT cross / and match a nested file"
        );
    }

    #[test]
    fn known_rules_lists_every_code_and_op() {
        let rules = known_rules();
        // The seven lint codes plus the six fix/operation names (including lints).
        for code in [
            "DOC001", "DOC002", "DOC003", "DOC004", "DOC005", "DOC006", "TEST001",
        ] {
            assert!(rules.iter().any(|r| *r == code), "missing lint code {code}");
        }
        for op in ["tables", "fences", "links", "reorder", "vis", "lints"] {
            assert!(rules.iter().any(|r| *r == op), "missing fix/operation {op}");
        }
    }
}