Skip to main content

semifold_resolver/resolver/
cpp.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    path::{Path, PathBuf},
4};
5
6use regex::Regex;
7use semifold_core::{
8    DependencyKind, EcosystemId, EditSource, FileEdit, FileEditExpectation, FileHash, PackageId,
9    PackageSnapshot, VersionMap, VersionSource,
10};
11
12use crate::{
13    adapter::{
14        AdapterError, EcosystemAdapter, EcosystemPlanInput, ManifestDependency, PackageInspection,
15        PackageLocation, ParsedPackage,
16    },
17    config::{PackageConfig, ReleaseChannel},
18    error::ResolveError,
19    utils,
20};
21
22/// C++ resolver for CMake-based projects
23pub struct CppResolver;
24
25impl CppResolver {
26    fn package_config(path: impl Into<PathBuf>) -> PackageConfig {
27        PackageConfig {
28            path: path.into(),
29            resolver: EcosystemId::CPP,
30            publish: None,
31            channel: ReleaseChannel::Stable,
32            channel_bump: None,
33            assets: Vec::new(),
34            github_release: None,
35            depends_on: vec![],
36        }
37    }
38
39    fn package_inspection(
40        id: PackageId,
41        package: ParsedPackage,
42        dependencies: Vec<ManifestDependency>,
43    ) -> Result<PackageInspection, AdapterError> {
44        let path = camino::Utf8PathBuf::from_path_buf(package.path).map_err(|path| {
45            AdapterError::InvalidInput {
46                reason: format!("C++ package path is not valid UTF-8: {}", path.display()),
47            }
48        })?;
49        Ok(PackageInspection {
50            id,
51            manifest_name: package.name,
52            version: package.version,
53            version_source: package.version_source,
54            ecosystem: EcosystemId::CPP,
55            path,
56            publishable: !package.private,
57            dependencies,
58        })
59    }
60
61    pub fn plan_file_edits(
62        root: &Path,
63        package: &PackageSnapshot,
64        versions: &VersionMap,
65    ) -> Result<Vec<FileEdit>, ResolveError> {
66        let next_version =
67            versions
68                .get(&package.id)
69                .ok_or_else(|| ResolveError::InvalidConfig {
70                    path: root.join(package.path.as_std_path()),
71                    reason: format!("missing planned version for {}", package.id),
72                })?;
73        let version = CppResolver.encode_version(next_version).map_err(|error| {
74            ResolveError::InvalidVersion {
75                version: next_version.to_string(),
76                reason: error.to_string(),
77            }
78        })?;
79        let package_path = root.join(package.path.as_std_path());
80        let cmake_path = package_path.join("CMakeLists.txt");
81        let cmake = std::fs::read_to_string(&cmake_path)?;
82        let re = Regex::new(
83            r"(?i)(project\s*\([^)]*VERSION\s+)([\d.]+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?)",
84        )
85        .map_err(|error| ResolveError::ParseError {
86            path: cmake_path.clone(),
87            reason: error.to_string(),
88        })?;
89        let cmake_updated = re.replace(&cmake, |caps: &regex::Captures| {
90            format!("{}{}", &caps[1], version)
91        });
92        let mut edits = vec![FileEdit {
93            path: package.path.join("CMakeLists.txt"),
94            expected: FileEditExpectation::Existing {
95                hash: FileHash::from_bytes(cmake.as_bytes()),
96            },
97            new_content: cmake_updated.into_owned(),
98            source: EditSource::PackageVersion {
99                package: package.id.clone(),
100            },
101        }];
102        let vcpkg_path = package_path.join("vcpkg.json");
103        if vcpkg_path.exists() {
104            let content = std::fs::read_to_string(&vcpkg_path)?;
105            let updated = utils::replace_root_json_string_field(&content, "version", &version)
106                .ok_or_else(|| ResolveError::ParseError {
107                    path: vcpkg_path.clone(),
108                    reason: "vcpkg.json version field could not be replaced".to_string(),
109                })?;
110            edits.push(FileEdit {
111                path: package.path.join("vcpkg.json"),
112                expected: FileEditExpectation::Existing {
113                    hash: FileHash::from_bytes(content.as_bytes()),
114                },
115                new_content: updated,
116                source: EditSource::PackageVersion {
117                    package: package.id.clone(),
118                },
119            });
120        }
121        Ok(edits)
122    }
123    fn literal_subdirectories(&self, directory: &Path) -> Result<Vec<PathBuf>, ResolveError> {
124        let cmake_path = directory.join("CMakeLists.txt");
125        let content = std::fs::read_to_string(&cmake_path)?;
126        let re =
127            Regex::new(r#"(?im)^\s*add_subdirectory\s*\(\s*["']?([^"'\s\)]+)"#).map_err(|e| {
128                ResolveError::ParseError {
129                    path: cmake_path.clone(),
130                    reason: format!("Invalid regex: {e}"),
131                }
132            })?;
133
134        Ok(re
135            .captures_iter(&content)
136            .filter_map(|captures| captures.get(1))
137            .map(|member| directory.join(member.as_str()))
138            .filter(|member| member.join("CMakeLists.txt").exists())
139            .collect())
140    }
141
142    fn workspace_members(&self, root: &Path) -> Result<Vec<PathBuf>, ResolveError> {
143        let canonical_root = std::fs::canonicalize(root)?;
144        let mut pending = BTreeSet::from([canonical_root.clone()]);
145        let mut visited = BTreeSet::new();
146        let mut members = BTreeSet::new();
147
148        while let Some(directory) = pending.pop_first() {
149            if !visited.insert(directory.clone()) {
150                continue;
151            }
152            if directory != canonical_root {
153                let cmake_path = directory.join("CMakeLists.txt");
154                let content = std::fs::read_to_string(&cmake_path)?;
155                if self.has_versioned_project(&content, &cmake_path)? {
156                    members.insert(directory.clone());
157                }
158            }
159            for child in self.literal_subdirectories(&directory)? {
160                let canonical_child = std::fs::canonicalize(&child)?;
161                if !canonical_child.starts_with(&canonical_root) {
162                    return Err(ResolveError::InvalidConfig {
163                        path: child,
164                        reason: "add_subdirectory path escapes the project root".to_string(),
165                    });
166                }
167                pending.insert(canonical_child);
168            }
169        }
170
171        members
172            .into_iter()
173            .map(|member| {
174                member
175                    .strip_prefix(&canonical_root)
176                    .map(Path::to_path_buf)
177                    .map_err(|_| ResolveError::InvalidConfig {
178                        path: member,
179                        reason: "C++ workspace member is outside the project root".to_string(),
180                    })
181            })
182            .collect()
183    }
184
185    fn has_versioned_project(
186        &self,
187        content: &str,
188        cmake_path: &Path,
189    ) -> Result<bool, ResolveError> {
190        Regex::new(r"(?i)project\s*\([^)]*VERSION\s+")
191            .map(|regex| regex.is_match(content))
192            .map_err(|error| ResolveError::ParseError {
193                path: cmake_path.to_path_buf(),
194                reason: format!("Invalid regex: {error}"),
195            })
196    }
197
198    fn internal_dependencies(
199        &self,
200        root: &Path,
201        pkg_config: &PackageConfig,
202    ) -> Result<Vec<String>, ResolveError> {
203        let cmake_path = root.join(&pkg_config.path).join("CMakeLists.txt");
204        let content = std::fs::read_to_string(&cmake_path)?;
205        let package_name = self.extract_name_from_content(&content, &cmake_path)?;
206        let re = Regex::new(&format!(
207            r"(?is)target_link_libraries\s*\(\s*{}\s+([^\)]*)\)",
208            regex::escape(&package_name)
209        ))
210        .map_err(|e| ResolveError::ParseError {
211            path: cmake_path.clone(),
212            reason: format!("Invalid regex: {e}"),
213        })?;
214
215        Ok(re
216            .captures_iter(&content)
217            .filter_map(|captures| captures.get(1))
218            .flat_map(|dependencies| {
219                dependencies
220                    .as_str()
221                    .split_whitespace()
222                    .map(|dependency| dependency.trim_matches(['\'', '"']))
223                    .filter(|dependency| !matches!(*dependency, "PUBLIC" | "PRIVATE" | "INTERFACE"))
224                    .map(str::to_string)
225                    .collect::<Vec<_>>()
226            })
227            .collect())
228    }
229
230    fn manifest_dependencies(
231        &self,
232        root: &Path,
233        pkg_config: &PackageConfig,
234    ) -> Result<Vec<ManifestDependency>, ResolveError> {
235        Ok(self
236            .internal_dependencies(root, pkg_config)?
237            .into_iter()
238            .map(|manifest_name| ManifestDependency {
239                manifest_name,
240                kind: DependencyKind::Runtime,
241                requirement: None,
242            })
243            .collect())
244    }
245
246    /// Extract version from CMakeLists.txt content
247    fn extract_version_from_content(
248        &self,
249        content: &str,
250        cmake_path: &Path,
251    ) -> Result<String, ResolveError> {
252        // Match: project(...VERSION x.y.z...)
253        let re = Regex::new(
254            r"(?i)project\s*\([^)]*VERSION\s+([\d.]+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?)",
255        )
256        .map_err(|e| ResolveError::ParseError {
257            path: cmake_path.to_path_buf(),
258            reason: format!("Invalid regex: {}", e),
259        })?;
260
261        let version = re
262            .captures(content)
263            .and_then(|caps| caps.get(1))
264            .map(|m| m.as_str().to_string())
265            .ok_or_else(|| ResolveError::ParseError {
266                path: cmake_path.to_path_buf(),
267                reason: "VERSION not found in project() declaration".to_string(),
268            })?;
269
270        Ok(version)
271    }
272
273    /// Extract project name from CMakeLists.txt content
274    fn extract_name_from_content(
275        &self,
276        content: &str,
277        cmake_path: &Path,
278    ) -> Result<String, ResolveError> {
279        // Match: project(ProjectName ...) or project("project-name" ...)
280        let re = Regex::new(r#"(?i)project\s*\(\s*["']?([a-zA-Z0-9_-]+)["']?"#).map_err(|e| {
281            ResolveError::ParseError {
282                path: cmake_path.to_path_buf(),
283                reason: format!("Invalid regex: {}", e),
284            }
285        })?;
286
287        let name = re
288            .captures(content)
289            .and_then(|caps| caps.get(1))
290            .map(|m| m.as_str().to_string())
291            .ok_or_else(|| ResolveError::ParseError {
292                path: cmake_path.to_path_buf(),
293                reason: "Project name not found in project() declaration".to_string(),
294            })?;
295
296        Ok(name)
297    }
298}
299
300impl EcosystemAdapter for CppResolver {
301    fn ecosystem(&self) -> EcosystemId {
302        EcosystemId::CPP
303    }
304
305    fn encode_version(&self, version: &semver::Version) -> Result<String, AdapterError> {
306        if version.pre.is_empty() && version.build.is_empty() {
307            Ok(version.to_string())
308        } else {
309            Err(AdapterError::InvalidVersion {
310                ecosystem: EcosystemId::CPP,
311                version: version.clone(),
312                reason: "CMake project(VERSION) only accepts stable numeric versions".to_string(),
313            })
314        }
315    }
316
317    fn discover(&self, root: &camino::Utf8Path) -> Result<Vec<PackageInspection>, AdapterError> {
318        let packages = self.discover_packages(root.as_std_path())?;
319        let mut inspections = packages
320            .into_iter()
321            .map(|package| {
322                let dependencies = self.manifest_dependencies(
323                    root.as_std_path(),
324                    &Self::package_config(package.path.clone()),
325                )?;
326                Self::package_inspection(
327                    PackageId::new(package.name.clone()),
328                    package,
329                    dependencies,
330                )
331            })
332            .collect::<Result<Vec<_>, AdapterError>>()?;
333        inspections.sort_by(|left, right| {
334            left.id
335                .cmp(&right.id)
336                .then_with(|| left.path.cmp(&right.path))
337        });
338        Ok(inspections)
339    }
340
341    fn inspect(&self, location: &PackageLocation) -> Result<PackageInspection, AdapterError> {
342        if location.path.is_absolute()
343            || location
344                .path
345                .components()
346                .any(|component| component == camino::Utf8Component::ParentDir)
347        {
348            return Err(AdapterError::InvalidInput {
349                reason: format!(
350                    "C++ package path must be relative to the project root: {}",
351                    location.path
352                ),
353            });
354        }
355        let config = Self::package_config(location.path.as_std_path());
356        let package = self.parse_package(location.project_root.as_std_path(), &config)?;
357        let dependencies =
358            self.manifest_dependencies(location.project_root.as_std_path(), &config)?;
359        Self::package_inspection(location.id.clone(), package, dependencies)
360    }
361
362    fn plan_edits(&self, input: EcosystemPlanInput<'_>) -> Result<Vec<FileEdit>, AdapterError> {
363        if input
364            .workspace_packages
365            .iter()
366            .any(|package| package.ecosystem != EcosystemId::CPP)
367        {
368            return Err(AdapterError::InvalidInput {
369                reason: "C++ edit planning received a non-C++ workspace package".to_string(),
370            });
371        }
372        let workspace_packages = input
373            .workspace_packages
374            .iter()
375            .map(|package| (package.id.clone(), package))
376            .collect::<BTreeMap<_, _>>();
377        let released_packages = input.released_packages.iter().collect::<BTreeSet<_>>();
378
379        released_packages
380            .into_iter()
381            .map(|id| {
382                let package = workspace_packages.get(id).copied().ok_or_else(|| {
383                    AdapterError::InvalidInput {
384                        reason: format!("released C++ package {id} is not in the workspace"),
385                    }
386                })?;
387                Ok(Self::plan_file_edits(
388                    input.project_root.as_std_path(),
389                    package,
390                    input.versions,
391                )?)
392            })
393            .collect::<Result<Vec<_>, AdapterError>>()
394            .map(|edits| edits.into_iter().flatten().collect())
395    }
396}
397
398impl CppResolver {
399    fn parse_package(
400        &self,
401        root: &Path,
402        pkg_config: &PackageConfig,
403    ) -> Result<ParsedPackage, ResolveError> {
404        let package_path = root.join(&pkg_config.path);
405        let cmake_path = package_path.join("CMakeLists.txt");
406
407        if !cmake_path.exists() {
408            return Err(ResolveError::FileOrDirNotFound {
409                path: cmake_path.clone(),
410            });
411        }
412
413        // Read file once and extract both name and version
414        let content = std::fs::read_to_string(&cmake_path)?;
415        let name = self.extract_name_from_content(&content, &cmake_path)?;
416        let version = self.extract_version_from_content(&content, &cmake_path)?;
417
418        Ok(ParsedPackage {
419            name,
420            version: semver::Version::parse(&version)?,
421            version_source: VersionSource::PackageManifest,
422            path: pkg_config.path.clone(),
423            private: false,
424        })
425    }
426
427    fn discover_packages(&self, root: &Path) -> Result<Vec<ParsedPackage>, ResolveError> {
428        let cmake_path = root.join("CMakeLists.txt");
429        if !cmake_path.exists() {
430            log::warn!(
431                "Cannot resolve package in {}, CMakeLists.txt not found.",
432                root.display()
433            );
434            return Ok(vec![]);
435        }
436
437        let root_package = self.parse_package(root, &Self::package_config("."))?;
438
439        let mut packages = vec![root_package];
440        for member in self.workspace_members(root)? {
441            packages.push(self.parse_package(root, &Self::package_config(member))?);
442        }
443
444        Ok(packages)
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use std::{
451        fs,
452        path::{Path, PathBuf},
453        time::{SystemTime, UNIX_EPOCH},
454    };
455
456    use crate::{
457        adapter::{AdapterError, EcosystemAdapter, EcosystemPlanInput, PackageLocation},
458        config::{PackageConfig, ReleaseChannel},
459        error::ResolveError,
460        resolver::ResolverType,
461    };
462    use semifold_core::{EcosystemId, PackageId, PackageSnapshot, VersionMap, VersionSource};
463
464    use super::CppResolver;
465
466    fn temp_dir(test_name: &str) -> PathBuf {
467        let nonce = SystemTime::now()
468            .duration_since(UNIX_EPOCH)
469            .unwrap()
470            .as_nanos();
471        let path = std::env::temp_dir().join(format!(
472            "semifold-cpp-resolver-{test_name}-{}-{nonce}",
473            std::process::id()
474        ));
475        fs::create_dir_all(&path).unwrap();
476        path
477    }
478
479    fn package_config(path: impl Into<PathBuf>) -> PackageConfig {
480        PackageConfig {
481            path: path.into(),
482            resolver: ResolverType::Cpp.into(),
483            publish: None,
484            channel: ReleaseChannel::Stable,
485            channel_bump: None,
486            assets: vec![],
487            github_release: None,
488            depends_on: vec![],
489        }
490    }
491
492    fn write_cmake_project(root: &Path, path: &str, name: &str, version: &str) {
493        let package_root = root.join(path);
494        fs::create_dir_all(&package_root).unwrap();
495        fs::write(
496            package_root.join("CMakeLists.txt"),
497            format!(
498                "cmake_minimum_required(VERSION 3.20)\nproject({name} VERSION {version} LANGUAGES CXX)\n"
499            ),
500        )
501        .unwrap();
502    }
503
504    #[test]
505    fn resolves_a_single_cmake_project() {
506        let root = temp_dir("single-package");
507        write_cmake_project(&root, ".", "demo_library", "1.2.3-alpha.1+build.7");
508
509        let package = CppResolver
510            .parse_package(&root, &package_config("."))
511            .unwrap();
512
513        assert_eq!(package.name, "demo_library");
514        assert_eq!(
515            package.version,
516            semver::Version::parse("1.2.3-alpha.1+build.7").unwrap()
517        );
518        assert_eq!(package.path, PathBuf::from("."));
519        assert!(!package.private);
520        fs::remove_dir_all(root).unwrap();
521    }
522
523    #[test]
524    fn discovers_the_root_cmake_project_only() {
525        let root = temp_dir("root-discovery");
526        write_cmake_project(&root, ".", "root-project", "1.0.0");
527        write_cmake_project(&root, "libraries/child", "child-project", "2.0.0");
528
529        let packages = CppResolver.discover_packages(&root).unwrap();
530
531        assert_eq!(packages.len(), 1);
532        assert_eq!(packages[0].name, "root-project");
533        assert_eq!(packages[0].path, PathBuf::from("."));
534        fs::remove_dir_all(root).unwrap();
535    }
536
537    #[test]
538    fn adapter_recursively_discovers_and_inspects_literal_subdirectories() {
539        let root = temp_dir("adapter-workspace");
540        write_cmake_project(&root, ".", "root-project", "1.0.0");
541        fs::write(
542            root.join("CMakeLists.txt"),
543            "project(root-project VERSION 1.0.0)\nadd_subdirectory(groups)\n",
544        )
545        .unwrap();
546        fs::create_dir_all(root.join("groups")).unwrap();
547        fs::write(
548            root.join("groups/CMakeLists.txt"),
549            "add_subdirectory(../libraries/core)\nadd_subdirectory(../applications/app)\n",
550        )
551        .unwrap();
552        write_cmake_project(&root, "libraries/core", "core", "1.0.0");
553        write_cmake_project(&root, "applications/app", "app", "1.0.0");
554        fs::write(
555            root.join("applications/app/CMakeLists.txt"),
556            "project(app VERSION 1.0.0)\ntarget_link_libraries(app PRIVATE core external)\n",
557        )
558        .unwrap();
559        let project_root = camino::Utf8PathBuf::from_path_buf(root.clone()).unwrap();
560
561        let discovered = CppResolver.discover(&project_root).unwrap();
562        assert_eq!(
563            discovered
564                .iter()
565                .map(|package| package.id.as_str())
566                .collect::<Vec<_>>(),
567            ["app", "core", "root-project"]
568        );
569        let app = CppResolver
570            .inspect(&PackageLocation {
571                id: PackageId::new("configured-app"),
572                project_root,
573                path: "applications/app".into(),
574            })
575            .unwrap();
576
577        assert_eq!(app.id, PackageId::new("configured-app"));
578        assert_eq!(app.manifest_name, "app");
579        assert_eq!(
580            app.dependencies
581                .iter()
582                .map(|dependency| dependency.manifest_name.as_str())
583                .collect::<Vec<_>>(),
584            ["core", "external"]
585        );
586        fs::remove_dir_all(root).unwrap();
587    }
588
589    #[test]
590    fn adapter_rejects_a_literal_subdirectory_outside_the_project_root() {
591        let root = temp_dir("adapter-escape");
592        let external = temp_dir("adapter-escape-external");
593        let external_name = external.file_name().unwrap().to_string_lossy();
594        write_cmake_project(&external, ".", "external", "1.0.0");
595        fs::write(
596            root.join("CMakeLists.txt"),
597            format!("project(root VERSION 1.0.0)\nadd_subdirectory(../{external_name})\n"),
598        )
599        .unwrap();
600        let project_root = camino::Utf8PathBuf::from_path_buf(root.clone()).unwrap();
601
602        assert!(matches!(
603            CppResolver.discover(&project_root),
604            Err(AdapterError::Manifest(ResolveError::InvalidConfig { .. }))
605        ));
606        fs::remove_dir_all(root).unwrap();
607        fs::remove_dir_all(external).unwrap();
608    }
609
610    #[test]
611    fn plans_cmake_and_optional_vcpkg_edits_without_writing() {
612        let root = temp_dir("plan-file-edits");
613        write_cmake_project(&root, "library", "demo-library", "1.0.0");
614        fs::write(
615            root.join("library/vcpkg.json"),
616            "{\"version\": \"1.0.0\"}\n",
617        )
618        .unwrap();
619        let package = PackageSnapshot {
620            id: PackageId::new("demo-library"),
621            manifest_name: "demo-library".to_string(),
622            version: semver::Version::new(1, 0, 0),
623            version_source: VersionSource::PackageManifest,
624            ecosystem: EcosystemId::CPP,
625            path: "library".into(),
626            publishable: true,
627            dependencies: vec![],
628        };
629
630        let versions = VersionMap::from([(
631            PackageId::new("demo-library"),
632            semver::Version::new(1, 0, 1),
633        )]);
634        let edits = CppResolver
635            .plan_edits(EcosystemPlanInput {
636                project_root: camino::Utf8Path::from_path(&root).unwrap(),
637                workspace_packages: std::slice::from_ref(&package),
638                released_packages: std::slice::from_ref(&package.id),
639                versions: &versions,
640            })
641            .unwrap();
642
643        assert_eq!(edits.len(), 2);
644        assert!(
645            edits
646                .iter()
647                .any(|edit| edit.path == "library/CMakeLists.txt"
648                    && edit.new_content.contains("VERSION 1.0.1"))
649        );
650        assert!(edits.iter().any(|edit| edit.path == "library/vcpkg.json" && edit.new_content.contains("1.0.1")));
651        assert!(
652            fs::read_to_string(root.join("library/CMakeLists.txt"))
653                .unwrap()
654                .contains("VERSION 1.0.0")
655        );
656        fs::remove_dir_all(root).unwrap();
657    }
658
659    #[test]
660    fn rejects_named_channels_for_cmake_versions() {
661        let version = semver::Version::parse("1.2.3-alpha.0").unwrap();
662
663        assert!(matches!(
664            CppResolver.encode_version(&version),
665            Err(AdapterError::InvalidVersion {
666                ecosystem,
667                ..
668            }) if ecosystem == EcosystemId::CPP
669        ));
670    }
671}