use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use gen_types::Registry;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdapterSpec {
pub name: String,
pub crate_name: String,
pub manifest_markers: Vec<String>,
pub lockfile_markers: Vec<String>,
pub registry: Registry,
pub constraint_family: ConstraintFamily,
pub dependency_tables: IndexMap<String, String>,
pub target_predicate_shape: TargetPredicateShape,
pub workspace_shape: WorkspaceShape,
pub manifest_format: ManifestFormat,
pub lockfile_format: LockfileFormat,
pub description: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ConstraintFamily {
SemverCaretDefault,
SemverExactDefault,
BundlerPessimistic,
Pep440,
GoMvs,
Composer,
HexPessimistic,
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TargetPredicateShape {
CargoCfg,
NpmEnginesOsCpu,
BundlerPlatforms,
Pep508,
GoBuildTags,
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WorkspaceShape {
CargoWorkspaceMembers,
NpmWorkspacesField,
SinglePackageOnly,
ComposerPathRepositories,
PnpmWorkspaceYaml,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ManifestFormat {
Toml,
Json,
Yaml,
LineOriented,
Sniffed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LockfileFormat {
Toml,
Json,
Yaml,
LineOriented,
GoSum,
None,
}
impl LockfileFormat {
pub const fn sniffed_default() -> Self {
Self::Json
}
}
#[must_use]
pub fn cargo_spec() -> AdapterSpec {
let mut deps = IndexMap::new();
deps.insert("dependencies".into(), "direct".into());
deps.insert("dev-dependencies".into(), "dev".into());
deps.insert("build-dependencies".into(), "build".into());
AdapterSpec {
name: "cargo".into(),
crate_name: "gen-cargo".into(),
manifest_markers: vec!["Cargo.toml".into()],
lockfile_markers: vec!["Cargo.lock".into()],
registry: Registry::CratesIo,
constraint_family: ConstraintFamily::SemverCaretDefault,
dependency_tables: deps,
target_predicate_shape: TargetPredicateShape::CargoCfg,
workspace_shape: WorkspaceShape::CargoWorkspaceMembers,
manifest_format: ManifestFormat::Toml,
lockfile_format: LockfileFormat::Toml,
description: "gen — Cargo adapter".into(),
}
}
#[must_use]
pub fn npm_spec() -> AdapterSpec {
let mut deps = IndexMap::new();
deps.insert("dependencies".into(), "direct".into());
deps.insert("devDependencies".into(), "dev".into());
deps.insert("peerDependencies".into(), "peer".into());
deps.insert("optionalDependencies".into(), "optional".into());
AdapterSpec {
name: "npm".into(),
crate_name: "gen-npm".into(),
manifest_markers: vec!["package.json".into()],
lockfile_markers: vec!["pnpm-lock.yaml".into(), "package-lock.json".into()],
registry: Registry::Npm,
constraint_family: ConstraintFamily::SemverExactDefault,
dependency_tables: deps,
target_predicate_shape: TargetPredicateShape::NpmEnginesOsCpu,
workspace_shape: WorkspaceShape::NpmWorkspacesField,
manifest_format: ManifestFormat::Json,
lockfile_format: LockfileFormat::sniffed_default(),
description: "gen — npm/pnpm/yarn adapter".into(),
}
}
#[must_use]
pub fn bundler_spec() -> AdapterSpec {
let mut deps = IndexMap::new();
deps.insert("gem".into(), "direct".into());
AdapterSpec {
name: "bundler".into(),
crate_name: "gen-bundler".into(),
manifest_markers: vec!["Gemfile".into()],
lockfile_markers: vec!["Gemfile.lock".into()],
registry: Registry::RubyGems,
constraint_family: ConstraintFamily::BundlerPessimistic,
dependency_tables: deps,
target_predicate_shape: TargetPredicateShape::BundlerPlatforms,
workspace_shape: WorkspaceShape::SinglePackageOnly,
manifest_format: ManifestFormat::LineOriented,
lockfile_format: LockfileFormat::LineOriented,
description: "gen — Ruby/Bundler adapter".into(),
}
}
#[derive(Debug, Clone)]
pub struct ScaffoldOutput {
pub files: IndexMap<String, String>,
}
#[must_use]
pub fn forge(spec: &AdapterSpec) -> ScaffoldOutput {
let mut files = IndexMap::new();
files.insert("Cargo.toml".to_string(), render_cargo_toml(spec));
files.insert("src/lib.rs".to_string(), render_lib_rs(spec));
files.insert("src/error.rs".to_string(), render_error_rs(spec));
if matches!(
spec.manifest_format,
ManifestFormat::Json | ManifestFormat::Yaml | ManifestFormat::Toml
) {
files.insert("src/raw.rs".to_string(), render_raw_rs(spec));
}
files.insert("tests/smoke.rs".to_string(), render_smoke_test(spec));
ScaffoldOutput { files }
}
fn render_cargo_toml(s: &AdapterSpec) -> String {
let format_dep = match s.manifest_format {
ManifestFormat::Toml => "toml = { workspace = true }\n",
ManifestFormat::Json => "",
ManifestFormat::Yaml => "serde_yaml = { workspace = true }\n",
ManifestFormat::LineOriented | ManifestFormat::Sniffed => "",
};
let lock_dep = match s.lockfile_format {
LockfileFormat::Yaml => "serde_yaml = { workspace = true }\n",
LockfileFormat::Toml | LockfileFormat::Json | LockfileFormat::LineOriented
| LockfileFormat::GoSum | LockfileFormat::None => "",
};
format!(
r#"[package]
name = "{crate_name}"
description = "{desc}"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
[lib]
name = "{lib_name}"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
gen-types = {{ workspace = true }}
serde = {{ workspace = true }}
serde_json = {{ workspace = true }}
indexmap = {{ workspace = true }}
thiserror = {{ workspace = true }}
{format_dep}{lock_dep}"#,
crate_name = s.crate_name,
lib_name = s.crate_name.replace('-', "_"),
desc = s.description,
)
}
fn render_lib_rs(s: &AdapterSpec) -> String {
let registry_variant = match s.registry {
Registry::CratesIo => "CratesIo",
Registry::Npm => "Npm",
Registry::RubyGems => "RubyGems",
Registry::PyPi => "PyPi",
Registry::GoProxy => "GoProxy",
Registry::Hex => "Hex",
Registry::Hackage => "Hackage",
Registry::Packagist => "Packagist",
Registry::Maven => "Maven",
Registry::Pub => "Pub",
Registry::Oci { .. } => "Oci { registry_url: String::new() }",
Registry::Private { .. } => "Private { url: String::new(), protocol: String::new() }",
Registry::None => "None",
};
let marker = s
.manifest_markers
.first()
.cloned()
.unwrap_or_else(|| "MANIFEST".into());
format!(
r#"//! `{crate_name}` — {name} adapter for the `gen` ecosystem.
//!
//! Generated by gen-adapter-forge from a typed AdapterSpec. Implement
//! the parse_manifest + (optionally) parse_lockfile bodies — the rest
//! of the wiring (manifest assembly, root dispatch, error mapping)
//! is already in place.
pub mod error;
pub use error::{{Error, Result}};
use std::path::Path;
use gen_types::{{
BuildStep, Dependency, Feature, Lockfile, Manifest, Package, PackageSource, Registry,
Version, Workspace,
}};
pub const MANIFEST_MARKER: &str = "{marker}";
/// Adapter entrypoint. Reads `<root>/{marker}` and emits a typed Manifest.
pub fn parse(root: &Path) -> Result<Manifest> {{
let _text = std::fs::read_to_string(root.join(MANIFEST_MARKER)).map_err(|source| Error::Io {{
path: root.join(MANIFEST_MARKER),
source,
}})?;
// TODO(adapter author): parse _text into Package + Dependency + Feature shapes.
let placeholder = Package {{
name: root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "<unnamed>".to_string()),
version: Version::new(0, 0, 0),
source: PackageSource::Path {{
path: root.display().to_string(),
}},
registry: Registry::{registry_variant},
dependencies: Vec::<Dependency>::new(),
features: Vec::<Feature>::new(),
build_steps: Vec::<BuildStep>::new(),
license: None,
description: None,
authors: Vec::new(),
homepage: None,
repository: None,
}};
let workspace = Workspace::single_package(root.to_path_buf(), "{name}");
let lockfile = None::<Lockfile>;
Ok(Manifest::new(root.to_path_buf(), workspace, vec![placeholder], lockfile))
}}
"#,
crate_name = s.crate_name,
name = s.name,
marker = marker,
registry_variant = registry_variant,
)
}
fn render_error_rs(s: &AdapterSpec) -> String {
format!(
r#"//! Typed errors for the {name} adapter.
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {{
#[error("failed to read {{path}}: {{source}}")]
Io {{
path: PathBuf,
#[source]
source: std::io::Error,
}},
#[error("version `{{raw}}` for {{context}} could not be parsed")]
BadVersion {{ raw: String, context: String }},
#[error("dependency `{{name}}` requirement `{{raw}}` could not be parsed")]
BadVersionReq {{ name: String, raw: String }},
}}
pub type Result<T> = std::result::Result<T, Error>;
"#,
name = s.name,
)
}
fn render_raw_rs(s: &AdapterSpec) -> String {
format!(
r#"//! Raw serde shapes mirroring the {name} on-disk format.
//!
//! TODO(adapter author): replace the placeholder struct with the
//! actual fields the manifest format ships.
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ManifestRaw {{
pub name: Option<String>,
pub version: Option<String>,
}}
"#,
name = s.name,
)
}
fn render_smoke_test(s: &AdapterSpec) -> String {
let marker = s
.manifest_markers
.first()
.cloned()
.unwrap_or_else(|| "MANIFEST".into());
let lib_name = s.crate_name.replace('-', "_");
format!(
r#"use std::fs;
use std::path::PathBuf;
#[test]
fn ingests_empty_manifest() {{
let dir: PathBuf = std::env::temp_dir().join("{name}-adapter-smoke");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("{marker}"), "").unwrap();
let m = {lib_name}::parse(&dir).unwrap();
assert!(m.package_count() >= 1);
}}
"#,
name = s.name,
marker = marker,
lib_name = lib_name,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cargo_spec_is_well_formed() {
let s = cargo_spec();
assert_eq!(s.name, "cargo");
assert_eq!(s.manifest_markers, vec!["Cargo.toml".to_string()]);
assert!(matches!(s.constraint_family, ConstraintFamily::SemverCaretDefault));
}
#[test]
fn npm_spec_is_well_formed() {
let s = npm_spec();
assert_eq!(s.name, "npm");
assert!(s.lockfile_markers.contains(&"pnpm-lock.yaml".to_string()));
}
#[test]
fn bundler_spec_is_well_formed() {
let s = bundler_spec();
assert!(matches!(s.target_predicate_shape, TargetPredicateShape::BundlerPlatforms));
}
#[test]
fn forge_emits_all_required_files() {
let s = cargo_spec();
let out = forge(&s);
assert!(out.files.contains_key("Cargo.toml"));
assert!(out.files.contains_key("src/lib.rs"));
assert!(out.files.contains_key("src/error.rs"));
assert!(out.files.contains_key("src/raw.rs"));
assert!(out.files.contains_key("tests/smoke.rs"));
}
#[test]
fn forge_skips_raw_for_line_oriented_format() {
let s = bundler_spec();
let out = forge(&s);
assert!(!out.files.contains_key("src/raw.rs"));
}
#[test]
fn rendered_cargo_toml_has_correct_crate_name() {
let s = cargo_spec();
let out = forge(&s);
let toml = out.files.get("Cargo.toml").unwrap();
assert!(toml.contains("name = \"gen-cargo\""));
assert!(toml.contains("name = \"gen_cargo\""));
}
#[test]
fn rendered_lib_uses_correct_registry_variant() {
let s = npm_spec();
let out = forge(&s);
let lib = out.files.get("src/lib.rs").unwrap();
assert!(lib.contains("Registry::Npm"));
}
#[test]
fn rendered_lib_compiles_to_a_valid_parse_signature() {
let s = bundler_spec();
let out = forge(&s);
let lib = out.files.get("src/lib.rs").unwrap();
assert!(lib.contains("pub fn parse(root: &Path) -> Result<Manifest>"));
assert!(lib.contains("MANIFEST_MARKER"));
}
}