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