1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, bail};
4use cmake_tidy_config::load_configuration;
5use cmake_tidy_format::format_source_with_options;
6
7use crate::coverage_excluded;
8
9pub fn run(paths: &[PathBuf]) -> Result<bool> {
10 let current_directory = std::env::current_dir().context("failed to read current directory")?;
11 let configuration = load_configuration(¤t_directory).with_context(|| {
12 format!(
13 "failed to load configuration from {}",
14 current_directory.display()
15 )
16 })?;
17 let targets = discover_targets(paths, ¤t_directory, &configuration.main)?;
18 if targets.is_empty() {
19 bail!("no CMake files found");
20 }
21
22 let mut changed_any = false;
23
24 for path in targets {
25 let source = coverage_excluded::read_cmake_file(&path)?;
26 let result = format_source_with_options(&source, &configuration.format);
27 if !result.changed {
28 continue;
29 }
30
31 coverage_excluded::write_formatted_file(&path, result.output)?;
32 changed_any = true;
33 }
34
35 Ok(changed_any)
36}
37
38fn discover_targets(
39 paths: &[PathBuf],
40 current_directory: &Path,
41 main: &cmake_tidy_config::MainConfiguration,
42) -> Result<Vec<PathBuf>> {
43 let mut targets = Vec::new();
44 for path in paths {
45 collect_targets(path, current_directory, main, &mut targets)?;
46 }
47
48 targets.sort();
49 targets.dedup();
50 Ok(targets)
51}
52
53fn collect_targets(
54 path: &Path,
55 current_directory: &Path,
56 main: &cmake_tidy_config::MainConfiguration,
57 targets: &mut Vec<PathBuf>,
58) -> Result<()> {
59 let metadata = coverage_excluded::read_metadata(path)?;
60
61 if metadata.is_file() {
62 if is_cmake_file(path) && !is_excluded(path, current_directory, main) {
63 targets.push(path.to_path_buf());
64 }
65 return Ok(());
66 }
67
68 for entry in coverage_excluded::read_directory(path)? {
69 let entry = coverage_excluded::read_directory_entry(entry, path)?;
70 let entry_path = entry.path();
71
72 if entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
73 collect_targets(&entry_path, current_directory, main, targets)?;
74 } else if is_cmake_file(&entry_path) && !is_excluded(&entry_path, current_directory, main) {
75 targets.push(entry_path);
76 }
77 }
78
79 Ok(())
80}
81
82fn is_cmake_file(path: &Path) -> bool {
83 path.file_name()
84 .is_some_and(|file_name| file_name == "CMakeLists.txt")
85 || path
86 .extension()
87 .is_some_and(|extension| extension == "cmake")
88}
89
90fn is_excluded(
91 path: &Path,
92 current_directory: &Path,
93 main: &cmake_tidy_config::MainConfiguration,
94) -> bool {
95 if main.is_path_excluded(path) {
96 return true;
97 }
98
99 if path
100 .strip_prefix(current_directory)
101 .is_ok_and(|relative| main.is_path_excluded(relative))
102 {
103 return true;
104 }
105
106 if let (Ok(canonical_path), Ok(canonical_current_directory)) = (
107 std::fs::canonicalize(path),
108 std::fs::canonicalize(current_directory),
109 ) && canonical_path
110 .strip_prefix(&canonical_current_directory)
111 .is_ok_and(|relative| main.is_path_excluded(relative))
112 {
113 return true;
114 }
115
116 false
117}
118
119#[cfg(test)]
120mod tests {
121 use std::fs;
122 use std::path::PathBuf;
123 use std::sync::atomic::{AtomicU64, Ordering};
124 use std::time::{SystemTime, UNIX_EPOCH};
125
126 use anyhow::{Context, Result};
127 use cmake_tidy_config::MainConfiguration;
128
129 use super::{discover_targets, is_cmake_file, is_excluded};
130
131 static NEXT_TEMP_DIR: AtomicU64 = AtomicU64::new(0);
132
133 #[test]
134 fn discovers_direct_cmake_file_inputs() -> Result<()> {
135 let temp_dir = unique_temp_dir()?;
136 fs::create_dir_all(&temp_dir)
137 .with_context(|| format!("failed to create {}", temp_dir.display()))?;
138 let cmake_file = temp_dir.join("tooling.cmake");
139 fs::write(&cmake_file, "message(STATUS hi)\n")
140 .with_context(|| format!("failed to write {}", cmake_file.display()))?;
141
142 let targets = discover_targets(
143 std::slice::from_ref(&cmake_file),
144 &temp_dir,
145 &MainConfiguration::default(),
146 )?;
147
148 assert_eq!(targets, vec![cmake_file]);
149
150 fs::remove_dir_all(&temp_dir)
151 .with_context(|| format!("failed to remove {}", temp_dir.display()))?;
152 Ok(())
153 }
154
155 #[test]
156 fn deduplicates_targets_when_directory_and_file_overlap() -> Result<()> {
157 let temp_dir = unique_temp_dir()?;
158 fs::create_dir_all(&temp_dir)
159 .with_context(|| format!("failed to create {}", temp_dir.display()))?;
160 let cmakelists = temp_dir.join("CMakeLists.txt");
161 fs::write(&cmakelists, "project(example)\n")
162 .with_context(|| format!("failed to write {}", cmakelists.display()))?;
163
164 let targets = discover_targets(
165 &[temp_dir.clone(), cmakelists.clone()],
166 &temp_dir,
167 &MainConfiguration::default(),
168 )?;
169
170 assert_eq!(targets, vec![cmakelists]);
171
172 fs::remove_dir_all(&temp_dir)
173 .with_context(|| format!("failed to remove {}", temp_dir.display()))?;
174 Ok(())
175 }
176
177 #[test]
178 fn recognizes_cmake_file_names() {
179 assert!(is_cmake_file(std::path::Path::new("CMakeLists.txt")));
180 assert!(is_cmake_file(std::path::Path::new("tooling.cmake")));
181 assert!(!is_cmake_file(std::path::Path::new("notes.txt")));
182 }
183
184 #[test]
185 fn discovers_no_targets_for_excluded_file_input() -> Result<()> {
186 let temp_dir = unique_temp_dir()?;
187 fs::create_dir_all(&temp_dir)
188 .with_context(|| format!("failed to create {}", temp_dir.display()))?;
189 let excluded_dir = temp_dir.join("generated");
190 fs::create_dir_all(&excluded_dir)
191 .with_context(|| format!("failed to create {}", excluded_dir.display()))?;
192 let cmake_file = excluded_dir.join("tooling.cmake");
193 fs::write(&cmake_file, "message(STATUS hi)\n")
194 .with_context(|| format!("failed to write {}", cmake_file.display()))?;
195
196 let targets = discover_targets(
197 std::slice::from_ref(&cmake_file),
198 &temp_dir,
199 &MainConfiguration {
200 exclude: vec![PathBuf::from("generated")],
201 ..MainConfiguration::default()
202 },
203 )?;
204
205 assert!(targets.is_empty());
206
207 fs::remove_dir_all(&temp_dir)
208 .with_context(|| format!("failed to remove {}", temp_dir.display()))?;
209 Ok(())
210 }
211
212 #[test]
213 fn discover_targets_errors_for_missing_path() {
214 let temp_dir = std::env::temp_dir();
215 let missing = temp_dir.join("cmake-tidy-format-missing-input");
216 let error = discover_targets(
217 std::slice::from_ref(&missing),
218 &temp_dir,
219 &MainConfiguration::default(),
220 )
221 .expect_err("missing path should error");
222
223 assert!(error.to_string().contains("failed to read file metadata"));
224 }
225
226 #[test]
227 fn excludes_match_relative_paths() {
228 let current_directory = std::path::Path::new("/workspace");
229 let path = current_directory.join("generated").join("tooling.cmake");
230 let configuration = MainConfiguration {
231 exclude: vec![PathBuf::from("generated")],
232 ..MainConfiguration::default()
233 };
234
235 assert!(is_excluded(&path, current_directory, &configuration));
236 assert!(!is_excluded(
237 ¤t_directory.join("src").join("tooling.cmake"),
238 current_directory,
239 &configuration,
240 ));
241 }
242
243 fn unique_temp_dir() -> Result<PathBuf> {
244 let timestamp = SystemTime::now()
245 .duration_since(UNIX_EPOCH)
246 .context("system clock is before UNIX_EPOCH")?
247 .as_nanos();
248 let sequence = NEXT_TEMP_DIR.fetch_add(1, Ordering::Relaxed);
249 Ok(std::env::temp_dir().join(format!(
250 "cmake-tidy-format-unit-{}-{timestamp}-{sequence}",
251 std::process::id(),
252 )))
253 }
254}