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
22pub 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: ®ex::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 fn extract_version_from_content(
248 &self,
249 content: &str,
250 cmake_path: &Path,
251 ) -> Result<String, ResolveError> {
252 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 fn extract_name_from_content(
275 &self,
276 content: &str,
277 cmake_path: &Path,
278 ) -> Result<String, ResolveError> {
279 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 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}