1use std::{path::PathBuf, str::FromStr};
2
3use crate::{
4 Cacher, ClangTool, PyPiDownloadError, PyPiDownloader,
5 downloader::{native_packages::try_install_package, static_dist::StaticDistDownloader},
6 tool::{GetClangPathError, GetClangVersionError},
7 utils::normalize_path,
8};
9use semver::{Version, VersionReq};
10
11#[derive(Debug, Clone)]
12pub struct ClangVersion {
13 pub version: Version,
14 pub path: PathBuf,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub enum RequestedVersion {
20 Path(PathBuf),
22
23 #[default]
25 SystemDefault,
26
27 Requirement(VersionReq),
31
32 NoValue,
37}
38
39#[derive(Debug, thiserror::Error)]
41pub enum GetToolError {
42 #[error("No executable found for the specified version requirement")]
44 NotFound,
45
46 #[error("Failed to parse version: {0}")]
48 VersionParseError(String),
49
50 #[error("The version requirement does not satisfy any known/supported clang version")]
52 UnsupportedVersion,
53
54 #[error("Binary executable in cache has no parent directory")]
56 ExecutablePathNoParent,
57
58 #[error(transparent)]
60 GetClangVersion(#[from] GetClangVersionError),
61
62 #[error("Failed to get the clang executable path: {0}")]
64 GetClangPathError(#[from] GetClangPathError),
65
66 #[error("Failed to create symlink for the downloaded binary: {0}")]
68 SymlinkError(std::io::Error),
69
70 #[error("Failed to download tool from PyPi: {0}")]
72 PyPiDownloadError(#[from] PyPiDownloadError),
73}
74
75impl RequestedVersion {
76 pub async fn eval_tool(
77 &self,
78 tool: &ClangTool,
79 overwrite_symlink: bool,
80 directory: Option<&PathBuf>,
81 ) -> Result<Option<ClangVersion>, GetToolError> {
82 match self {
83 RequestedVersion::Path(_) => {
84 let exec_path = tool.get_exe_path(self)?;
85 let version = tool.capture_version(&exec_path)?;
86 log::info!(
87 "Found {tool} version {version} at path: {:?}",
88 exec_path.to_string_lossy()
89 );
90 Ok(Some(ClangVersion {
91 version,
92 path: exec_path,
93 }))
94 }
95 RequestedVersion::SystemDefault => {
96 let path = tool.get_exe_path(&Self::SystemDefault)?;
97 let version = tool.capture_version(&path)?;
98 log::info!(
99 "Found {tool} version {version} at path: {:?}",
100 path.to_string_lossy()
101 );
102 Ok(Some(ClangVersion { version, path }))
103 }
104 RequestedVersion::Requirement(version_req) => {
105 if let Ok(path) = tool.get_exe_path(&Self::Requirement(version_req.clone())) {
107 let version = tool.capture_version(&path)?;
108 if version_req.matches(&version) {
109 log::info!(
110 "Found {tool} version {version} at path: {:?}",
111 path.to_string_lossy()
112 );
113 return Ok(Some(ClangVersion { version, path }));
114 }
115 }
116
117 let bin_ext = if cfg!(windows) { ".exe" } else { "" };
119 let min_ver = get_min_ver(version_req).ok_or(GetToolError::UnsupportedVersion)?;
120 let cached_bin = StaticDistDownloader::get_cache_dir()
121 .join("bin")
122 .join(format!("{tool}-{min_ver}{bin_ext}"));
123 if cached_bin.exists() {
124 let version = tool.capture_version(&cached_bin)?;
125 if version_req.matches(&version) {
126 log::info!(
127 "Found {tool} version {version} in cache at path: {:?}",
128 cached_bin.to_string_lossy()
129 );
130 return Ok(Some(ClangVersion {
131 version,
132 path: cached_bin,
133 }));
134 }
135 }
136
137 let bin = match PyPiDownloader::download_tool(tool, version_req, directory).await {
139 Ok(bin) => bin,
140 Err(e) => {
141 log::error!("Failed to download {tool} {version_req} from PyPi: {e}");
142 if let Some(result) =
143 try_install_package(tool, version_req, &min_ver).await?
144 {
145 return Ok(Some(result));
146 }
147 log::info!("Falling back to downloading {tool} static binaries.");
148 match StaticDistDownloader::download_tool(tool, version_req, directory)
149 .await
150 {
151 Ok(bin) => bin,
152 Err(e) => {
153 log::error!(
154 "Failed to download {tool} from static distribution: {e}"
155 );
156 return Err(GetToolError::NotFound);
157 }
158 }
159 }
160 };
161
162 let bin_dir = bin.parent().ok_or(GetToolError::ExecutablePathNoParent)?;
164 let symlink_path = bin_dir.join(format!("{tool}{bin_ext}"));
165 tool.symlink_bin(&bin, &symlink_path, overwrite_symlink)
166 .map_err(GetToolError::SymlinkError)?;
167 let version = tool.capture_version(&bin)?;
168 Ok(Some(ClangVersion { version, path: bin }))
169 }
170 RequestedVersion::NoValue => {
171 log::info!(
172 "{} version: {}",
173 option_env!("CARGO_BIN_NAME").unwrap_or(env!("CARGO_PKG_NAME")),
174 env!("CARGO_PKG_VERSION")
175 );
176 Ok(None)
177 }
178 }
179 }
180}
181
182pub fn get_min_ver(version_req: &VersionReq) -> Option<Version> {
183 let mut result = None;
184 let supported_version_range = StaticDistDownloader::get_major_version_range();
185 for major in supported_version_range.rev() {
186 let ver = Version::new(major as u64, 0, 0);
187 if version_req.matches(&ver) {
188 result = Some(ver);
189 }
190 }
191 result
192}
193
194#[derive(Debug, thiserror::Error)]
196pub enum RequestedVersionParsingError {
197 #[error("The specified version is not a proper version requirement or a valid path: {0}")]
199 InvalidInput(String),
200
201 #[error("Unknown parent directory of the given file path for `--version`: {0}")]
203 InvalidPath(String),
204
205 #[error("Failed to canonicalize path '{0}': {1:?}")]
207 NonCanonicalPath(String, std::io::Error),
208}
209
210impl FromStr for RequestedVersion {
211 type Err = RequestedVersionParsingError;
212
213 fn from_str(input: &str) -> Result<Self, Self::Err> {
214 if input.is_empty() {
215 Ok(Self::SystemDefault)
216 } else if input == "CPP-LINTER-VERSION" {
217 Ok(Self::NoValue)
218 } else if let Ok(req) = VersionReq::parse(input) {
219 Ok(Self::Requirement(req))
220 } else {
221 let path = PathBuf::from(input);
222 if !path.exists() {
223 return Err(RequestedVersionParsingError::InvalidInput(
224 input.to_string(),
225 ));
226 }
227 let path = if !path.is_dir() {
228 path.parent()
229 .ok_or(RequestedVersionParsingError::InvalidPath(input.to_string()))?
230 .to_path_buf()
231 } else {
232 path
233 };
234 let path = match path.canonicalize() {
235 Ok(p) => Ok(normalize_path(&p)),
236 Err(e) => Err(RequestedVersionParsingError::NonCanonicalPath(
237 input.to_string(),
238 e,
239 )),
240 }?;
241 Ok(Self::Path(path))
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use std::{path::PathBuf, str::FromStr};
249
250 use semver::VersionReq;
251 use tempfile::TempDir;
252
253 use super::RequestedVersion;
254 use crate::{ClangTool, utils::normalize_path};
255
256 #[test]
259 fn validate_version_path() {
260 let this_path_str = "src/version.rs";
261 let this_path = PathBuf::from(this_path_str);
262 let this_canonical = this_path.canonicalize().unwrap();
263 let parent = this_canonical.parent().unwrap();
264 let expected = normalize_path(parent);
265 let req_ver = RequestedVersion::from_str(this_path_str).unwrap();
266 if let RequestedVersion::Path(parsed) = req_ver {
267 assert_eq!(&parsed, &expected);
268 }
269
270 assert!(RequestedVersion::from_str("file.rs").is_err());
271 }
272
273 #[test]
274 fn validate_version_exact() {
275 let req_ver = RequestedVersion::from_str("12").unwrap();
276 if let RequestedVersion::Requirement(req) = req_ver {
277 assert_eq!(req.to_string(), "^12");
278 }
279 }
280
281 #[tokio::test]
282 async fn eval_no_value() {
283 let result = RequestedVersion::NoValue
284 .eval_tool(&ClangTool::ClangFormat, false, None)
285 .await
286 .unwrap();
287 assert!(result.is_none());
288 }
289
290 #[tokio::test]
293 async fn eval_download_path() {
294 let tmp_cache_dir = TempDir::new().unwrap();
295 unsafe {
296 std::env::set_var("CPP_LINTER_CACHE", tmp_cache_dir.path());
297 }
298 let tool = ClangTool::ClangFormat;
299 let version_req =
302 VersionReq::parse(option_env!("MIN_CLANG_TOOLS_VERSION").unwrap_or("16")).unwrap();
303 let downloaded_clang = RequestedVersion::Requirement(version_req.clone())
304 .eval_tool(&tool, false, Some(&PathBuf::from(tmp_cache_dir.path())))
305 .await
306 .unwrap()
307 .unwrap();
308 println!("Downloaded clang-format: {downloaded_clang:?}");
309 let req_ver = RequestedVersion::Path(downloaded_clang.path.parent().unwrap().to_owned());
310 let result = req_ver
311 .eval_tool(&tool, false, None)
312 .await
313 .unwrap()
314 .unwrap();
315 println!("Evaluated clang-format from path: {result:?}");
316 assert!(
317 version_req.matches(&result.version),
318 "Expected {downloaded_clang:?} does not match {result:?}",
319 );
320 assert_eq!(result.version, downloaded_clang.version);
321 assert_eq!(result.path.parent(), downloaded_clang.path.parent());
322 }
323
324 #[tokio::test]
328 async fn eval_version() {
329 let clang_version = option_env!("CLANG_VERSION").unwrap_or("12.0.1");
330 for tool in [ClangTool::ClangFormat, ClangTool::ClangTidy] {
331 let version_req = VersionReq::parse(clang_version).unwrap();
332 println!("Installing {tool} with version requirement: {version_req}");
333 let clang_path = RequestedVersion::Requirement(version_req.clone())
334 .eval_tool(&tool, false, None)
335 .await
336 .unwrap()
337 .unwrap();
338 eprintln!("Using {clang_path:?}");
339 }
341 }
342}