1use crate::errors::{Result, SampoError, WorkspaceError};
3use crate::types::{PackageInfo, PackageKind, Workspace};
4use cargo_metadata::{DependencyKind, MetadataCommand};
5use rustc_hash::FxHashSet;
6use semver::{Version, VersionReq};
7use std::collections::{BTreeMap, BTreeSet, HashMap};
8use std::fs;
9use std::path::{Component, Path, PathBuf};
10use std::process::Command;
11use std::time::Duration;
12use toml_edit::{DocumentMut, InlineTable, Item, Table, Value};
13
14pub(super) struct CargoAdapter;
16
17impl CargoAdapter {
18 pub(super) fn can_discover(&self, root: &Path) -> bool {
19 root.join("Cargo.toml").exists()
20 }
21
22 pub(super) fn discover(
23 &self,
24 root: &Path,
25 ) -> std::result::Result<Vec<PackageInfo>, WorkspaceError> {
26 discover_cargo(root)
27 }
28
29 pub(super) fn manifest_path(&self, package_dir: &Path) -> PathBuf {
30 package_dir.join("Cargo.toml")
31 }
32
33 pub(super) fn is_publishable(&self, manifest_path: &Path) -> Result<bool> {
34 is_publishable_to_crates_io(manifest_path)
35 }
36
37 pub(super) fn version_exists(&self, package_name: &str, version: &str) -> Result<bool> {
38 version_exists_on_crates_io(package_name, version)
39 }
40
41 pub(super) fn publish(
42 &self,
43 manifest_path: &Path,
44 dry_run: bool,
45 extra_args: &[String],
46 ) -> Result<()> {
47 let mut cmd = Command::new("cargo");
48 cmd.arg("publish").arg("--manifest-path").arg(manifest_path);
49
50 if dry_run {
51 cmd.arg("--dry-run");
52 }
53
54 if !extra_args.is_empty() {
55 cmd.args(extra_args);
56 }
57
58 println!(
59 "Running: {}",
60 format_command_display(cmd.get_program(), cmd.get_args())
61 );
62
63 let status = cmd.status()?;
64 if !status.success() {
65 return Err(SampoError::Publish(format!(
66 "cargo publish failed for {} with status {}",
67 manifest_path.display(),
68 status
69 )));
70 }
71
72 Ok(())
73 }
74
75 pub(super) fn regenerate_lockfile(&self, workspace_root: &Path) -> Result<()> {
76 regenerate_cargo_lockfile(workspace_root)
77 }
78}
79
80fn is_publishable_to_crates_io(manifest_path: &Path) -> Result<bool> {
82 let text = fs::read_to_string(manifest_path)
83 .map_err(|e| SampoError::Io(crate::errors::io_error_with_path(e, manifest_path)))?;
84 let value: toml::Value = text.parse().map_err(|e| {
85 SampoError::InvalidData(format!("invalid TOML in {}: {e}", manifest_path.display()))
86 })?;
87
88 let pkg = match value.get("package").and_then(|v| v.as_table()) {
89 Some(p) => p,
90 None => return Ok(false),
91 };
92
93 if let Some(val) = pkg.get("publish") {
95 match val {
96 toml::Value::Boolean(false) => return Ok(false),
97 toml::Value::Array(arr) => {
98 let allowed: Vec<String> = arr
101 .iter()
102 .filter_map(|v| v.as_str().map(|s| s.to_string()))
103 .collect();
104 return Ok(allowed.iter().any(|s| s == "crates-io"));
105 }
106 _ => {}
107 }
108 }
109
110 Ok(true)
112}
113
114fn version_exists_on_crates_io(crate_name: &str, version: &str) -> Result<bool> {
116 let url = format!("https://crates.io/api/v1/crates/{}/{}", crate_name, version);
118
119 let client = reqwest::blocking::Client::builder()
120 .timeout(Duration::from_secs(10))
121 .user_agent(format!("sampo-core/{}", env!("CARGO_PKG_VERSION")))
122 .build()
123 .map_err(|e| SampoError::Publish(format!("failed to build HTTP client: {}", e)))?;
124
125 let res = client
126 .get(&url)
127 .send()
128 .map_err(|e| SampoError::Publish(format!("HTTP request failed: {}", e)))?;
129
130 let status = res.status();
131 if status == reqwest::StatusCode::OK {
132 Ok(true)
133 } else if status == reqwest::StatusCode::NOT_FOUND {
134 Ok(false)
135 } else {
136 let body = res.text().unwrap_or_default();
138 let snippet: String = body.trim().chars().take(500).collect();
139 let snippet = snippet.split_whitespace().collect::<Vec<_>>().join(" ");
140
141 let body_part = if snippet.is_empty() {
142 String::new()
143 } else {
144 format!(" body=\"{}\"", snippet)
145 };
146
147 Err(SampoError::Publish(format!(
148 "Crates.io {} response:{}",
149 status, body_part
150 )))
151 }
152}
153
154fn regenerate_cargo_lockfile(root: &Path) -> Result<()> {
156 let mut cmd = Command::new("cargo");
157 cmd.arg("generate-lockfile").current_dir(root);
158
159 println!("Regenerating Cargo.lockā¦");
160 let status = cmd.status().map_err(SampoError::Io)?;
161 if !status.success() {
162 return Err(SampoError::Release(format!(
163 "cargo generate-lockfile failed with status {}",
164 status
165 )));
166 }
167 println!("Cargo.lock updated.");
168 Ok(())
169}
170
171fn format_command_display(program: &std::ffi::OsStr, args: std::process::CommandArgs) -> String {
172 let prog = program.to_string_lossy();
173 let mut s = String::new();
174 s.push_str(&prog);
175 for a in args {
176 s.push(' ');
177 s.push_str(&a.to_string_lossy());
178 }
179 s
180}
181
182pub struct ManifestMetadata {
184 packages: Vec<MetadataPackage>,
185 by_manifest: HashMap<PathBuf, usize>,
186 by_name: HashMap<String, usize>,
187}
188
189struct MetadataPackage {
190 dependencies: Vec<MetadataDependency>,
191}
192
193struct MetadataDependency {
194 manifest_key: String,
195 package_name: String,
196 kind: DependencyKind,
197 target: Option<String>,
198}
199
200impl ManifestMetadata {
201 pub fn load(workspace: &Workspace) -> Result<Self> {
202 let manifest_path = workspace.root.join("Cargo.toml");
203 let metadata = MetadataCommand::new()
204 .manifest_path(&manifest_path)
205 .no_deps()
206 .exec()
207 .map_err(|err| {
208 SampoError::Release(format!(
209 "Failed to load cargo metadata for {}: {err}",
210 manifest_path.display()
211 ))
212 })?;
213
214 let workspace_ids: FxHashSet<_> = metadata.workspace_members.iter().cloned().collect();
215
216 let mut packages = Vec::new();
217 let mut by_manifest = HashMap::new();
218 let mut by_name = HashMap::new();
219
220 for package in metadata.packages {
221 if !workspace_ids.contains(&package.id) {
222 continue;
223 }
224
225 let manifest_path: PathBuf = package.manifest_path.clone().into();
226 let dependencies = package
227 .dependencies
228 .iter()
229 .map(|dep| MetadataDependency {
230 manifest_key: dep.rename.clone().unwrap_or_else(|| dep.name.clone()),
231 package_name: dep.name.clone(),
232 kind: dep.kind,
233 target: dep.target.as_ref().map(|platform| platform.to_string()),
234 })
235 .collect();
236
237 let idx = packages.len();
238 by_manifest.insert(manifest_path.clone(), idx);
239 by_name.insert(package.name.clone(), idx);
240 packages.push(MetadataPackage { dependencies });
241 }
242
243 Ok(Self {
244 packages,
245 by_manifest,
246 by_name,
247 })
248 }
249
250 fn package_for_manifest(&self, manifest_path: &Path) -> Option<&MetadataPackage> {
251 self.by_manifest
252 .get(manifest_path)
253 .and_then(|idx| self.packages.get(*idx))
254 }
255
256 fn is_workspace_package(&self, name: &str) -> bool {
257 self.by_name.contains_key(name)
258 }
259}
260
261pub fn update_manifest_versions(
264 manifest_path: &Path,
265 input: &str,
266 new_pkg_version: Option<&str>,
267 new_version_by_name: &BTreeMap<String, String>,
268 metadata: Option<&ManifestMetadata>,
269) -> Result<(String, Vec<(String, String)>)> {
270 let mut doc: DocumentMut = input.parse().map_err(|err| {
271 SampoError::Release(format!(
272 "Failed to parse manifest {}: {err}",
273 manifest_path.display()
274 ))
275 })?;
276
277 if let Some(version) = new_pkg_version {
278 update_package_version(&mut doc, manifest_path, version)?;
279 }
280
281 let mut applied = Vec::new();
282 let package_info = metadata.and_then(|data| data.package_for_manifest(manifest_path));
283
284 for (dep_name, new_version) in new_version_by_name {
285 if let Some(meta) = metadata
286 && !meta.is_workspace_package(dep_name)
287 {
288 continue;
289 }
290
291 let mut changed = false;
292
293 if let Some(package) = package_info {
294 changed |= update_dependencies_from_metadata(&mut doc, package, dep_name, new_version);
295 }
296
297 let workspace_changed = update_workspace_dependency(&mut doc, dep_name, new_version);
298 changed |= workspace_changed;
299
300 if !changed {
301 changed |= update_dependencies_fallback(&mut doc, dep_name, new_version);
302 }
303
304 if changed {
305 applied.push((dep_name.clone(), new_version.clone()));
306 }
307 }
308
309 Ok((doc.to_string(), applied))
310}
311
312fn update_package_version(
313 doc: &mut DocumentMut,
314 manifest_path: &Path,
315 new_version: &str,
316) -> Result<()> {
317 let package_table = doc
318 .as_table_mut()
319 .get_mut("package")
320 .and_then(Item::as_table_mut)
321 .ok_or_else(|| {
322 SampoError::Release(format!(
323 "Manifest {} is missing a [package] section",
324 manifest_path.display()
325 ))
326 })?;
327
328 let current = package_table
329 .get("version")
330 .and_then(Item::as_value)
331 .and_then(Value::as_str);
332
333 if current == Some(new_version) {
334 return Ok(());
335 }
336
337 package_table.insert("version", Item::Value(Value::from(new_version)));
338 Ok(())
339}
340
341fn update_dependencies_from_metadata(
342 doc: &mut DocumentMut,
343 package: &MetadataPackage,
344 dep_name: &str,
345 new_version: &str,
346) -> bool {
347 let mut changed = false;
348
349 for dependency in &package.dependencies {
350 if dependency.package_name != dep_name {
351 continue;
352 }
353
354 if let Some(table) =
355 dependency_table_mut(doc, dependency.target.as_deref(), dependency.kind)
356 && let Some(item) = table.get_mut(&dependency.manifest_key)
357 {
358 changed |= update_standard_dependency_item(item, new_version);
359 }
360 }
361
362 changed
363}
364
365fn dependency_table_mut<'a>(
366 doc: &'a mut DocumentMut,
367 target: Option<&str>,
368 kind: DependencyKind,
369) -> Option<&'a mut Table> {
370 let section = dependency_section_name(kind);
371
372 match target {
373 None => doc.get_mut(section).and_then(Item::as_table_mut),
374 Some(target_spec) => doc
375 .get_mut("target")
376 .and_then(Item::as_table_mut)?
377 .get_mut(target_spec)
378 .and_then(Item::as_table_mut)?
379 .get_mut(section)
380 .and_then(Item::as_table_mut),
381 }
382}
383
384fn dependency_section_name(kind: DependencyKind) -> &'static str {
385 match kind {
386 DependencyKind::Normal | DependencyKind::Unknown => "dependencies",
387 DependencyKind::Development => "dev-dependencies",
388 DependencyKind::Build => "build-dependencies",
389 }
390}
391
392fn update_standard_dependency_item(item: &mut Item, new_version: &str) -> bool {
393 match item {
394 Item::Value(Value::InlineTable(table)) => update_inline_dependency(table, new_version),
395 Item::Table(table) => update_table_dependency(table, new_version),
396 Item::Value(value) => {
397 if value.as_str() == Some(new_version) {
398 false
399 } else {
400 *item = Item::Value(Value::from(new_version));
401 true
402 }
403 }
404 _ => false,
405 }
406}
407
408fn update_inline_dependency(table: &mut InlineTable, new_version: &str) -> bool {
409 if table
410 .get("workspace")
411 .and_then(Value::as_bool)
412 .unwrap_or(false)
413 {
414 return false;
415 }
416
417 let needs_update = table
418 .get("version")
419 .and_then(Value::as_str)
420 .map(|current| current != new_version)
421 .unwrap_or(true);
422
423 if needs_update {
424 table.insert("version", Value::from(new_version));
425 }
426
427 needs_update
428}
429
430fn update_table_dependency(table: &mut Table, new_version: &str) -> bool {
431 if table
432 .get("workspace")
433 .and_then(Item::as_value)
434 .and_then(Value::as_bool)
435 .unwrap_or(false)
436 {
437 return false;
438 }
439
440 let needs_update = table
441 .get("version")
442 .and_then(Item::as_value)
443 .and_then(Value::as_str)
444 .map(|current| current != new_version)
445 .unwrap_or(true);
446
447 if needs_update {
448 table.insert("version", Item::Value(Value::from(new_version)));
449 }
450
451 needs_update
452}
453
454fn update_dependencies_fallback(doc: &mut DocumentMut, dep_name: &str, new_version: &str) -> bool {
455 let mut changed = false;
456 let top_level = doc.as_table_mut();
457
458 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
459 if let Some(table) = top_level.get_mut(section).and_then(Item::as_table_mut)
460 && let Some(item) = table.get_mut(dep_name)
461 {
462 changed |= update_standard_dependency_item(item, new_version);
463 }
464 }
465
466 if let Some(targets) = top_level.get_mut("target").and_then(Item::as_table_mut) {
467 for (_, target_item) in targets.iter_mut() {
468 if let Some(target_table) = target_item.as_table_mut() {
469 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
470 if let Some(table) = target_table.get_mut(section).and_then(Item::as_table_mut)
471 && let Some(item) = table.get_mut(dep_name)
472 {
473 changed |= update_standard_dependency_item(item, new_version);
474 }
475 }
476 }
477 }
478 }
479
480 changed
481}
482
483fn update_workspace_dependency(doc: &mut DocumentMut, dep_name: &str, new_version: &str) -> bool {
484 let workspace_table = match doc
485 .as_table_mut()
486 .get_mut("workspace")
487 .and_then(Item::as_table_mut)
488 {
489 Some(table) => table,
490 None => return false,
491 };
492
493 let deps_item = match workspace_table.get_mut("dependencies") {
494 Some(item) => item,
495 None => return false,
496 };
497
498 match deps_item {
499 Item::Table(table) => {
500 if let Some(item) = table.get_mut(dep_name) {
501 update_workspace_dependency_item(item, new_version)
502 } else {
503 false
504 }
505 }
506 _ => false,
507 }
508}
509
510fn update_workspace_dependency_item(item: &mut Item, new_version: &str) -> bool {
511 match item {
512 Item::Value(Value::InlineTable(table)) => {
513 let current = table.get("version").and_then(Value::as_str);
514 let Some(existing) = current else {
515 return false;
516 };
517
518 match compute_workspace_dependency_version(existing, new_version) {
519 Some(resolved) if resolved != existing => {
520 table.insert("version", Value::from(resolved));
521 true
522 }
523 _ => false,
524 }
525 }
526 Item::Table(table) => {
527 let current = table
528 .get("version")
529 .and_then(Item::as_value)
530 .and_then(Value::as_str);
531 let Some(existing) = current else {
532 return false;
533 };
534
535 match compute_workspace_dependency_version(existing, new_version) {
536 Some(resolved) if resolved != existing => {
537 table.insert("version", Item::Value(Value::from(resolved)));
538 true
539 }
540 _ => false,
541 }
542 }
543 Item::Value(value) => {
544 let Some(existing) = value.as_str() else {
545 return false;
546 };
547
548 match compute_workspace_dependency_version(existing, new_version) {
549 Some(resolved) if resolved != existing => {
550 *item = Item::Value(Value::from(resolved));
551 true
552 }
553 _ => false,
554 }
555 }
556 _ => false,
557 }
558}
559
560fn compute_workspace_dependency_version(existing: &str, new_version: &str) -> Option<String> {
561 let trimmed_existing = existing.trim();
562 if trimmed_existing == "*" {
563 return None;
564 }
565
566 if Version::parse(trimmed_existing).is_ok() {
567 if trimmed_existing == new_version {
568 return None;
569 }
570 return Some(new_version.to_string());
571 }
572
573 let shorthand = parse_numeric_shorthand(trimmed_existing)?;
574 VersionReq::parse(trimmed_existing).ok()?;
575 let parsed_new = Version::parse(new_version).ok()?;
576
577 let resolved = match shorthand.len() {
578 1 => parsed_new.major.to_string(),
579 2 => format!("{}.{}", parsed_new.major, parsed_new.minor),
580 _ => return None,
581 };
582
583 if resolved == trimmed_existing {
584 None
585 } else {
586 Some(resolved)
587 }
588}
589
590fn parse_numeric_shorthand(value: &str) -> Option<Vec<u64>> {
591 let segments: Vec<&str> = value.split('.').collect();
592 if segments.is_empty() || segments.len() > 2 {
593 return None;
594 }
595
596 let mut numeric_segments = Vec::with_capacity(segments.len());
597 for segment in segments {
598 if segment.is_empty() || !segment.chars().all(|ch| ch.is_ascii_digit()) {
599 return None;
600 }
601 let parsed = segment.parse::<u64>().ok()?;
602 numeric_segments.push(parsed);
603 }
604
605 Some(numeric_segments)
606}
607
608fn clean_path(path: &Path) -> PathBuf {
610 let mut result = PathBuf::new();
611 for component in path.components() {
612 match component {
613 Component::CurDir => {}
614 Component::ParentDir => {
615 if !matches!(
617 result.components().next_back(),
618 Some(Component::RootDir | Component::Prefix(_))
619 ) {
620 result.pop();
621 }
622 }
623 Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
624 result.push(component);
625 }
626 }
627 }
628 result
629}
630
631fn find_cargo_workspace_root(
633 start_dir: &Path,
634) -> std::result::Result<(PathBuf, toml::Value), WorkspaceError> {
635 let mut current = start_dir;
636 loop {
637 let toml_path = current.join("Cargo.toml");
638 if toml_path.exists() {
639 let text = fs::read_to_string(&toml_path).map_err(|e| {
640 WorkspaceError::Io(crate::errors::io_error_with_path(e, &toml_path))
641 })?;
642 let value: toml::Value = text.parse().map_err(|e| {
643 WorkspaceError::InvalidManifest(format!("{}: {}", toml_path.display(), e))
644 })?;
645 if value.get("workspace").is_some() {
646 return Ok((current.to_path_buf(), value));
647 }
648 }
649 current = current.parent().ok_or(WorkspaceError::NotFound)?;
650 }
651}
652
653fn parse_cargo_workspace_members(
655 root: &Path,
656 root_toml: &toml::Value,
657) -> std::result::Result<Vec<PathBuf>, WorkspaceError> {
658 let workspace = root_toml
659 .get("workspace")
660 .and_then(|v| v.as_table())
661 .ok_or(WorkspaceError::NotFound)?;
662
663 let members = workspace
664 .get("members")
665 .and_then(|v| v.as_array())
666 .ok_or_else(|| {
667 WorkspaceError::InvalidWorkspace("missing 'members' in [workspace]".into())
668 })?;
669
670 let mut paths = Vec::new();
671 for mem in members {
672 let pattern = mem.as_str().ok_or_else(|| {
673 WorkspaceError::InvalidWorkspace("non-string member in workspace.members".into())
674 })?;
675 expand_cargo_member_pattern(root, pattern, &mut paths)?;
676 }
677
678 Ok(paths)
679}
680
681fn expand_cargo_member_pattern(
683 root: &Path,
684 pattern: &str,
685 paths: &mut Vec<PathBuf>,
686) -> std::result::Result<(), WorkspaceError> {
687 if pattern.contains('*') {
688 let full_pattern = root.join(pattern);
690 let pattern_str = full_pattern.to_string_lossy();
691 let entries = glob::glob(&pattern_str).map_err(|e| {
692 WorkspaceError::InvalidWorkspace(format!("invalid glob pattern '{}': {}", pattern, e))
693 })?;
694 for entry in entries {
695 let path = entry
696 .map_err(|e| WorkspaceError::InvalidWorkspace(format!("glob error: {}", e)))?;
697 if path.join("Cargo.toml").exists() {
699 paths.push(path);
700 }
701 }
702 } else {
703 let member_path = clean_path(&root.join(pattern));
705 if member_path.join("Cargo.toml").exists() {
706 paths.push(member_path);
707 } else {
708 return Err(WorkspaceError::InvalidWorkspace(format!(
709 "member '{}' does not contain Cargo.toml",
710 pattern
711 )));
712 }
713 }
714 Ok(())
715}
716
717fn collect_cargo_internal_deps(
719 crate_dir: &Path,
720 name_to_path: &BTreeMap<String, PathBuf>,
721 manifest: &toml::Value,
722) -> BTreeSet<String> {
723 let mut internal = BTreeSet::new();
724 for key in ["dependencies", "dev-dependencies", "build-dependencies"] {
725 if let Some(tbl) = manifest.get(key).and_then(|v| v.as_table()) {
726 for (dep_name, dep_val) in tbl {
727 if is_cargo_internal_dep(crate_dir, name_to_path, dep_name, dep_val) {
728 internal.insert(PackageInfo::dependency_identifier(
729 PackageKind::Cargo,
730 dep_name,
731 ));
732 }
733 }
734 }
735 }
736 internal
737}
738
739fn is_cargo_internal_dep(
741 crate_dir: &Path,
742 name_to_path: &BTreeMap<String, PathBuf>,
743 dep_name: &str,
744 dep_val: &toml::Value,
745) -> bool {
746 if let Some(tbl) = dep_val.as_table() {
747 if let Some(path_val) = tbl.get("path")
749 && let Some(path_str) = path_val.as_str()
750 {
751 let dep_path = clean_path(&crate_dir.join(path_str));
752 return name_to_path.values().any(|p| *p == dep_path);
753 }
754 if let Some(workspace_val) = tbl.get("workspace")
756 && workspace_val.as_bool() == Some(true)
757 {
758 return name_to_path.contains_key(dep_name);
760 }
761 }
762 false
763}
764
765fn discover_cargo(root: &Path) -> std::result::Result<Vec<PackageInfo>, WorkspaceError> {
766 let (workspace_root, root_toml) = find_cargo_workspace_root(root)?;
767 let members = parse_cargo_workspace_members(&workspace_root, &root_toml)?;
768 let mut crates = Vec::new();
769
770 let mut name_to_path: BTreeMap<String, PathBuf> = BTreeMap::new();
772 for member_dir in &members {
773 let manifest_path = member_dir.join("Cargo.toml");
774 let text = fs::read_to_string(&manifest_path).map_err(|e| {
775 WorkspaceError::Io(crate::errors::io_error_with_path(e, &manifest_path))
776 })?;
777 let value: toml::Value = text.parse().map_err(|e| {
778 WorkspaceError::InvalidManifest(format!("{}: {}", manifest_path.display(), e))
779 })?;
780 let pkg = value
781 .get("package")
782 .and_then(|v| v.as_table())
783 .ok_or_else(|| {
784 WorkspaceError::InvalidManifest(format!(
785 "missing [package] in {}",
786 manifest_path.display()
787 ))
788 })?;
789 let name = pkg
790 .get("name")
791 .and_then(|v| v.as_str())
792 .ok_or_else(|| {
793 WorkspaceError::InvalidManifest(format!(
794 "missing package.name in {}",
795 manifest_path.display()
796 ))
797 })?
798 .to_string();
799 let version = pkg
800 .get("version")
801 .and_then(|v| v.as_str())
802 .unwrap_or("")
803 .to_string();
804 name_to_path.insert(name.clone(), member_dir.clone());
805 crates.push((name, version, member_dir.clone(), value));
806 }
807
808 let mut out: Vec<PackageInfo> = Vec::new();
810 for (name, version, path, manifest) in crates {
811 let identifier = PackageInfo::dependency_identifier(PackageKind::Cargo, &name);
812 let internal_deps = collect_cargo_internal_deps(&path, &name_to_path, &manifest);
813 out.push(PackageInfo {
814 name,
815 identifier,
816 version,
817 path,
818 internal_deps,
819 kind: PackageKind::Cargo,
820 });
821 }
822
823 Ok(out)
824}
825
826#[cfg(test)]
827mod cargo_tests;