Skip to main content

gen_adapter_forge/
lib.rs

1//! `gen-adapter-forge` — typed declarative spec for gen adapters.
2//!
3//! Every existing adapter (gen-cargo / gen-npm / gen-bundler) has the
4//! same shape:
5//!
6//!   - one or more **manifest markers** (`Cargo.toml`, `package.json`, …)
7//!   - zero or more **lockfile markers** (`Cargo.lock`, `pnpm-lock.yaml`, …)
8//!   - a **registry** (CratesIo / Npm / RubyGems / …)
9//!   - a **constraint syntax family** (`semver-caret-default` /
10//!     `semver-exact-default` / `bundler-pessimistic` / …)
11//!   - **dependency table names** (`dependencies` / `devDependencies` / …)
12//!   - **target-predicate shape** (cargo cfg / npm engines+os / bundler
13//!     platforms / pep-508 markers / none)
14//!   - **workspace shape** (`members` / `workspaces` array / single-package)
15//!
16//! A typed [`AdapterSpec`] captures all of this. The forge component
17//! generates the boilerplate scaffold (lib.rs + error.rs + raw.rs +
18//! tests) from one spec; the adapter author only writes the format-
19//! specific parsing logic — typically <100 LOC.
20
21use indexmap::IndexMap;
22use serde::{Deserialize, Serialize};
23
24use gen_types::Registry;
25
26/// Typed declarative adapter spec. One value of this struct generates
27/// a complete adapter scaffold. Authoring-side equivalent of the
28/// `(defadapter …)` Lisp form (M5+).
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30pub struct AdapterSpec {
31    /// Adapter name — `cargo`, `npm`, `bundler`, `pip`, `gomod`, ….
32    pub name: String,
33    /// Crate name on crates.io — convention `gen-<name>`.
34    pub crate_name: String,
35    /// Marker files probed in declaration order; first hit wins.
36    pub manifest_markers: Vec<String>,
37    /// Lockfile markers; first present is used, otherwise none.
38    pub lockfile_markers: Vec<String>,
39    /// Upstream registry the adapter primarily talks to.
40    pub registry: Registry,
41    /// Constraint-syntax family. See [`ConstraintFamily`].
42    pub constraint_family: ConstraintFamily,
43    /// Per-kind dependency table name → DependencyKind mapping. e.g.
44    /// `dependencies → Direct`, `devDependencies → Dev`, …
45    pub dependency_tables: IndexMap<String, String>,
46    /// Target-predicate shape supported by this format.
47    pub target_predicate_shape: TargetPredicateShape,
48    /// Workspace shape — how multi-package projects are declared.
49    pub workspace_shape: WorkspaceShape,
50    /// Manifest format — drives the parser the forge generates.
51    pub manifest_format: ManifestFormat,
52    /// Lockfile format — drives the parser the forge generates.
53    pub lockfile_format: LockfileFormat,
54    /// Human-readable one-line description used in the generated
55    /// Cargo.toml `description` field.
56    pub description: String,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum ConstraintFamily {
62    /// Cargo: bare version means caret. `^1.2`, `~1.2`, `>=1.2,<2`.
63    SemverCaretDefault,
64    /// npm: bare version means exact-match. `^`, `~`, `>=` parsed.
65    SemverExactDefault,
66    /// Bundler: `~> 1.2` (pessimistic), bare means exact.
67    BundlerPessimistic,
68    /// pip: `==1.2`, `>=1.2,<2`, `>=1.2.*`. PEP 440.
69    Pep440,
70    /// Go modules: `v1.2.3` exact + minimum-version-selection.
71    GoMvs,
72    /// Composer: `^`, `~`, OR-disjunctions (`|`).
73    Composer,
74    /// Hex: `~> 1.2`, `>= 1.2`. Similar to Bundler.
75    HexPessimistic,
76    /// No semantic versioning — adapter handles arbitrary strings.
77    None,
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "kebab-case")]
82pub enum TargetPredicateShape {
83    /// `target.'cfg(unix)'.dependencies` — cargo.
84    CargoCfg,
85    /// `engines` + `os` + `cpu` — npm.
86    NpmEnginesOsCpu,
87    /// `platforms` block — Bundler.
88    BundlerPlatforms,
89    /// PEP-508 environment markers — pip.
90    Pep508,
91    /// `// +build linux,amd64` build tags — go.
92    GoBuildTags,
93    /// None — format has no concept of conditional deps.
94    None,
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "kebab-case")]
99pub enum WorkspaceShape {
100    /// `[workspace] members = [...]` in a top-level TOML.
101    CargoWorkspaceMembers,
102    /// `workspaces` array in package.json (or `packages` field).
103    NpmWorkspacesField,
104    /// Single-package only; no native workspace shape.
105    SinglePackageOnly,
106    /// Composer monorepo (path repositories).
107    ComposerPathRepositories,
108    /// pnpm workspace via `pnpm-workspace.yaml`.
109    PnpmWorkspaceYaml,
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "kebab-case")]
114pub enum ManifestFormat {
115    Toml,
116    Json,
117    Yaml,
118    /// Line-oriented (Gemfile, requirements.txt) — adapter writes a
119    /// custom parser.
120    LineOriented,
121    /// Mixed — adapter sniffs the file extension.
122    Sniffed,
123}
124
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "kebab-case")]
127pub enum LockfileFormat {
128    Toml,
129    Json,
130    Yaml,
131    /// Line-oriented (Gemfile.lock, requirements.txt freeze).
132    LineOriented,
133    /// `go.sum` simple two-column format.
134    GoSum,
135    /// No lockfile.
136    None,
137}
138
139impl LockfileFormat {
140    /// Sniff mode — adapter probes multiple formats. npm prefers
141    /// pnpm-lock.yaml over package-lock.json; expressed today as
142    /// individual specs choosing the lockfile_format their write-path
143    /// settles on.
144    pub const fn sniffed_default() -> Self {
145        Self::Json
146    }
147}
148
149// ── Built-in specs (every shipping adapter as typed data) ────────────
150
151/// The cargo adapter as a typed spec — what `gen-cargo` would have
152/// been authored as if [`AdapterSpec`] existed before it.
153#[must_use]
154pub fn cargo_spec() -> AdapterSpec {
155    let mut deps = IndexMap::new();
156    deps.insert("dependencies".into(), "direct".into());
157    deps.insert("dev-dependencies".into(), "dev".into());
158    deps.insert("build-dependencies".into(), "build".into());
159    AdapterSpec {
160        name: "cargo".into(),
161        crate_name: "gen-cargo".into(),
162        manifest_markers: vec!["Cargo.toml".into()],
163        lockfile_markers: vec!["Cargo.lock".into()],
164        registry: Registry::CratesIo,
165        constraint_family: ConstraintFamily::SemverCaretDefault,
166        dependency_tables: deps,
167        target_predicate_shape: TargetPredicateShape::CargoCfg,
168        workspace_shape: WorkspaceShape::CargoWorkspaceMembers,
169        manifest_format: ManifestFormat::Toml,
170        lockfile_format: LockfileFormat::Toml,
171        description: "gen — Cargo adapter".into(),
172    }
173}
174
175#[must_use]
176pub fn npm_spec() -> AdapterSpec {
177    let mut deps = IndexMap::new();
178    deps.insert("dependencies".into(), "direct".into());
179    deps.insert("devDependencies".into(), "dev".into());
180    deps.insert("peerDependencies".into(), "peer".into());
181    deps.insert("optionalDependencies".into(), "optional".into());
182    AdapterSpec {
183        name: "npm".into(),
184        crate_name: "gen-npm".into(),
185        manifest_markers: vec!["package.json".into()],
186        lockfile_markers: vec!["pnpm-lock.yaml".into(), "package-lock.json".into()],
187        registry: Registry::Npm,
188        constraint_family: ConstraintFamily::SemverExactDefault,
189        dependency_tables: deps,
190        target_predicate_shape: TargetPredicateShape::NpmEnginesOsCpu,
191        workspace_shape: WorkspaceShape::NpmWorkspacesField,
192        manifest_format: ManifestFormat::Json,
193        lockfile_format: LockfileFormat::sniffed_default(),
194        description: "gen — npm/pnpm/yarn adapter".into(),
195    }
196}
197
198#[must_use]
199pub fn bundler_spec() -> AdapterSpec {
200    let mut deps = IndexMap::new();
201    deps.insert("gem".into(), "direct".into());
202    AdapterSpec {
203        name: "bundler".into(),
204        crate_name: "gen-bundler".into(),
205        manifest_markers: vec!["Gemfile".into()],
206        lockfile_markers: vec!["Gemfile.lock".into()],
207        registry: Registry::RubyGems,
208        constraint_family: ConstraintFamily::BundlerPessimistic,
209        dependency_tables: deps,
210        target_predicate_shape: TargetPredicateShape::BundlerPlatforms,
211        workspace_shape: WorkspaceShape::SinglePackageOnly,
212        manifest_format: ManifestFormat::LineOriented,
213        lockfile_format: LockfileFormat::LineOriented,
214        description: "gen — Ruby/Bundler adapter".into(),
215    }
216}
217
218/// Forge output — the rendered scaffold ready to drop in a new
219/// `crates/gen-<name>/` directory.
220#[derive(Debug, Clone)]
221pub struct ScaffoldOutput {
222    pub files: IndexMap<String, String>,
223}
224
225/// Forge a fresh adapter scaffold from a typed spec. Produces a
226/// directory's worth of starter files:
227///   - `Cargo.toml` (with the right deps for the format)
228///   - `src/lib.rs` (parse(root) skeleton + dispatch + Manifest assembly)
229///   - `src/error.rs` (typed error enum matching the format's failure modes)
230///   - `src/raw.rs` (serde shapes for the marker files when JSON/YAML/TOML)
231///   - `tests/smoke.rs` (one integration test that asserts the adapter
232///     can ingest an empty manifest without panic)
233///
234/// The output is deliberately a *starter* — the adapter author then
235/// fleshes out the format-specific parsing. ~80% of the boilerplate
236/// is gone.
237#[must_use]
238pub fn forge(spec: &AdapterSpec) -> ScaffoldOutput {
239    let mut files = IndexMap::new();
240    files.insert("Cargo.toml".to_string(), render_cargo_toml(spec));
241    files.insert("src/lib.rs".to_string(), render_lib_rs(spec));
242    files.insert("src/error.rs".to_string(), render_error_rs(spec));
243    if matches!(
244        spec.manifest_format,
245        ManifestFormat::Json | ManifestFormat::Yaml | ManifestFormat::Toml
246    ) {
247        files.insert("src/raw.rs".to_string(), render_raw_rs(spec));
248    }
249    files.insert("tests/smoke.rs".to_string(), render_smoke_test(spec));
250    ScaffoldOutput { files }
251}
252
253fn render_cargo_toml(s: &AdapterSpec) -> String {
254    let format_dep = match s.manifest_format {
255        ManifestFormat::Toml => "toml = { workspace = true }\n",
256        ManifestFormat::Json => "",
257        ManifestFormat::Yaml => "serde_yaml = { workspace = true }\n",
258        ManifestFormat::LineOriented | ManifestFormat::Sniffed => "",
259    };
260    let lock_dep = match s.lockfile_format {
261        LockfileFormat::Yaml => "serde_yaml = { workspace = true }\n",
262        LockfileFormat::Toml | LockfileFormat::Json | LockfileFormat::LineOriented
263        | LockfileFormat::GoSum | LockfileFormat::None => "",
264    };
265    format!(
266        r#"[package]
267name = "{crate_name}"
268description = "{desc}"
269version.workspace = true
270edition.workspace = true
271rust-version.workspace = true
272license.workspace = true
273homepage.workspace = true
274repository.workspace = true
275authors.workspace = true
276
277[lib]
278name = "{lib_name}"
279path = "src/lib.rs"
280
281[lints]
282workspace = true
283
284[dependencies]
285gen-types = {{ workspace = true }}
286serde = {{ workspace = true }}
287serde_json = {{ workspace = true }}
288indexmap = {{ workspace = true }}
289thiserror = {{ workspace = true }}
290{format_dep}{lock_dep}"#,
291        crate_name = s.crate_name,
292        lib_name = s.crate_name.replace('-', "_"),
293        desc = s.description,
294    )
295}
296
297fn render_lib_rs(s: &AdapterSpec) -> String {
298    let registry_variant = match s.registry {
299        Registry::CratesIo => "CratesIo",
300        Registry::Npm => "Npm",
301        Registry::RubyGems => "RubyGems",
302        Registry::PyPi => "PyPi",
303        Registry::GoProxy => "GoProxy",
304        Registry::Hex => "Hex",
305        Registry::Hackage => "Hackage",
306        Registry::Packagist => "Packagist",
307        Registry::Maven => "Maven",
308        Registry::Pub => "Pub",
309        Registry::Oci { .. } => "Oci { registry_url: String::new() }",
310        Registry::Private { .. } => "Private { url: String::new(), protocol: String::new() }",
311        Registry::None => "None",
312    };
313    let marker = s
314        .manifest_markers
315        .first()
316        .cloned()
317        .unwrap_or_else(|| "MANIFEST".into());
318    format!(
319        r#"//! `{crate_name}` — {name} adapter for the `gen` ecosystem.
320//!
321//! Generated by gen-adapter-forge from a typed AdapterSpec. Implement
322//! the parse_manifest + (optionally) parse_lockfile bodies — the rest
323//! of the wiring (manifest assembly, root dispatch, error mapping)
324//! is already in place.
325
326pub mod error;
327pub use error::{{Error, Result}};
328
329use std::path::Path;
330
331use gen_types::{{
332    BuildStep, Dependency, Feature, Lockfile, Manifest, Package, PackageSource, Registry,
333    Version, Workspace,
334}};
335
336pub const MANIFEST_MARKER: &str = "{marker}";
337
338/// Adapter entrypoint. Reads `<root>/{marker}` and emits a typed Manifest.
339pub fn parse(root: &Path) -> Result<Manifest> {{
340    let _text = std::fs::read_to_string(root.join(MANIFEST_MARKER)).map_err(|source| Error::Io {{
341        path: root.join(MANIFEST_MARKER),
342        source,
343    }})?;
344    // TODO(adapter author): parse _text into Package + Dependency + Feature shapes.
345    let placeholder = Package {{
346        name: root
347            .file_name()
348            .map(|s| s.to_string_lossy().into_owned())
349            .unwrap_or_else(|| "<unnamed>".to_string()),
350        version: Version::new(0, 0, 0),
351        source: PackageSource::Path {{
352            path: root.display().to_string(),
353        }},
354        registry: Registry::{registry_variant},
355        dependencies: Vec::<Dependency>::new(),
356        features: Vec::<Feature>::new(),
357        build_steps: Vec::<BuildStep>::new(),
358        license: None,
359        description: None,
360        authors: Vec::new(),
361        homepage: None,
362        repository: None,
363    }};
364    let workspace = Workspace::single_package(root.to_path_buf(), "{name}");
365    let lockfile = None::<Lockfile>;
366    Ok(Manifest::new(root.to_path_buf(), workspace, vec![placeholder], lockfile))
367}}
368"#,
369        crate_name = s.crate_name,
370        name = s.name,
371        marker = marker,
372        registry_variant = registry_variant,
373    )
374}
375
376fn render_error_rs(s: &AdapterSpec) -> String {
377    format!(
378        r#"//! Typed errors for the {name} adapter.
379
380use std::path::PathBuf;
381use thiserror::Error;
382
383#[derive(Debug, Error)]
384pub enum Error {{
385    #[error("failed to read {{path}}: {{source}}")]
386    Io {{
387        path: PathBuf,
388        #[source]
389        source: std::io::Error,
390    }},
391    #[error("version `{{raw}}` for {{context}} could not be parsed")]
392    BadVersion {{ raw: String, context: String }},
393    #[error("dependency `{{name}}` requirement `{{raw}}` could not be parsed")]
394    BadVersionReq {{ name: String, raw: String }},
395}}
396
397pub type Result<T> = std::result::Result<T, Error>;
398"#,
399        name = s.name,
400    )
401}
402
403fn render_raw_rs(s: &AdapterSpec) -> String {
404    format!(
405        r#"//! Raw serde shapes mirroring the {name} on-disk format.
406//!
407//! TODO(adapter author): replace the placeholder struct with the
408//! actual fields the manifest format ships.
409
410use serde::Deserialize;
411
412#[derive(Debug, Clone, Default, Deserialize)]
413pub struct ManifestRaw {{
414    pub name: Option<String>,
415    pub version: Option<String>,
416}}
417"#,
418        name = s.name,
419    )
420}
421
422fn render_smoke_test(s: &AdapterSpec) -> String {
423    let marker = s
424        .manifest_markers
425        .first()
426        .cloned()
427        .unwrap_or_else(|| "MANIFEST".into());
428    let lib_name = s.crate_name.replace('-', "_");
429    format!(
430        r#"use std::fs;
431use std::path::PathBuf;
432
433#[test]
434fn ingests_empty_manifest() {{
435    let dir: PathBuf = std::env::temp_dir().join("{name}-adapter-smoke");
436    let _ = fs::remove_dir_all(&dir);
437    fs::create_dir_all(&dir).unwrap();
438    fs::write(dir.join("{marker}"), "").unwrap();
439    let m = {lib_name}::parse(&dir).unwrap();
440    assert!(m.package_count() >= 1);
441}}
442"#,
443        name = s.name,
444        marker = marker,
445        lib_name = lib_name,
446    )
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn cargo_spec_is_well_formed() {
455        let s = cargo_spec();
456        assert_eq!(s.name, "cargo");
457        assert_eq!(s.manifest_markers, vec!["Cargo.toml".to_string()]);
458        assert!(matches!(s.constraint_family, ConstraintFamily::SemverCaretDefault));
459    }
460
461    #[test]
462    fn npm_spec_is_well_formed() {
463        let s = npm_spec();
464        assert_eq!(s.name, "npm");
465        assert!(s.lockfile_markers.contains(&"pnpm-lock.yaml".to_string()));
466    }
467
468    #[test]
469    fn bundler_spec_is_well_formed() {
470        let s = bundler_spec();
471        assert!(matches!(s.target_predicate_shape, TargetPredicateShape::BundlerPlatforms));
472    }
473
474    #[test]
475    fn forge_emits_all_required_files() {
476        let s = cargo_spec();
477        let out = forge(&s);
478        assert!(out.files.contains_key("Cargo.toml"));
479        assert!(out.files.contains_key("src/lib.rs"));
480        assert!(out.files.contains_key("src/error.rs"));
481        assert!(out.files.contains_key("src/raw.rs"));
482        assert!(out.files.contains_key("tests/smoke.rs"));
483    }
484
485    #[test]
486    fn forge_skips_raw_for_line_oriented_format() {
487        let s = bundler_spec();
488        let out = forge(&s);
489        assert!(!out.files.contains_key("src/raw.rs"));
490    }
491
492    #[test]
493    fn rendered_cargo_toml_has_correct_crate_name() {
494        let s = cargo_spec();
495        let out = forge(&s);
496        let toml = out.files.get("Cargo.toml").unwrap();
497        assert!(toml.contains("name = \"gen-cargo\""));
498        assert!(toml.contains("name = \"gen_cargo\""));
499    }
500
501    #[test]
502    fn rendered_lib_uses_correct_registry_variant() {
503        let s = npm_spec();
504        let out = forge(&s);
505        let lib = out.files.get("src/lib.rs").unwrap();
506        assert!(lib.contains("Registry::Npm"));
507    }
508
509    #[test]
510    fn rendered_lib_compiles_to_a_valid_parse_signature() {
511        let s = bundler_spec();
512        let out = forge(&s);
513        let lib = out.files.get("src/lib.rs").unwrap();
514        assert!(lib.contains("pub fn parse(root: &Path) -> Result<Manifest>"));
515        assert!(lib.contains("MANIFEST_MARKER"));
516    }
517}