cmake_package/
cmake.rs

1// SPDX-FileCopyrightText: 2024 Daniel Vrátil <dvratil@kde.org>
2//
3// SPDX-License-Identifier: MIT
4
5use crate::version::{Version, VersionError};
6use crate::{CMakePackage, CMakeTarget};
7
8use itertools::Itertools;
9use serde::Deserialize;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use tempfile::TempDir;
13use which::which;
14
15/// The minimum version of CMake required by this crate.
16pub const CMAKE_MIN_VERSION: &str = "3.19";
17
18/// A structure representing the CMake program found on the system.
19#[derive(Debug, Clone)]
20pub struct CMakeProgram {
21    /// Path to the `cmake` executable to be used.
22    pub path: PathBuf,
23    /// Version of the `cmake` detected. Must be at least [`CMAKE_MIN_VERSION`].
24    pub version: Version,
25}
26
27fn script_path(script: &str) -> PathBuf {
28    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
29        .join("cmake")
30        .join(script)
31}
32
33/// Errors that can occur while working with CMake.
34#[derive(Debug)]
35pub enum Error {
36    /// The `cmake` executable was not found in system `PATH` environment variable.
37    CMakeNotFound,
38    /// The available CMake version is too old (see [`CMAKE_MIN_VERSION`]).
39    UnsupportedCMakeVersion,
40    /// An internal error in the library.
41    Internal,
42    /// An I/O error while executing `cmake`
43    IO(std::io::Error),
44    /// An version-related error (e.g. the found package version is too old)
45    Version(VersionError),
46    /// The requested package was not found by CMake.
47    PackageNotFound,
48}
49
50#[derive(Clone, Debug, Deserialize)]
51struct PackageResult {
52    name: Option<String>,
53    version: Option<String>,
54    components: Option<Vec<String>>,
55}
56
57/// Find the CMake program on the system and check version compatibility.
58///
59/// Tries to find the `cmake` executable in all paths listed in the `PATH` environment variable.
60/// If found, it also checks that the version of CMake is at least [`CMAKE_MIN_VERSION`].
61///
62/// Returns [`CMakeProgram`] on success and [`Error::CMakeNotFound`] when the `cmake` executable
63/// is not found or [`Error::UnsupportedCMakeVersion`] when the version is too low.
64pub fn find_cmake() -> Result<CMakeProgram, Error> {
65    let path = which("cmake").or(Err(Error::CMakeNotFound))?;
66
67    let output = Command::new(&path)
68        .env("CLICOLOR", "0")
69        .arg("-P")
70        .arg(script_path("cmake_version.cmake"))
71        .output()
72        .or(Err(Error::Internal))?;
73
74    let version = String::from_utf8_lossy(&output.stderr)
75        .trim()
76        .to_string()
77        .try_into()
78        .or(Err(Error::UnsupportedCMakeVersion))?;
79
80    if version
81        < CMAKE_MIN_VERSION
82            .try_into()
83            .map_err(|_| Error::UnsupportedCMakeVersion)?
84    {
85        return Err(Error::UnsupportedCMakeVersion);
86    }
87
88    Ok(CMakeProgram { path, version })
89}
90
91fn get_temporary_working_directory() -> Result<TempDir, Error> {
92    #[cfg(test)]
93    let out_dir = std::env::temp_dir();
94    #[cfg(not(test))]
95    let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap_or_else(|_| {
96        panic!("OUT_DIR is not set, are you running the crate from build.rs?")
97    }));
98
99    // Make a unique directory inside
100    tempfile::Builder::new()
101        .prefix("cmake-package-rs")
102        .tempdir_in(out_dir)
103        .or(Err(Error::Internal))
104}
105
106fn setup_cmake_project(working_directory: &Path) -> Result<(), Error> {
107    std::fs::copy(
108        script_path("find_package.cmake"),
109        working_directory.join("CMakeLists.txt"),
110    )
111    .map_err(Error::IO)?;
112    Ok(())
113}
114
115fn stdio(verbose: bool) -> Stdio {
116    if verbose {
117        Stdio::inherit()
118    } else {
119        Stdio::null()
120    }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
124enum CMakeBuildType {
125    Debug,
126    Release,
127    RelWithDebInfo,
128    MinSizeRel,
129}
130
131fn build_type() -> CMakeBuildType {
132    // The PROFILE variable is set to "release" for release builds and to "debug" for any other build type.
133    // This is fairly easy to map to CMake's build types...
134    match std::env::var("PROFILE")
135        .as_ref()
136        .unwrap_or(&"debug".to_string())
137        .as_str()
138    {
139        "release" => {
140            // If the release profile is enabled, and also "s" or "z" optimimzation is set, meaning "optimize for binary size",
141            // then we want to use MinSizeRel.
142            // There's no way in CMake to combine MinSizeRel and RelWithDebInfo. Since those two options kinds contradict themselves,
143            // we make the assumption here that if the user wants to optimize for binary size, they want that more than they want
144            // debug info, so MinSizeRel is checked first.
145            let opt_level = std::env::var("OPT_LEVEL").unwrap_or("0".to_string());
146            if "sz".contains(&opt_level) {
147                return CMakeBuildType::MinSizeRel;
148            }
149
150            // If DEBUG is set to anything other than "0", "false" or "none" (meaning to include /some/ kind of debug info),
151            // then we want to use RelWithDebInfo.
152            let debug = std::env::var("DEBUG").unwrap_or("0".to_string());
153            if !["0", "false", "none"].contains(&debug.as_str()) {
154                return CMakeBuildType::RelWithDebInfo;
155            }
156
157            // For everything else, there's Mastercard...I mean Release.
158            CMakeBuildType::Release
159        }
160        // Any other profile (which really should only be "debug"), we map to Debug.
161        _ => CMakeBuildType::Debug,
162    }
163}
164
165/// Performs the actual `find_package()` operation with CMake
166pub(crate) fn find_package(
167    name: String,
168    version: Option<Version>,
169    components: Option<Vec<String>>,
170    verbose: bool,
171) -> Result<CMakePackage, Error> {
172    // Find cmake or panic
173    let cmake = find_cmake()?;
174
175    let working_directory = get_temporary_working_directory()?;
176
177    setup_cmake_project(working_directory.path())?;
178
179    let output_file = working_directory.path().join("package.json");
180    // Run the CMake - see the find_package.cmake script for docs
181    let mut command = Command::new(&cmake.path);
182    command
183        .stdout(stdio(verbose))
184        .stderr(stdio(verbose))
185        .current_dir(&working_directory)
186        .arg(".")
187        .arg(format!("-DCMAKE_BUILD_TYPE={:?}", build_type()))
188        .arg(format!("-DCMAKE_MIN_VERSION={CMAKE_MIN_VERSION}"))
189        .arg(format!("-DPACKAGE={}", name))
190        .arg(format!("-DOUTPUT_FILE={}", output_file.display()));
191    if let Some(version) = version {
192        command.arg(format!("-DVERSION={}", version));
193    }
194    if let Some(components) = components {
195        command.arg(format!("-DCOMPONENTS={}", components.join(";")));
196    }
197    command.output().map_err(Error::IO)?;
198
199    // Read from the generated JSON file
200    let reader = std::fs::File::open(output_file).map_err(Error::IO)?;
201    let package: PackageResult = serde_json::from_reader(reader).or(Err(Error::Internal))?;
202
203    let package_name = match package.name {
204        Some(name) => name,
205        None => return Err(Error::PackageNotFound),
206    };
207
208    let package_version = match package.version {
209        Some(version) => Some(version.try_into().map_err(Error::Version)?),
210        None => None, // Missing version is not an error
211    };
212
213    if let Some(version) = version {
214        if let Some(package_version) = package_version {
215            if package_version < version {
216                return Err(Error::Version(VersionError::VersionTooOld(package_version)));
217            }
218        }
219
220        // It's not an error if the package did not provide a version.
221    }
222
223    Ok(CMakePackage::new(
224        cmake,
225        working_directory,
226        package_name,
227        package_version,
228        package.components,
229        verbose,
230    ))
231}
232
233#[derive(Clone, Debug, Deserialize)]
234#[serde(untagged)]
235enum PropertyValue {
236    String(String),
237    Target(Target),
238}
239
240impl From<PropertyValue> for Vec<String> {
241    fn from(value: PropertyValue) -> Self {
242        match value {
243            PropertyValue::String(value) => vec![value],
244            PropertyValue::Target(target) => match target.location {
245                Some(location) => vec![location],
246                None => vec![],
247            }
248            .into_iter()
249            .chain(
250                target
251                    .interface_link_libraries
252                    .unwrap_or_default()
253                    .into_iter()
254                    .flat_map(Into::<Vec<String>>::into),
255            )
256            .collect(),
257        }
258    }
259}
260
261#[derive(Debug, Default, Deserialize, Clone)]
262#[serde(default, rename_all = "UPPERCASE")]
263struct Target {
264    name: String,
265    location: Option<String>,
266    #[serde(rename = "LOCATION_Release")]
267    location_release: Option<String>,
268    #[serde(rename = "LOCATION_Debug")]
269    location_debug: Option<String>,
270    #[serde(rename = "LOCATION_RelWithDebInfo")]
271    location_relwithdebinfo: Option<String>,
272    #[serde(rename = "LOCATION_MinSizeRel")]
273    location_minsizerel: Option<String>,
274    imported_implib: Option<String>,
275    #[serde(rename = "IMPORTED_IMPLIB_Release")]
276    imported_implib_release: Option<String>,
277    #[serde(rename = "IMPORTED_IMPLIB_Debug")]
278    imported_implib_debug: Option<String>,
279    #[serde(rename = "IMPORTED_IMPLIB_RelWithDebInfo")]
280    imported_implib_relwithdebinfo: Option<String>,
281    #[serde(rename = "IMPORTED_IMPLIB_MinSizeRel")]
282    imported_implib_minsizerel: Option<String>,
283    interface_compile_definitions: Option<Vec<String>>,
284    interface_compile_options: Option<Vec<String>>,
285    interface_include_directories: Option<Vec<String>>,
286    interface_link_directories: Option<Vec<String>>,
287    interface_link_libraries: Option<Vec<PropertyValue>>,
288    interface_link_options: Option<Vec<String>>,
289}
290
291/// Collects values from `property` of the current target and from `property` of
292/// all targets linked in `interface_link_libraries` recursively.
293///
294/// This basically implements the CMake logic as described in the documentation
295/// of e.g. [`INTERFACE_COMPILE_DEFINITIONS`][cmake_interface_compile_definitions] for
296/// the target property:
297///
298/// > When target dependencies are specified using [`target_link_libraries()`][target_link_libraries],
299/// > CMake will read this property from all target dependencies to determine the build properties of
300/// > the consumer.
301///
302/// This function preserves the order of the values as they are found in the targets, the value of the
303/// immediate `target` value is first, followed by all transitive properties of each linked target.
304///
305/// [cmake_interface_compile_definitions]: https://cmake.org/cmake/help/latest/prop_tgt/INTERFACE_COMPILE_DEFINITIONS.html
306/// [target_link_libraries]: https://cmake.org/cmake/help/latest/command/target_link_libraries.html
307fn collect_from_targets<'a>(
308    target: &'a Target,
309    property: impl Fn(&Target) -> &Option<Vec<String>> + 'a + Copy,
310) -> Vec<String> {
311    property(target)
312        .as_ref()
313        .map_or(Vec::new(), Clone::clone)
314        .into_iter()
315        .chain(
316            target
317                .interface_link_libraries
318                .as_ref()
319                .map_or(Vec::new(), Clone::clone)
320                .iter()
321                .filter_map(|value| match value {
322                    PropertyValue::String(_) => None,
323                    PropertyValue::Target(target) => Some(target),
324                })
325                .flat_map(|target| collect_from_targets(target, property)),
326        )
327        .collect()
328}
329
330/// Equivalent to `collect_from_target`, but it sorts and deduplicates the properties - use with
331/// care, as the order of the properties might be important (e.g. for compile options).
332fn collect_from_targets_unique<'a>(
333    target: &'a Target,
334    property: impl Fn(&Target) -> &Option<Vec<String>> + 'a + Copy,
335) -> Vec<String> {
336    collect_from_targets(target, property)
337        .into_iter()
338        .sorted()
339        .dedup()
340        .collect()
341}
342
343fn implib_for_build_type(build_type: CMakeBuildType, target: &Target) -> Option<String> {
344    match build_type {
345        CMakeBuildType::Debug => target
346            .imported_implib_debug
347            .clone()
348            .or(target.imported_implib.clone()),
349        CMakeBuildType::Release => target
350            .imported_implib_release
351            .clone()
352            .or(target.imported_implib.clone()),
353        CMakeBuildType::RelWithDebInfo => target
354            .imported_implib_relwithdebinfo
355            .clone()
356            .or(target.imported_implib.clone()),
357        CMakeBuildType::MinSizeRel => target
358            .imported_implib_minsizerel
359            .clone()
360            .or(target.imported_implib.clone()),
361    }
362    .or_else(|| location_for_build_type(build_type, target))
363}
364
365fn location_for_build_type(build_type: CMakeBuildType, target: &Target) -> Option<String> {
366    match build_type {
367        CMakeBuildType::Debug => target.location_debug.clone().or(target.location.clone()),
368        CMakeBuildType::Release => target.location_release.clone().or(target.location.clone()),
369        CMakeBuildType::RelWithDebInfo => target
370            .location_relwithdebinfo
371            .clone()
372            .or(target.location.clone()),
373        CMakeBuildType::MinSizeRel => target
374            .location_minsizerel
375            .clone()
376            .or(target.location.clone()),
377    }
378}
379
380fn library_for_build_type(build_type: CMakeBuildType, target: &Target) -> Option<String> {
381    if cfg!(target_os = "windows") {
382        implib_for_build_type(build_type, target)
383    } else {
384        location_for_build_type(build_type, target)
385    }
386}
387
388impl Target {
389    fn into_cmake_target(self, build_type: CMakeBuildType) -> CMakeTarget {
390        CMakeTarget {
391            compile_definitions: collect_from_targets_unique(&self, |target| {
392                &target.interface_compile_definitions
393            }),
394            compile_options: collect_from_targets(&self, |target| {
395                &target.interface_compile_options
396            }),
397            include_directories: collect_from_targets_unique(&self, |target| {
398                &target.interface_include_directories
399            }),
400            link_directories: collect_from_targets_unique(&self, |target| {
401                &target.interface_link_directories
402            }),
403            link_options: collect_from_targets(&self, |target| &target.interface_link_options),
404            link_libraries: library_for_build_type(build_type, &self)
405                .as_ref()
406                .map_or(vec![], |location| vec![location.clone()])
407                .into_iter()
408                .chain(
409                    self.interface_link_libraries
410                        .as_ref()
411                        .map_or(Vec::new(), Clone::clone)
412                        .into_iter()
413                        .flat_map(Into::<Vec<String>>::into),
414                )
415                .sorted() // FIXME: should we really do this for libraries? Linking order might be important...
416                .dedup()
417                .collect(),
418            name: self.name,
419            location: self.location,
420        }
421    }
422}
423
424/// Finds the specified target in the CMake package and extracts its properties.
425/// Returns `None` if the target was not found.
426pub(crate) fn find_target(
427    package: &CMakePackage,
428    target: impl Into<String>,
429) -> Option<CMakeTarget> {
430    let target: String = target.into();
431
432    // Run the CMake script
433    let output_file = package.working_directory.path().join(format!(
434        "target_{}.json",
435        target.to_lowercase().replace(":", "_")
436    ));
437    let build_type = build_type();
438    let mut command = Command::new(&package.cmake.path);
439    command
440        .stdout(stdio(package.verbose))
441        .stderr(stdio(package.verbose))
442        .current_dir(package.working_directory.path())
443        .arg(".")
444        .arg(format!("-DCMAKE_BUILD_TYPE={:?}", build_type))
445        .arg(format!("-DCMAKE_MIN_VERSION={CMAKE_MIN_VERSION}"))
446        .arg(format!("-DPACKAGE={}", package.name))
447        .arg(format!("-DTARGET={}", target))
448        .arg(format!("-DOUTPUT_FILE={}", output_file.display()));
449    if let Some(version) = package.version {
450        command.arg(format!("-DVERSION={}", version));
451    }
452    if let Some(components) = &package.components {
453        command.arg(format!("-DCOMPONENTS={}", components.join(";")));
454    }
455    command.output().ok()?;
456
457    // Read from the generated JSON file
458    let reader = std::fs::File::open(&output_file).ok()?;
459    let target: Target = serde_json::from_reader(reader)
460        .map_err(|e| {
461            eprintln!("Failed to parse target JSON: {:?}", e);
462        })
463        .ok()?;
464    if target.name.is_empty() {
465        return None;
466    }
467    Some(target.into_cmake_target(build_type))
468}
469
470#[cfg(test)]
471mod testing {
472    use scopeguard::{guard, ScopeGuard};
473    use serial_test::serial;
474
475    use super::*;
476
477    #[test]
478    fn from_target() {
479        let target = Target {
480            name: "my_target".to_string(),
481            location: Some("/path/to/target.so".to_string()),
482            interface_compile_definitions: Some(vec!["DEFINE1".to_string(), "DEFINE2".to_string()]),
483            interface_compile_options: Some(vec!["-O2".to_string(), "-Wall".to_string()]),
484            interface_include_directories: Some(vec!["/path/to/include".to_string()]),
485            interface_link_directories: Some(vec!["/path/to/lib".to_string()]),
486            interface_link_options: Some(vec!["-L/path/to/lib".to_string()]),
487            interface_link_libraries: Some(vec![
488                PropertyValue::String("library1".to_string()),
489                PropertyValue::String("library2".to_string()),
490                PropertyValue::Target(Target {
491                    name: "dependency".to_string(),
492                    location: Some("/path/to/dependency.so".to_string()),
493                    interface_compile_definitions: Some(vec!["DEFINE3".to_string()]),
494                    interface_compile_options: Some(vec!["-O3".to_string()]),
495                    interface_include_directories: Some(vec![
496                        "/path/to/dependency/include".to_string()
497                    ]),
498                    interface_link_directories: Some(vec!["/path/to/dependency/lib".to_string()]),
499                    interface_link_options: Some(vec!["-L/path/to/dependency/lib".to_string()]),
500                    interface_link_libraries: Some(vec![PropertyValue::String(
501                        "dependency_library".to_string(),
502                    )]),
503                    ..Default::default()
504                }),
505            ]),
506            ..Default::default()
507        };
508
509        let cmake_target: CMakeTarget = target.into_cmake_target(CMakeBuildType::Release);
510
511        assert_eq!(cmake_target.name, "my_target");
512        assert_eq!(
513            cmake_target.compile_definitions,
514            vec!["DEFINE1", "DEFINE2", "DEFINE3"]
515        );
516        assert_eq!(cmake_target.compile_options, vec!["-O2", "-Wall", "-O3"]);
517        assert_eq!(
518            cmake_target.include_directories,
519            vec!["/path/to/dependency/include", "/path/to/include"]
520        );
521        assert_eq!(
522            cmake_target.link_directories,
523            vec!["/path/to/dependency/lib", "/path/to/lib"]
524        );
525        assert_eq!(
526            cmake_target.link_options,
527            vec!["-L/path/to/lib", "-L/path/to/dependency/lib"]
528        );
529        assert_eq!(
530            cmake_target.link_libraries,
531            vec![
532                "/path/to/dependency.so",
533                "/path/to/target.so",
534                "dependency_library",
535                "library1",
536                "library2",
537            ]
538        );
539    }
540
541    #[test]
542    fn from_debug_target() {
543        let target = Target {
544            name: "test_target".to_string(),
545            location: Some("/path/to/target.so".to_string()),
546            location_debug: Some("/path/to/libtarget_debug.so".to_string()),
547            ..Default::default()
548        };
549
550        let cmake_target = target.into_cmake_target(CMakeBuildType::Debug);
551        assert_eq!(
552            cmake_target.link_libraries,
553            vec!["/path/to/libtarget_debug.so"]
554        );
555    }
556
557    #[test]
558    fn from_json() {
559        let json = r#"
560{
561  "INTERFACE_INCLUDE_DIRECTORIES" : [ "/usr/include" ],
562  "INTERFACE_LINK_LIBRARIES" :
563  [
564    {
565      "INTERFACE_INCLUDE_DIRECTORIES" : [ "/usr/include" ],
566      "LOCATION" : "/usr/lib/libcrypto.so",
567      "NAME" : "OpenSSL::Crypto"
568    }
569  ],
570  "LOCATION" : "/usr/lib/libssl.so",
571  "NAME" : "OpenSSL::SSL"
572}
573"#;
574        let target: Target = serde_json::from_str(json).expect("Failed to parse JSON");
575        assert_eq!(target.name, "OpenSSL::SSL");
576        assert_eq!(target.location, Some("/usr/lib/libssl.so".to_string()));
577        assert_eq!(
578            target.interface_include_directories,
579            Some(vec!["/usr/include".to_string()])
580        );
581        assert!(target.interface_link_libraries.is_some());
582        assert_eq!(target.interface_link_libraries.as_ref().unwrap().len(), 1);
583        let sub_target = target
584            .interface_link_libraries
585            .as_ref()
586            .unwrap()
587            .first()
588            .unwrap();
589        match sub_target {
590            PropertyValue::Target(sub_target) => {
591                assert_eq!(sub_target.name, "OpenSSL::Crypto");
592                assert_eq!(
593                    sub_target.location,
594                    Some("/usr/lib/libcrypto.so".to_string())
595                );
596            }
597            _ => panic!("Expected PropertyValue::Target"),
598        }
599    }
600
601    fn clear_env(name: &'static str) -> ScopeGuard<(), impl FnOnce(())> {
602        let value = std::env::var(name);
603        std::env::remove_var(name);
604        guard((), move |_| {
605            if let Ok(value) = value {
606                std::env::set_var(name, value);
607            } else {
608                std::env::remove_var(name);
609            }
610        })
611    }
612
613    #[test]
614    #[serial]
615    fn test_build_type() {
616        let _profile = clear_env("PROFILE");
617        let _debug = clear_env("DEBUG");
618        let _opt_level = clear_env("OPT_LEVEL");
619
620        assert_eq!(build_type(), CMakeBuildType::Debug);
621
622        std::env::set_var("PROFILE", "release");
623        assert_eq!(build_type(), CMakeBuildType::Release);
624
625        std::env::set_var("DEBUG", "1");
626        assert_eq!(build_type(), CMakeBuildType::RelWithDebInfo);
627
628        std::env::set_var("DEBUG", "0");
629        std::env::set_var("OPT_LEVEL", "s");
630        assert_eq!(build_type(), CMakeBuildType::MinSizeRel);
631    }
632}