1use indexmap::IndexMap;
22use serde::{Deserialize, Serialize};
23
24use gen_types::Registry;
25
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30pub struct AdapterSpec {
31 pub name: String,
33 pub crate_name: String,
35 pub manifest_markers: Vec<String>,
37 pub lockfile_markers: Vec<String>,
39 pub registry: Registry,
41 pub constraint_family: ConstraintFamily,
43 pub dependency_tables: IndexMap<String, String>,
46 pub target_predicate_shape: TargetPredicateShape,
48 pub workspace_shape: WorkspaceShape,
50 pub manifest_format: ManifestFormat,
52 pub lockfile_format: LockfileFormat,
54 pub description: String,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "kebab-case")]
61pub enum ConstraintFamily {
62 SemverCaretDefault,
64 SemverExactDefault,
66 BundlerPessimistic,
68 Pep440,
70 GoMvs,
72 Composer,
74 HexPessimistic,
76 None,
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "kebab-case")]
82pub enum TargetPredicateShape {
83 CargoCfg,
85 NpmEnginesOsCpu,
87 BundlerPlatforms,
89 Pep508,
91 GoBuildTags,
93 None,
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "kebab-case")]
99pub enum WorkspaceShape {
100 CargoWorkspaceMembers,
102 NpmWorkspacesField,
104 SinglePackageOnly,
106 ComposerPathRepositories,
108 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 LineOriented,
121 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 LineOriented,
133 GoSum,
135 None,
137}
138
139impl LockfileFormat {
140 pub const fn sniffed_default() -> Self {
145 Self::Json
146 }
147}
148
149#[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#[derive(Debug, Clone)]
221pub struct ScaffoldOutput {
222 pub files: IndexMap<String, String>,
223}
224
225#[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}