sampo_core/adapters/
cargo.rs

1/// Cargo ecosystem adapter for all Cargo operations.
2use 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
14/// Stateless adapter for all Cargo operations (discovery, publish, registry, lockfile).
15pub(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
80/// Check Cargo.toml `publish` field per Cargo rules (false, array of registries, or default true).
81fn 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 publish = false => skip
94    if let Some(val) = pkg.get("publish") {
95        match val {
96            toml::Value::Boolean(false) => return Ok(false),
97            toml::Value::Array(arr) => {
98                // Only publish if the array contains "crates-io"
99                // (Cargo uses this to whitelist registries.)
100                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    // Default case: publishable
111    Ok(true)
112}
113
114/// Query crates.io API to check if a specific version already exists.
115fn version_exists_on_crates_io(crate_name: &str, version: &str) -> Result<bool> {
116    // Query crates.io: https://crates.io/api/v1/crates/<name>/<version>
117    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        // Include a short, normalized snippet of the response body for diagnostics
137        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
154/// Run `cargo generate-lockfile` to rebuild the lockfile with updated versions.
155fn 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
182/// Metadata extracted from `cargo_metadata` for the workspace.
183pub 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
261/// Update a Cargo manifest by setting the package version (if provided) and retargeting internal
262/// dependency requirements to the latest planned versions.
263pub 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
608/// Clean a path by resolving .. and . components
609fn 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                // pop only normal components; keep root prefixes
616                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
631/// Find the workspace root starting from a directory
632fn 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
653/// Parse workspace members from the root Cargo.toml
654fn 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
681/// Expand a member pattern (plain path or glob) into concrete paths
682fn 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        // Glob pattern
689        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            // Only include if it has a Cargo.toml
698            if path.join("Cargo.toml").exists() {
699                paths.push(path);
700            }
701        }
702    } else {
703        // Plain path
704        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
717/// Collect internal dependencies for a crate
718fn 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
739/// Check if a dependency is internal to the workspace
740fn 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        // Check for `path = "..."` dependency
748        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        // Check for `workspace = true` dependency
755        if let Some(workspace_val) = tbl.get("workspace")
756            && workspace_val.as_bool() == Some(true)
757        {
758            // Only internal if dependency name is another workspace member
759            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    // First pass: parse per-crate metadata (name, version)
771    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    // Second pass: compute internal dependencies
809    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;