Skip to main content

gen_cargo/
lib.rs

1//! `gen-cargo` — Cargo adapter for the `gen` ecosystem.
2//!
3//! Reads a Cargo workspace (or single-crate repo) + its lockfile and
4//! emits a typed [`gen_types::Manifest`]. The cargo half of the
5//! universal package-manager engine — one of N adapters that share a
6//! single typed IR. See `theory/GEN.md` for the design.
7//!
8//! ```no_run
9//! use std::path::Path;
10//! let manifest = gen_cargo::parse(Path::new("/path/to/workspace")).unwrap();
11//! assert!(manifest.package_count() >= 1);
12//! ```
13
14pub mod adapter;
15pub mod build_spec;
16pub mod convert;
17pub mod diagnostics;
18pub mod ecosystem_impl;
19pub mod error;
20pub mod features;
21pub mod fleet_commit;
22pub mod fleet_migrate;
23pub mod fleet_verify;
24pub mod fleet_sweep;
25pub mod gen_build_job;
26pub mod gen_delta;
27pub mod git_prefetcher;
28pub mod invariants;
29pub mod lock_lifecycle;
30pub mod path_resolver;
31pub mod platform_features;
32pub mod quirks;
33pub mod raw;
34
35pub use adapter::{ctx_for, CargoAdapter};
36pub use error::{CargoError, Result};
37
38use std::path::{Path, PathBuf};
39
40use gen_types::Manifest;
41
42/// Parse the Cargo workspace (or single-crate repo) rooted at `root`
43/// into a typed [`Manifest`]. Reads `<root>/Cargo.toml`, every member
44/// crate's `Cargo.toml`, and (if present) `<root>/Cargo.lock`.
45///
46/// # Errors
47///
48/// Returns a typed [`CargoError`] for IO failures, TOML parse errors,
49/// missing workspace members, or malformed version requirements.
50pub fn parse(root: &Path) -> Result<Manifest> {
51    let root_manifest_path = root.join("Cargo.toml");
52    let root_raw = read_cargo_toml(&root_manifest_path)?;
53
54    let mut packages = Vec::new();
55
56    if let Some(_pkg) = &root_raw.package {
57        // Single-crate or workspace-root-also-a-package case.
58        let pkg = convert::convert_package(
59            &root_raw,
60            &root_manifest_path,
61            workspace_pkg(&root_raw),
62            workspace_deps(&root_raw),
63        )?;
64        packages.push(pkg);
65    }
66
67    if let Some(ws) = &root_raw.workspace {
68        for member in &ws.members {
69            // Glob support (`crates/*`) is intentionally deferred —
70            // the consumer can pre-expand globs; the engine never
71            // shells out. Members with `*` are skipped with a
72            // diagnostic carried through MissingWorkspaceMember.
73            if member.contains('*') {
74                if let Some(expanded) = expand_glob(root, member) {
75                    for mpath in expanded {
76                        packages.push(parse_member(root, &mpath, &root_raw)?);
77                    }
78                    continue;
79                }
80            }
81            let mpath = PathBuf::from(member);
82            packages.push(parse_member(root, &mpath, &root_raw)?);
83        }
84    }
85
86    let lockfile = match read_optional(&root.join("Cargo.lock"))? {
87        Some(text) => {
88            let parsed: raw::CargoLock =
89                toml::from_str(&text).map_err(|source| CargoError::Toml {
90                    path: root.join("Cargo.lock"),
91                    source,
92                })?;
93            Some(convert::convert_lockfile(&parsed, &root.join("Cargo.lock"))?)
94        }
95        None => None,
96    };
97
98    Ok(convert::build_manifest(root, &root_raw, packages, lockfile))
99}
100
101fn parse_member(
102    root: &Path,
103    member_rel: &Path,
104    workspace_root_raw: &raw::CargoToml,
105) -> Result<gen_types::Package> {
106    let path = root.join(member_rel).join("Cargo.toml");
107    let raw = read_cargo_toml(&path).map_err(|e| match e {
108        CargoError::Io { .. } => CargoError::MissingWorkspaceMember {
109            root: root.to_path_buf(),
110            member: member_rel.display().to_string(),
111        },
112        other => other,
113    })?;
114    convert::convert_package(
115        &raw,
116        &path,
117        workspace_pkg(workspace_root_raw),
118        workspace_deps(workspace_root_raw),
119    )
120}
121
122fn workspace_pkg(raw: &raw::CargoToml) -> Option<&raw::RawPackage> {
123    raw.workspace.as_ref().and_then(|w| w.package.as_ref())
124}
125
126fn workspace_deps(raw: &raw::CargoToml) -> &indexmap::IndexMap<String, raw::RawDep> {
127    static EMPTY: std::sync::OnceLock<indexmap::IndexMap<String, raw::RawDep>> =
128        std::sync::OnceLock::new();
129    raw.workspace
130        .as_ref()
131        .map(|w| &w.dependencies)
132        .unwrap_or_else(|| EMPTY.get_or_init(indexmap::IndexMap::new))
133}
134
135fn read_cargo_toml(path: &Path) -> Result<raw::CargoToml> {
136    let text = std::fs::read_to_string(path).map_err(|source| CargoError::Io {
137        path: path.to_path_buf(),
138        source,
139    })?;
140    toml::from_str(&text).map_err(|source| CargoError::Toml {
141        path: path.to_path_buf(),
142        source,
143    })
144}
145
146fn read_optional(path: &Path) -> Result<Option<String>> {
147    match std::fs::read_to_string(path) {
148        Ok(s) => Ok(Some(s)),
149        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
150        Err(source) => Err(CargoError::Io {
151            path: path.to_path_buf(),
152            source,
153        }),
154    }
155}
156
157/// Minimal glob expansion for `crates/*` / `crates/**` shapes — no
158/// regex, no external dep. Reads the parent dir and filters by the
159/// segment after the `*`. Returns `None` if the pattern shape is
160/// unsupported (caller treats it as a literal path).
161fn expand_glob(root: &Path, pattern: &str) -> Option<Vec<PathBuf>> {
162    let (prefix, rest) = pattern.split_once('*')?;
163    if !rest.is_empty() && rest != "/" {
164        return None; // unsupported tail
165    }
166    // Cargo glob shapes seen in the fleet:
167    //   "crates/*"        — dir-prefix glob (matches dirs under crates/)
168    //   "ratatui-*"       — file-prefix glob (matches dirs in root with this prefix)
169    // Distinguish by whether prefix ends with '/' (dir) or a name segment.
170    let (search_dir, name_prefix) =
171        if prefix.is_empty() || prefix.ends_with('/') {
172            (root.join(prefix.trim_end_matches('/')), String::new())
173        } else if let Some(slash) = prefix.rfind('/') {
174            (root.join(&prefix[..slash]), prefix[slash + 1..].to_string())
175        } else {
176            (root.to_path_buf(), prefix.to_string())
177        };
178    let mut out = Vec::new();
179    let entries = std::fs::read_dir(&search_dir).ok()?;
180    for e in entries.flatten() {
181        let path = e.path();
182        if !path.is_dir() || !path.join("Cargo.toml").exists() {
183            continue;
184        }
185        let fname = path.file_name()?.to_string_lossy().into_owned();
186        if !name_prefix.is_empty() && !fname.starts_with(&name_prefix) {
187            continue;
188        }
189        if let Ok(rel) = path.strip_prefix(root) {
190            out.push(rel.to_path_buf());
191        }
192    }
193    out.sort();
194    Some(out)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use std::fs;
201
202    fn write(path: &Path, body: &str) {
203        if let Some(p) = path.parent() {
204            fs::create_dir_all(p).unwrap();
205        }
206        fs::write(path, body).unwrap();
207    }
208
209    #[test]
210    fn parses_single_crate_repo() {
211        let dir = tempfile_dir();
212        write(
213            &dir.join("Cargo.toml"),
214            r#"
215[package]
216name = "demo"
217version = "0.2.1"
218edition = "2024"
219license = "MIT"
220description = "demo crate"
221
222[dependencies]
223serde = "1.0"
224serde_json = { version = "1", features = ["preserve_order"] }
225indexmap = { version = "2", default-features = false }
226"#,
227        );
228        let m = parse(&dir).unwrap();
229        assert_eq!(m.package_count(), 1);
230        let p = m.find_package("demo").unwrap();
231        assert_eq!(p.version, gen_types::Version::new(0, 2, 1));
232        assert_eq!(p.license.as_deref(), Some("MIT"));
233        assert_eq!(p.dependencies.len(), 3);
234        let s = p.dependencies.iter().find(|d| d.name == "serde").unwrap();
235        assert!(matches!(s.kind, gen_types::DependencyKind::Direct));
236        assert_eq!(s.constraint.native_syntax.as_deref(), Some("1.0"));
237        let sj = p.dependencies.iter().find(|d| d.name == "serde_json").unwrap();
238        assert_eq!(sj.features_enabled, vec!["preserve_order".to_string()]);
239        let im = p.dependencies.iter().find(|d| d.name == "indexmap").unwrap();
240        assert!(!im.default_features);
241    }
242
243    #[test]
244    fn parses_workspace_with_member_inheritance() {
245        let dir = tempfile_dir();
246        write(
247            &dir.join("Cargo.toml"),
248            r#"
249[workspace]
250members = ["crates/a", "crates/b"]
251
252[workspace.package]
253version = "0.3.0"
254license = "MIT"
255edition = "2024"
256
257[workspace.dependencies]
258serde = "1.0"
259shared = { version = "0.5", features = ["foo"] }
260"#,
261        );
262        write(
263            &dir.join("crates/a/Cargo.toml"),
264            r#"
265[package]
266name = "a"
267version.workspace = true
268license.workspace = true
269edition.workspace = true
270
271[dependencies]
272serde = { workspace = true }
273"#,
274        );
275        write(
276            &dir.join("crates/b/Cargo.toml"),
277            r#"
278[package]
279name = "b"
280version = "0.9.0"
281edition = "2024"
282license = "MIT"
283
284[dependencies]
285shared = { workspace = true, features = ["bar"] }
286"#,
287        );
288        let m = parse(&dir).unwrap();
289        assert_eq!(m.package_count(), 2);
290        let a = m.find_package("a").unwrap();
291        assert_eq!(a.version, gen_types::Version::new(0, 3, 0));
292        assert_eq!(a.license.as_deref(), Some("MIT"));
293        assert_eq!(a.dependencies[0].name, "serde");
294
295        let b = m.find_package("b").unwrap();
296        assert_eq!(b.version, gen_types::Version::new(0, 9, 0));
297        let shared = b.dependencies.iter().find(|d| d.name == "shared").unwrap();
298        // inherited foo + local bar
299        assert!(shared.features_enabled.contains(&"foo".to_string()));
300        assert!(shared.features_enabled.contains(&"bar".to_string()));
301    }
302
303    #[test]
304    fn parses_target_cfg_dependencies() {
305        let dir = tempfile_dir();
306        write(
307            &dir.join("Cargo.toml"),
308            r#"
309[package]
310name = "p"
311version = "0.1.0"
312edition = "2024"
313
314[dependencies]
315
316[target.'cfg(unix)'.dependencies]
317nix = "0.27"
318
319[target.'cfg(windows)'.dev-dependencies]
320winapi = "0.3"
321"#,
322        );
323        let m = parse(&dir).unwrap();
324        let p = m.find_package("p").unwrap();
325        let nix = p.dependencies.iter().find(|d| d.name == "nix").unwrap();
326        let pred = nix.target_predicate.as_ref().unwrap();
327        assert!(matches!(pred, gen_types::TargetPredicate::CargoCfg { expr } if expr.contains("unix")));
328        let winapi = p.dependencies.iter().find(|d| d.name == "winapi").unwrap();
329        assert!(matches!(winapi.kind, gen_types::DependencyKind::Dev));
330    }
331
332    #[test]
333    fn parses_git_dependency_with_rev() {
334        let dir = tempfile_dir();
335        write(
336            &dir.join("Cargo.toml"),
337            r#"
338[package]
339name = "p"
340version = "0.1.0"
341edition = "2024"
342
343[dependencies]
344foo = { git = "https://github.com/x/y", rev = "abc123" }
345bar = { git = "https://github.com/x/z", branch = "main" }
346"#,
347        );
348        let m = parse(&dir).unwrap();
349        let p = m.find_package("p").unwrap();
350        let foo = p.dependencies.iter().find(|d| d.name == "foo").unwrap();
351        let src = foo.source_override.as_ref().unwrap();
352        match src {
353            gen_types::PackageSource::Git { url, rev, .. } => {
354                assert_eq!(url, "https://github.com/x/y");
355                assert_eq!(rev, "abc123");
356            }
357            other => panic!("expected Git, got {other:?}"),
358        }
359        let bar = p.dependencies.iter().find(|d| d.name == "bar").unwrap();
360        match bar.source_override.as_ref().unwrap() {
361            gen_types::PackageSource::Git { rev, .. } => assert_eq!(rev, "main"),
362            other => panic!("expected Git, got {other:?}"),
363        }
364    }
365
366    #[test]
367    fn parses_optional_dep_as_optional_kind() {
368        let dir = tempfile_dir();
369        write(
370            &dir.join("Cargo.toml"),
371            r#"
372[package]
373name = "p"
374version = "0.1.0"
375edition = "2024"
376
377[dependencies]
378opt = { version = "1", optional = true }
379"#,
380        );
381        let m = parse(&dir).unwrap();
382        let p = m.find_package("p").unwrap();
383        let opt = p.dependencies.iter().find(|d| d.name == "opt").unwrap();
384        assert!(matches!(opt.kind, gen_types::DependencyKind::Optional));
385    }
386
387    #[test]
388    fn parses_features_block() {
389        let dir = tempfile_dir();
390        write(
391            &dir.join("Cargo.toml"),
392            r#"
393[package]
394name = "p"
395version = "0.1.0"
396edition = "2024"
397
398[dependencies]
399
400[features]
401default = ["std"]
402std = []
403extra = ["serde/derive", "dep:opt"]
404"#,
405        );
406        let m = parse(&dir).unwrap();
407        let p = m.find_package("p").unwrap();
408        assert_eq!(p.features.len(), 3);
409        let extra = p.features.iter().find(|f| f.name == "extra").unwrap();
410        assert!(
411            extra
412                .implies
413                .iter()
414                .any(|r| matches!(r, gen_types::FeatureRef::Namespaced { package, feature }
415                    if package == "serde" && feature == "derive"))
416        );
417        assert!(
418            extra
419                .implies
420                .iter()
421                .any(|r| matches!(r, gen_types::FeatureRef::DepActivation { dep_name }
422                    if dep_name == "opt"))
423        );
424    }
425
426    #[test]
427    fn parses_version_range_to_range_spec() {
428        let dir = tempfile_dir();
429        write(
430            &dir.join("Cargo.toml"),
431            r#"
432[package]
433name = "p"
434version = "0.1.0"
435edition = "2024"
436
437[dependencies]
438foo = ">=1.2.3, <2.0.0"
439"#,
440        );
441        let m = parse(&dir).unwrap();
442        let p = m.find_package("p").unwrap();
443        let foo = p.dependencies.iter().find(|d| d.name == "foo").unwrap();
444        match &foo.constraint.spec {
445            gen_types::ConstraintSpec::Range {
446                lower_inclusive,
447                upper_exclusive,
448            } => {
449                assert_eq!(*lower_inclusive, gen_types::Version::new(1, 2, 3));
450                assert_eq!(*upper_exclusive, gen_types::Version::new(2, 0, 0));
451            }
452            other => panic!("expected Range, got {other:?}"),
453        }
454    }
455
456    #[test]
457    fn parses_lockfile_when_present() {
458        let dir = tempfile_dir();
459        write(
460            &dir.join("Cargo.toml"),
461            r#"
462[package]
463name = "demo"
464version = "0.1.0"
465edition = "2024"
466"#,
467        );
468        write(
469            &dir.join("Cargo.lock"),
470            r#"
471version = 3
472
473[[package]]
474name = "demo"
475version = "0.1.0"
476
477[[package]]
478name = "serde"
479version = "1.0.228"
480source = "registry+https://github.com/rust-lang/crates.io-index"
481checksum = "0000000000000000000000000000000000000000000000000000000000000001"
482dependencies = ["serde_derive"]
483
484[[package]]
485name = "serde_derive"
486version = "1.0.228"
487source = "registry+https://github.com/rust-lang/crates.io-index"
488checksum = "0000000000000000000000000000000000000000000000000000000000000002"
489"#,
490        );
491        let m = parse(&dir).unwrap();
492        let lock = m.lockfile.as_ref().unwrap();
493        assert_eq!(lock.resolved.len(), 3);
494        let serde_entry = lock.resolved.get("serde/1.0.228").unwrap();
495        match &serde_entry.source {
496            gen_types::PackageSource::Registry {
497                registry,
498                integrity_hash,
499                ..
500            } => {
501                assert_eq!(*registry, gen_types::Registry::CratesIo);
502                assert!(integrity_hash.as_ref().unwrap().starts_with("sha256:"));
503            }
504            other => panic!("expected Registry source, got {other:?}"),
505        }
506        assert_eq!(serde_entry.resolved_dependencies.len(), 1);
507        assert_eq!(serde_entry.resolved_dependencies[0].name, "serde_derive");
508        assert_ne!(lock.content_addressed_hash, gen_types::ContentHash::genesis());
509    }
510
511    #[test]
512    fn glob_expands_workspace_members() {
513        let dir = tempfile_dir();
514        write(
515            &dir.join("Cargo.toml"),
516            r#"
517[workspace]
518members = ["crates/*"]
519"#,
520        );
521        write(
522            &dir.join("crates/alpha/Cargo.toml"),
523            r#"
524[package]
525name = "alpha"
526version = "0.1.0"
527edition = "2024"
528"#,
529        );
530        write(
531            &dir.join("crates/beta/Cargo.toml"),
532            r#"
533[package]
534name = "beta"
535version = "0.1.0"
536edition = "2024"
537"#,
538        );
539        let m = parse(&dir).unwrap();
540        assert_eq!(m.package_count(), 2);
541        assert!(m.find_package("alpha").is_some());
542        assert!(m.find_package("beta").is_some());
543    }
544
545    #[test]
546    fn missing_workspace_member_surfaces_typed_error() {
547        let dir = tempfile_dir();
548        write(
549            &dir.join("Cargo.toml"),
550            r#"
551[workspace]
552members = ["does/not/exist"]
553"#,
554        );
555        let e = parse(&dir).unwrap_err();
556        match e {
557            CargoError::MissingWorkspaceMember { member, .. } => {
558                assert_eq!(member, "does/not/exist");
559            }
560            other => panic!("expected MissingWorkspaceMember, got {other:?}"),
561        }
562    }
563
564    fn tempfile_dir() -> PathBuf {
565        use std::sync::atomic::{AtomicU64, Ordering};
566        static COUNTER: AtomicU64 = AtomicU64::new(0);
567        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
568        let base = std::env::temp_dir().join(format!(
569            "gen-cargo-test-{}-{}-{:?}",
570            std::process::id(),
571            n,
572            std::time::SystemTime::now()
573                .duration_since(std::time::UNIX_EPOCH)
574                .unwrap()
575                .as_nanos()
576        ));
577        let _ = fs::remove_dir_all(&base);
578        fs::create_dir_all(&base).unwrap();
579        base
580    }
581}