Skip to main content

tidy/
triagebot.rs

1//! Tidy check to ensure paths mentioned in triagebot.toml exist in the project.
2
3use std::collections::HashSet;
4use std::path::Path;
5use std::sync::LazyLock;
6
7use toml::Value;
8
9use crate::diagnostics::TidyCtx;
10
11static SUBMODULES: LazyLock<Vec<&'static Path>> = LazyLock::new(|| {
12    // WORKSPACES doesn't list all submodules but it's contains the main at least
13    crate::deps::WORKSPACES
14        .iter()
15        .map(|ws| ws.submodules.iter())
16        .flatten()
17        .map(|p| Path::new(p))
18        .collect()
19});
20
21pub fn check(path: &Path, tidy_ctx: TidyCtx) {
22    let mut check = tidy_ctx.start_check("triagebot");
23    let triagebot_path = path.join("triagebot.toml");
24
25    // This check is mostly to catch broken path filters *within* `triagebot.toml`, and not enforce
26    // the existence of `triagebot.toml` itself (which is more obvious), as distribution tarballs
27    // will not include non-essential bits like `triagebot.toml`.
28    if !triagebot_path.exists() {
29        return;
30    }
31
32    let contents = std::fs::read_to_string(&triagebot_path).unwrap();
33    let config: Value = toml::from_str(&contents).unwrap();
34
35    // Check [mentions."*"] sections, i.e. [mentions."compiler/rustc_const_eval/src/"]
36    if let Some(Value::Table(mentions)) = config.get("mentions") {
37        let mut builder = globset::GlobSetBuilder::new();
38        let mut glob_entries = Vec::new();
39
40        for (entry_key, entry_val) in mentions.iter() {
41            // If the type is set to something other than "filename", then this is not a path.
42            if entry_val.get("type").is_some_and(|t| t.as_str().unwrap_or_default() != "filename") {
43                continue;
44            }
45            let path_str = entry_key;
46            // Remove quotes from the path
47            let clean_path = path_str.trim_matches('"');
48            let full_path = path.join(clean_path);
49
50            if !full_path.exists() {
51                // The full-path doesn't exists, maybe it's a glob, let's add it to the glob set builder
52                // to be checked against all the file and directories in the repository.
53                let trimmed_path = clean_path.trim_end_matches('/');
54                builder.add(
55                    globset::GlobBuilder::new(&format!("{trimmed_path}{{,/*}}"))
56                        .empty_alternates(true)
57                        .build()
58                        .unwrap(),
59                );
60                glob_entries.push(clean_path.to_string());
61            } else if is_in_submodule(Path::new(clean_path)) {
62                check.error(format!(
63                    "triagebot.toml [mentions.*] '{clean_path}' cannot match inside a submodule"
64                ));
65            }
66        }
67
68        let gs = builder.build().unwrap();
69
70        let mut found = HashSet::new();
71        let mut matches = Vec::new();
72
73        let cloned_path = path.to_path_buf();
74
75        // Walk the entire repository and match any entry against the remaining paths
76        for entry in ignore::WalkBuilder::new(&path)
77            .filter_entry(move |entry| {
78                // Ignore entries inside submodules as triagebot cannot detect them
79                let entry_path = entry.path().strip_prefix(&cloned_path).unwrap();
80                is_not_in_submodule(entry_path)
81            })
82            .build()
83            .flatten()
84        {
85            // Strip the prefix as mentions entries are always relative to the repo
86            let entry_path = entry.path().strip_prefix(path).unwrap();
87
88            // Find the matches and add them to the found set
89            gs.matches_into(entry_path, &mut matches);
90            found.extend(matches.iter().copied());
91
92            // Early-exist if all the globs have been matched
93            if found.len() == glob_entries.len() {
94                break;
95            }
96        }
97
98        for (i, clean_path) in glob_entries.iter().enumerate() {
99            if !found.contains(&i) {
100                check.error(format!(
101                    "triagebot.toml [mentions.*] contains '{clean_path}' which doesn't match any file or directory in the repository"
102                ));
103            }
104        }
105    } else {
106        check.error(
107            "triagebot.toml missing [mentions.*] section, this wrong for rust-lang/rust repo.",
108        );
109    }
110
111    // Check [assign.owners] sections, i.e.
112    // [assign.owners]
113    // "/.github/workflows" = ["infra-ci"]
114    if let Some(Value::Table(assign)) = config.get("assign") {
115        if let Some(Value::Table(owners)) = assign.get("owners") {
116            for path_str in owners.keys() {
117                // Remove quotes and leading slash from the path
118                let clean_path = path_str.trim_matches('"').trim_start_matches('/');
119                let full_path = path.join(clean_path);
120
121                if !full_path.exists() {
122                    check.error(format!(
123                        "triagebot.toml [assign.owners] contains path '{clean_path}' which doesn't exist"
124                    ));
125                }
126            }
127        } else {
128            check.error(
129                "triagebot.toml missing [assign.owners] section, this wrong for rust-lang/rust repo."
130            );
131        }
132    }
133
134    // Verify that trigger_files in [autolabel."*"] exist in the project, i.e.
135    // [autolabel."A-rustdoc-search"]
136    // trigger_files = [
137    //    "src/librustdoc/html/static/js/search.js",
138    //    "tests/rustdoc-js",
139    //    "tests/rustdoc-js-std",
140    // ]
141    if let Some(Value::Table(autolabels)) = config.get("autolabel") {
142        for (label, content) in autolabels {
143            if let Some(trigger_files) = content.get("trigger_files").and_then(|v| v.as_array()) {
144                for file in trigger_files {
145                    if let Some(file_str) = file.as_str() {
146                        let full_path = path.join(file_str);
147
148                        // Handle both file and directory paths
149                        if !full_path.exists() {
150                            check.error(format!(
151                                "triagebot.toml [autolabel.{label}] contains trigger_files path '{file_str}' which doesn't exist",
152                            ));
153                        }
154                    }
155                }
156            }
157        }
158    }
159}
160
161fn is_not_in_submodule(path: &Path) -> bool {
162    SUBMODULES.contains(&path) || !SUBMODULES.iter().any(|p| path.starts_with(*p))
163}
164
165fn is_in_submodule(path: &Path) -> bool {
166    !SUBMODULES.contains(&path) && SUBMODULES.iter().any(|p| path.starts_with(*p))
167}