use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, Subcommand, ValueEnum};
use gen_config::GenConfig;
use shikumi::{ConfigTier, TieredConfig};
mod flake_lint;
mod fleet_migrate_plan;
#[allow(unused_imports)]
use {
gen_ansible as _, gen_bundler as _, gen_cargo as _, gen_gomod as _, gen_helm as _,
gen_npm as _, gen_pip as _, gen_poetry as _, gen_swift as _,
};
#[derive(Parser, Debug)]
#[command(name = "gen", version, about = "universal package-manager engine — operator CLI")]
struct Cli {
#[arg(long, value_enum, default_value_t = OutputFormat::Json, global = true)]
format: OutputFormat,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum OutputFormat {
Json,
Yaml,
}
#[derive(Subcommand, Debug)]
enum Cmd {
Check {
path: Option<PathBuf>,
},
#[command(name = "lockfile-info")]
LockfileInfo {
path: Option<PathBuf>,
},
#[command(name = "config-show")]
ConfigShow {
#[arg(value_enum, default_value_t = TierArg::Default)]
tier: TierArg,
path: Option<PathBuf>,
},
#[command(name = "config-diff")]
ConfigDiff {
#[arg(value_enum)]
from: TierArg,
#[arg(value_enum)]
to: TierArg,
},
Adapters,
Quirks {
#[arg(long)]
adapter: Option<String>,
},
Dispatchers {
#[arg(long)]
ecosystem: Option<String>,
#[arg(long)]
from_catalog: bool,
},
#[command(name = "dispatchers-skeleton")]
DispatchersSkeleton {
#[arg(long)]
label: String,
},
Render {
path: Option<PathBuf>,
},
#[command(name = "lock-features")]
LockFeatures {
path: Option<PathBuf>,
},
#[command(name = "lock-build")]
LockBuild {
path: Option<PathBuf>,
},
Build {
path: Option<PathBuf>,
#[arg(long, value_name = "TRIPLE", conflicts_with = "single_target")]
filter_platform: Option<String>,
#[arg(long, conflicts_with = "filter_platform")]
single_target: bool,
#[arg(long)]
if_stale: bool,
#[arg(long)]
commit: bool,
},
#[command(name = "check-spec")]
CheckSpec {
path: Option<PathBuf>,
},
Confirm {
path: Option<PathBuf>,
#[arg(long)]
if_present: bool,
},
#[command(name = "fleet-check")]
FleetCheck {
path: PathBuf,
},
#[command(name = "fleet-sweep")]
FleetSweep {
path: PathBuf,
#[arg(long)]
write: bool,
},
#[command(name = "flake-lint")]
FlakeLint {
path: Option<PathBuf>,
#[arg(long)]
fix: bool,
},
#[command(name = "lock")]
Lock {
path: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["update", "reset"])]
snapshot: bool,
#[arg(long, conflicts_with_all = ["snapshot", "reset"])]
update: bool,
#[arg(long, conflicts_with_all = ["snapshot", "update"])]
reset: bool,
},
#[command(name = "fleet-commit")]
FleetCommit {
path: PathBuf,
#[arg(long)]
push: bool,
#[arg(long)]
rebase_first: bool,
},
#[command(name = "fleet-migrate")]
FleetMigrate {
plan: PathBuf,
#[arg(long)]
no_push: bool,
#[arg(long)]
jobs: Option<usize>,
#[arg(long)]
refresh: bool,
},
#[command(name = "fleet-verify")]
FleetVerify {
plan: PathBuf,
#[arg(long)]
fetch: bool,
#[arg(long)]
jobs: Option<usize>,
},
#[command(name = "cache-probe")]
CacheProbe {
path: Option<PathBuf>,
},
Scaffold {
#[arg(value_enum)]
shape: ScaffoldShape,
name: String,
#[arg(long, short = 'd')]
dir: Option<PathBuf>,
#[arg(long, default_value = "pleme-io")]
owner: String,
},
#[command(name = "scaffold-adapter")]
ScaffoldAdapter {
name: String,
#[arg(long)]
manifest: String,
#[arg(long)]
dir: Option<PathBuf>,
},
#[command(subcommand)]
Kanshou(KanshouCmd),
}
#[derive(Subcommand, Debug)]
enum KanshouCmd {
List {
#[arg(long)]
app: Option<String>,
},
Query {
app: String,
path: String,
#[arg(long)]
pid: Option<u32>,
},
Schema {
app: String,
#[arg(long)]
pid: Option<u32>,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ScaffoldShape {
Tool,
Workspace,
Library,
Service,
Binary,
}
impl ScaffoldShape {
fn as_str(self) -> &'static str {
match self {
Self::Tool => "tool",
Self::Workspace => "workspace",
Self::Library => "library",
Self::Service => "service",
Self::Binary => "binary",
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum TierArg {
Bare,
Discovered,
Default,
Custom,
}
impl TierArg {
fn into_shikumi(self, custom_path: Option<PathBuf>) -> ConfigTier {
match self {
Self::Bare => ConfigTier::Bare,
Self::Discovered => ConfigTier::Discovered,
Self::Default => ConfigTier::Default,
Self::Custom => ConfigTier::Custom(
custom_path.unwrap_or_else(|| PathBuf::from(".gen.yaml")),
),
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let cfg = GenConfig::resolve_from_env("GEN_TIER");
match run(&cli, &cfg) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("gen: error: {e}");
ExitCode::from(1)
}
}
}
fn run(cli: &Cli, cfg: &GenConfig) -> Result<(), CliError> {
match &cli.cmd {
Cmd::Check { path } => {
let root = resolve_root(path, cfg);
let manifest = dispatch(&root, cfg)?;
emit(&manifest, cli.format)
}
Cmd::LockfileInfo { path } => {
let root = resolve_root(path, cfg);
let manifest = dispatch(&root, cfg)?;
#[derive(serde::Serialize)]
struct LockReport<'a> {
has_lockfile: bool,
resolved_count: usize,
content_addressed_hash: Option<String>,
content_addressed_sources: usize,
first_few: Vec<&'a str>,
}
let report = match &manifest.lockfile {
None => LockReport {
has_lockfile: false,
resolved_count: 0,
content_addressed_hash: None,
content_addressed_sources: 0,
first_few: Vec::new(),
},
Some(l) => {
let cac = l
.resolved
.values()
.filter(|r| r.source.is_content_addressed())
.count();
LockReport {
has_lockfile: true,
resolved_count: l.resolved.len(),
content_addressed_hash: Some(l.content_addressed_hash.hex()),
content_addressed_sources: cac,
first_few: l.resolved.keys().take(10).map(String::as_str).collect(),
}
}
};
emit(&report, cli.format)
}
Cmd::ConfigShow { tier, path } => {
let resolved = GenConfig::resolve_tier(tier.into_shikumi(path.clone()));
emit(&resolved, cli.format)
}
Cmd::ConfigDiff { from, to } => {
let a = GenConfig::resolve_tier(from.into_shikumi(None));
let b = GenConfig::resolve_tier(to.into_shikumi(None));
let diff = b.diff_against(&a);
println!("{}", diff.render_unified());
Ok(())
}
Cmd::Render { path } => {
let root = resolve_root(path, cfg);
let adapter = pick_adapter(&root, cfg)?;
if adapter != "cargo" {
return Err(CliError::RenderNotImplementedForAdapter(adapter));
}
let mut manifest = dispatch(&root, cfg)?;
enrich_manifest_with_build_spec(&root, &mut manifest);
let text = gen_nix::render_workspace_to_cargo_nix(&manifest);
if cfg.render.output_path.is_empty() {
println!("{text}");
} else {
std::fs::write(&cfg.render.output_path, &text).map_err(CliError::Io)?;
eprintln!(
"gen: wrote {} bytes to {}",
text.len(),
cfg.render.output_path
);
}
Ok(())
}
Cmd::LockFeatures { path } => {
let root = resolve_root(path, cfg);
let out = gen_cargo::features::generate_and_write(&root).map_err(CliError::Cargo)?;
eprintln!("gen: wrote {}", out.display());
Ok(())
}
Cmd::LockBuild { path } => {
let root = resolve_root(path, cfg);
let out = gen_cargo::build_spec::generate_and_write(&root).map_err(CliError::Cargo)?;
eprintln!("gen: wrote {}", out.display());
Ok(())
}
Cmd::FleetCommit {
path,
push,
rebase_first,
} => {
let report = gen_cargo::fleet_commit::run(path, *push, *rebase_first)
.map_err(CliError::Cargo)?;
#[derive(serde::Serialize)]
struct Summary {
root: std::path::PathBuf,
total: usize,
committed: usize,
pushed: usize,
skipped: usize,
failed: usize,
elapsed_ms: u64,
report: gen_cargo::fleet_commit::CommitReport,
}
let summary = Summary {
root: report.root.clone(),
total: report.total(),
committed: report.committed_count(),
pushed: report.pushed_count(),
skipped: report.skipped_count(),
failed: report.failed_count(),
elapsed_ms: report.total_elapsed_ms,
report,
};
emit(&summary, cli.format)
}
Cmd::FleetMigrate { plan, no_push, jobs, refresh } => {
let src = std::fs::read_to_string(plan)
.map_err(|e| CliError::Other(format!("read {}: {e}", plan.display())))?;
let report = fleet_migrate_plan::run_plan(&src, *no_push, *jobs, *refresh)
.map_err(CliError::Other)?;
#[derive(serde::Serialize)]
struct Summary {
total: usize,
migrated: usize,
pushed: usize,
skipped: usize,
failed: usize,
elapsed_ms: u64,
report: gen_cargo::fleet_migrate::MigrateReport,
}
let summary = Summary {
total: report.total(),
migrated: report.migrated_count(),
pushed: report.pushed_count(),
skipped: report.skipped_count(),
failed: report.failed_count(),
elapsed_ms: report.total_elapsed_ms,
report,
};
emit(&summary, cli.format)
}
Cmd::FleetVerify { plan, fetch, jobs } => {
let src = std::fs::read_to_string(plan)
.map_err(|e| CliError::Other(format!("read {}: {e}", plan.display())))?;
let report =
fleet_migrate_plan::verify_plan(&src, *fetch, *jobs).map_err(CliError::Other)?;
#[derive(serde::Serialize)]
struct Summary {
total: usize,
delta_only: usize,
all_delta_only: bool,
elapsed_ms: u64,
report: gen_cargo::fleet_verify::VerifyReport,
}
let summary = Summary {
total: report.rows.len(),
delta_only: report.delta_only(),
all_delta_only: report.all_delta_only(),
elapsed_ms: report.total_elapsed_ms,
report,
};
emit(&summary, cli.format)
}
Cmd::FleetSweep { path, write } => {
let report = gen_cargo::fleet_sweep::run(path, *write).map_err(CliError::Cargo)?;
#[derive(serde::Serialize)]
struct Summary {
root: std::path::PathBuf,
total: usize,
ok: usize,
failed: usize,
skipped: usize,
total_spec_bytes: usize,
elapsed_ms: u64,
failures_by_category: indexmap::IndexMap<String, Vec<String>>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
wrote_sidecars: bool,
report: gen_cargo::fleet_sweep::SweepReport,
}
let by_cat = report.failures_by_category();
let by_cat_str: indexmap::IndexMap<String, Vec<String>> = by_cat
.into_iter()
.map(|(k, v)| (format!("{k:?}"), v))
.collect();
let summary = Summary {
root: report.root.clone(),
total: report.total(),
ok: report.ok_count(),
failed: report.failed_count(),
skipped: report.skipped_count(),
total_spec_bytes: report.total_spec_bytes(),
elapsed_ms: report.total_elapsed_ms,
failures_by_category: by_cat_str,
wrote_sidecars: *write,
report,
};
emit(&summary, cli.format)
}
Cmd::Build { path, filter_platform, single_target, if_stale, commit } => {
let root = resolve_root(path, cfg);
let adapter = pick_adapter(&root, cfg)?;
match adapter.as_str() {
"cargo" => {
if *if_stale && filter_platform.is_none() && !*single_target {
let (freshness, out) =
gen_cargo::build_spec::generate_and_write_if_stale(&root)
.map_err(CliError::Cargo)?;
eprintln!(
"gen build --if-stale: {} ({})",
freshness.summary(),
out.display()
);
return Ok(());
}
let out = if let Some(triple) = filter_platform {
gen_cargo::build_spec::generate_for_target_and_write(&root, &triple)
.map_err(CliError::Cargo)?
} else if *single_target {
gen_cargo::build_spec::generate_and_write(&root)
.map_err(CliError::Cargo)?
} else {
gen_cargo::build_spec::generate_multi_target_and_write(&root)
.map_err(CliError::Cargo)?
};
eprintln!("gen build: wrote {}", out.display());
if *commit {
commit_spec_if_drifted(&root)?;
}
}
"gomod" => {
let out = match filter_platform {
Some(triple) => {
gen_gomod::generate_for_target_and_write(&root, triple)
.map_err(CliError::Gomod)?
}
None => gen_gomod::generate_and_write(&root).map_err(CliError::Gomod)?,
};
eprintln!("gen build: wrote {}", out.display());
}
other => {
return Err(CliError::RenderNotImplementedForAdapter(other.to_string()));
}
}
Ok(())
}
Cmd::CheckSpec { path } => {
let root = resolve_root(path, cfg);
let adapter = pick_adapter(&root, cfg)?;
match adapter.as_str() {
"cargo" => {
let freshness = gen_cargo::build_spec::check_freshness(&root);
let needs_regen = freshness.needs_regen();
emit(&freshness, cli.format)?;
if needs_regen {
std::process::exit(1);
}
Ok(())
}
other => {
return Err(CliError::RenderNotImplementedForAdapter(other.to_string()));
}
}
}
Cmd::Confirm { path, if_present } => {
let root = resolve_root(path, cfg);
let adapter = pick_adapter(&root, cfg)?;
match adapter.as_str() {
"cargo" => {
let freshness = gen_cargo::gen_delta::confirm_freshness(&root);
let gate = freshness.gates_failure(!*if_present);
emit(&freshness, cli.format)?;
if gate {
std::process::exit(1);
}
Ok(())
}
other => {
return Err(CliError::RenderNotImplementedForAdapter(other.to_string()));
}
}
}
Cmd::FleetCheck { path } => {
#[derive(serde::Serialize)]
struct RepoFreshness<'a> {
path: &'a str,
freshness: gen_cargo::build_spec::Freshness,
}
let mut entries: Vec<RepoFreshness> = Vec::new();
let mut owned_paths: Vec<String> = Vec::new();
let mut needs_any_regen = false;
for entry in std::fs::read_dir(path).map_err(|e| {
CliError::Other(format!("fleet-check: read_dir {}: {e}", path.display()))
})? {
let Ok(entry) = entry else { continue };
let p = entry.path();
if !p.is_dir() {
continue;
}
if !p.join("Cargo.toml").is_file() {
continue;
}
let f = gen_cargo::build_spec::check_freshness(&p);
if f.needs_regen() {
needs_any_regen = true;
}
owned_paths.push(p.display().to_string());
entries.push(RepoFreshness {
path: "",
freshness: f,
});
}
let final_entries: Vec<RepoFreshness> = entries
.into_iter()
.zip(owned_paths.iter())
.map(|(e, p)| RepoFreshness {
path: p.as_str(),
freshness: e.freshness,
})
.collect();
emit(&final_entries, cli.format)?;
if needs_any_regen {
std::process::exit(1);
}
Ok(())
}
Cmd::FlakeLint { path, fix } => {
let root = path.clone().unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let source = flake_lint::NixCliMetadataSource;
let report = flake_lint::run(&source, &root, *fix)
.map_err(|e| CliError::Other(format!("flake-lint: {e}")))?;
emit(&report, cli.format)?;
if !report.issues.is_empty() && !*fix {
std::process::exit(1);
}
Ok(())
}
Cmd::Lock {
path,
snapshot,
update,
reset,
} => {
use gen_cargo::lock_lifecycle::{self, CargoLifecycle, LockLifecycleState};
use gen_types::LockLifecyclePrimitive;
let root = path
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let prim = CargoLifecycle::new();
let state = prim.current_state(&root);
let spec_path = root.join("Cargo.build-spec.json");
#[derive(serde::Serialize)]
struct LockReport<'a> {
root: &'a std::path::Path,
action: &'a str,
state_before: &'a LockLifecycleState,
#[serde(skip_serializing_if = "Option::is_none")]
state_after: Option<LockLifecycleState>,
#[serde(skip_serializing_if = "Option::is_none")]
diff: Option<lock_lifecycle::LockDiff>,
}
let _ = spec_path; match (*snapshot, *update, *reset) {
(false, false, false) => {
let r = LockReport {
root: &root,
action: "show",
state_before: &state,
state_after: None,
diff: None,
};
emit(&r, cli.format)?;
if prim.requires_operator_action(&state) {
std::process::exit(1);
}
Ok(())
}
(true, false, false) => {
prim.snapshot(&root)
.map_err(|e| CliError::Other(format!("gen lock --snapshot: {e}")))?;
let after = prim.current_state(&root);
let r = LockReport {
root: &root,
action: "snapshot",
state_before: &state,
state_after: Some(after),
diff: None,
};
emit(&r, cli.format)
}
(false, true, false) => {
let diff = prim
.update(&root)
.map_err(|e| CliError::Other(format!("gen lock --update: {e}")))?;
let after = prim.current_state(&root);
let r = LockReport {
root: &root,
action: "update",
state_before: &state,
state_after: Some(after),
diff: Some(diff),
};
emit(&r, cli.format)
}
(false, false, true) => {
prim.reset(&root)
.map_err(|e| CliError::Other(format!("gen lock --reset: {e}")))?;
let after = prim.current_state(&root);
let r = LockReport {
root: &root,
action: "reset",
state_before: &state,
state_after: Some(after),
diff: None,
};
emit(&r, cli.format)
}
_ => unreachable!("clap conflicts_with_all prevents multi-mode"),
}
}
Cmd::CacheProbe { path } => {
let root = resolve_root(path, cfg);
let manifest = dispatch(&root, cfg)?;
let client = gen_cache_attic::client_from_config(&cfg.cache);
let keys: Vec<gen_cache_attic::CacheKey> = match &manifest.lockfile {
None => Vec::new(),
Some(l) => l
.resolved
.values()
.filter(|r| r.source.is_content_addressed())
.map(|r| {
gen_cache_attic::CacheKey::new(
r.integrity
.as_deref()
.and_then(|i| {
let hex = i.strip_prefix("sha256:").unwrap_or(i);
gen_types::ContentHash::from_hex_padded(hex)
})
.unwrap_or_else(|| {
gen_types::ContentHash::of(r.id.dotted().as_bytes())
}),
r.id.dotted(),
)
})
.collect(),
};
let report = client.probe_batch(&keys);
#[derive(serde::Serialize)]
struct Summary {
substituters: Vec<String>,
probed: usize,
hits: usize,
misses: usize,
hit_rate: f64,
report: gen_cache_attic::CacheReport,
}
let summary = Summary {
substituters: cfg.cache.substituters.clone(),
probed: report.outcomes.len(),
hits: report.hit_count(),
misses: report.miss_count(),
hit_rate: report.hit_rate(),
report,
};
emit(&summary, cli.format)
}
Cmd::Adapters => {
#[derive(serde::Serialize)]
struct AdapterRow<'a> {
marker: &'a str,
adapter: &'a str,
}
let rows: Vec<AdapterRow> = cfg
.workspace
.adapter_routing
.iter()
.map(|(k, v)| AdapterRow {
marker: k.as_str(),
adapter: v.as_str(),
})
.collect();
emit(&rows, cli.format)
}
Cmd::Scaffold { shape, name, dir, owner } => {
scaffold_repo(*shape, name, dir.as_deref(), owner)
.map_err(|e| CliError::Other(format!("scaffold: {e}")))
}
Cmd::ScaffoldAdapter { name, manifest, dir } => {
scaffold_adapter(name, manifest, dir.as_deref())
.map_err(|e| CliError::Other(format!("scaffold-adapter: {e}")))?;
println!(
"✓ gen-{name} scaffolded.\n Next: add `\"crates/gen-{name}\",` to the gen workspace members in `Cargo.toml`, fill in adapter::Adapter::build, then `cargo build -p gen-{name}`."
);
Ok(())
}
Cmd::Kanshou(sub) => kanshou_dispatch(sub)
.map_err(|e| CliError::Other(format!("kanshou: {e}"))),
Cmd::Dispatchers { ecosystem, from_catalog } => {
if *from_catalog {
#[derive(serde::Serialize)]
struct CatalogEntry<'a> {
label: &'a str,
variant_count: usize,
variant_kinds: Vec<&'static str>,
variant_fields: Vec<(&'static str, Vec<&'static str>)>,
}
let mut out: Vec<CatalogEntry> = gen_platform::catalog_registered()
.into_iter()
.map(|e| CatalogEntry {
label: e.label,
variant_count: (e.variant_count)(),
variant_kinds: (e.variant_kinds)(),
variant_fields: (e.variant_fields)(),
})
.collect();
out.sort_by_key(|e| e.label);
return emit(&out, cli.format);
}
#[derive(serde::Serialize)]
struct EcoDispatcher<'a> {
ecosystem: &'a str,
variant_count: usize,
variants: Vec<gen_types::DispatcherVariant>,
}
let filter = ecosystem.as_deref();
let mut out: Vec<EcoDispatcher> = gen_types::registered_adapters()
.iter()
.filter(|a| filter.map(|f| a.name() == f).unwrap_or(true))
.map(|a| {
let variants = a.dispatcher_reflection();
EcoDispatcher {
ecosystem: a.name(),
variant_count: variants.len(),
variants,
}
})
.collect();
out.sort_by_key(|e| e.ecosystem);
emit(&out, cli.format)
}
Cmd::DispatchersSkeleton { label } => {
let entry = gen_platform::catalog_by_label(label).ok_or_else(|| {
CliError::Other(format!(
"no dispatcher registered with label '{label}'. Run `gen dispatchers --from-catalog` to list available labels."
))
})?;
print!("{}", gen_platform::to_helpers_skeleton(entry));
Ok(())
}
Cmd::Quirks { adapter } => {
#[derive(serde::Serialize)]
struct AdapterQuirks<'a> {
adapter: &'a str,
entries: Vec<gen_types::AdapterQuirkEntry>,
}
let filter = adapter.as_deref();
let out: Vec<AdapterQuirks> = gen_types::registered_adapters()
.iter()
.filter(|a| filter.map(|f| a.name() == f).unwrap_or(true))
.map(|a| AdapterQuirks {
adapter: a.name(),
entries: a.quirks_registry(),
})
.collect();
emit(&out, cli.format)
}
}
}
fn scaffold_repo(
shape: ScaffoldShape,
name: &str,
dir: Option<&std::path::Path>,
owner: &str,
) -> std::io::Result<()> {
use std::fs;
use std::path::PathBuf;
let target = match dir {
Some(p) => p.to_path_buf(),
None => PathBuf::from(name),
};
fs::create_dir_all(&target)?;
fs::create_dir_all(target.join("src"))?;
fs::create_dir_all(target.join(".github").join("workflows"))?;
let flake_nix = format!(
r#"{{
description = "{name} — pleme-io {shape}";
inputs.substrate.url = "github:pleme-io/substrate";
outputs = {{ substrate, ... }}: substrate.rust.{shape} {{ src = ./.; }};
}}
"#,
name = name,
shape = shape.as_str(),
);
fs::write(target.join("flake.nix"), flake_nix)?;
let cargo_toml = match shape {
ScaffoldShape::Library => format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "{name} — pleme-io library"
repository = "https://github.com/{owner}/{name}"
[lib]
name = "{lib_name}"
"#,
name = name,
owner = owner,
lib_name = name.replace('-', "_"),
),
_ => format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "{name} — pleme-io {shape}"
repository = "https://github.com/{owner}/{name}"
[[bin]]
name = "{name}"
path = "src/main.rs"
"#,
name = name,
owner = owner,
shape = shape.as_str(),
),
};
fs::write(target.join("Cargo.toml"), cargo_toml)?;
let (src_path, src_body) = match shape {
ScaffoldShape::Library => (
"src/lib.rs",
format!(
"//! {name} — pleme-io library crate.\n\n#![deny(missing_docs)]\n\n/// Greeting helper.\npub fn hello() -> &'static str {{ \"hello from {name}\" }}\n",
name = name,
),
),
_ => (
"src/main.rs",
format!(
"//! {name} — pleme-io {shape}.\n\nfn main() {{\n println!(\"hello from {name}\");\n}}\n",
name = name,
shape = shape.as_str(),
),
),
};
fs::write(target.join(src_path), src_body)?;
let gitignore = "/target\n/result\n.direnv/\n.envrc.cache\n";
fs::write(target.join(".gitignore"), gitignore)?;
let workflow = r#"name: auto-release
on:
push:
branches: [main]
workflow_dispatch:
inputs:
bump-type:
description: "patch | minor | major"
required: false
default: patch
jobs:
release:
uses: pleme-io/substrate/.github/workflows/auto-release.yml@main
with:
bump-type: ${{ inputs.bump-type || 'patch' }}
secrets: inherit
"#;
fs::write(
target.join(".github").join("workflows").join("auto-release.yml"),
workflow,
)?;
eprintln!("gen scaffold: wrote {} files under {}", 5, target.display());
Ok(())
}
fn resolve_root(arg: &Option<PathBuf>, cfg: &GenConfig) -> PathBuf {
if let Some(p) = arg {
return p.clone();
}
if !cfg.workspace.root.is_empty() {
return PathBuf::from(&cfg.workspace.root);
}
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn emit<T: serde::Serialize>(value: &T, format: OutputFormat) -> Result<(), CliError> {
let text = match format {
OutputFormat::Json => serde_json::to_string_pretty(value).map_err(CliError::Json)?,
OutputFormat::Yaml => serde_yaml::to_string(value).map_err(CliError::Yaml)?,
};
println!("{text}");
Ok(())
}
#[derive(Debug, thiserror::Error)]
enum CliError {
#[error(transparent)]
Cargo(#[from] gen_cargo::CargoError),
#[error(transparent)]
Npm(#[from] gen_npm::NpmError),
#[error(transparent)]
Bundler(#[from] gen_bundler::BundlerError),
#[error(transparent)]
Gomod(#[from] gen_gomod::GomodError),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Yaml(#[from] serde_yaml::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("no adapter found for workspace at {0} — checked: {1}")]
NoAdapter(std::path::PathBuf, String),
#[error("cargo workspace at {0} renders via Nix; npm/bundler renderers ship in M0.5b")]
RenderNotImplementedForAdapter(String),
#[error("{0}")]
Other(String),
}
fn enrich_manifest_with_build_spec(
root: &std::path::Path,
manifest: &mut gen_types::Manifest,
) {
let path = root.join("Cargo.build-spec.json");
let Ok(text) = std::fs::read_to_string(&path) else {
return;
};
let Ok(spec) = serde_json::from_str::<serde_json::Value>(&text) else {
return;
};
let Some(crates_obj) = spec.get("crates").and_then(|v| v.as_object()) else {
return;
};
let links_by_key: std::collections::HashMap<String, String> = crates_obj
.iter()
.filter_map(|(k, v)| {
let links = v.get("links").and_then(|l| l.as_str())?;
let alias = match (
v.get("name").and_then(|n| n.as_str()),
v.get("version").and_then(|n| n.as_str()),
) {
(Some(n), Some(ver)) => Some(format!("{n}-{ver}")),
_ => None,
};
Some((k.clone(), alias, links.to_string()))
})
.flat_map(|(k, alias, links)| {
let mut entries = vec![(k, links.clone())];
if let Some(a) = alias {
entries.push((a, links));
}
entries
})
.collect();
if links_by_key.is_empty() {
return;
}
let lockfile = match &mut manifest.lockfile {
Some(l) => l,
None => return,
};
for r in lockfile.resolved.values_mut() {
let key = format!("{}-{}", r.id.name.as_str(), r.id.version);
if let Some(links) = links_by_key.get(&key) {
r.links = Some(links.clone());
}
}
}
fn dispatch(
root: &std::path::Path,
cfg: &GenConfig,
) -> Result<gen_types::Manifest, CliError> {
let adapter = pick_adapter(root, cfg)?;
match adapter.as_str() {
"cargo" => Ok(gen_cargo::parse(root)?),
"npm" => Ok(gen_npm::parse(root)?),
"bundler" => Ok(gen_bundler::parse(root)?),
other => Err(CliError::NoAdapter(
root.to_path_buf(),
format!("force_adapter={other} — not yet implemented"),
)),
}
}
fn pick_adapter(
root: &std::path::Path,
cfg: &GenConfig,
) -> Result<String, CliError> {
if let Some(force) = &cfg.workspace.force_adapter {
return Ok(force.clone());
}
let mut checked: Vec<String> = Vec::new();
for (marker, adapter) in &cfg.workspace.adapter_routing {
checked.push(marker.clone());
if root.join(marker).exists() {
return Ok(adapter.clone());
}
}
Err(CliError::NoAdapter(root.to_path_buf(), checked.join(", ")))
}
fn _resolve_root_lint_silencer(p: &Path) -> PathBuf {
p.to_path_buf()
}
fn scaffold_adapter(
name: &str,
manifest: &str,
dir: Option<&Path>,
) -> Result<(), std::io::Error> {
use std::fs;
let crate_dir = dir
.map(|p| p.to_path_buf())
.unwrap_or_else(|| Path::new("crates").join(format!("gen-{name}")));
fs::create_dir_all(crate_dir.join("src"))?;
let pascal = pascalize(name);
let cargo_toml = format!(
r#"[package]
name = "gen-{name}"
description = "gen — {name} adapter. Parses {manifest} + lockfile into a typed gen_types::Spec emission. One of N adapters that share the typed core via Spec/QuirkRegistry/Invariants traits."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
[lib]
name = "gen_{snake}"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
gen-types = {{ workspace = true }}
gen-macros = {{ path = "../gen-macros" }}
serde = {{ workspace = true }}
serde_json = {{ workspace = true }}
indexmap = {{ workspace = true }}
thiserror = {{ workspace = true }}
inventory = {{ workspace = true }}
[dev-dependencies]
indexmap = {{ workspace = true }}
"#,
snake = name.replace('-', "_"),
);
fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?;
let lib_rs = format!(
r#"//! `gen-{name}` — {name} adapter for the gen ecosystem.
//!
//! Parses `{manifest}` into a typed `BuildSpec` and emits it as
//! Cargo-equivalent typed JSON. See
//! `theory/ECOSYSTEM-INTAKE.md` for the seven-artifact contract.
pub mod adapter;
pub mod build_spec;
pub mod error;
pub mod invariants;
pub mod quirks;
pub use adapter::{pascal}Adapter;
pub use error::{{Result, {pascal}Error}};
"#,
);
fs::write(crate_dir.join("src/lib.rs"), lib_rs)?;
let error_rs = format!(
r#"use thiserror::Error;
#[derive(Debug, Error)]
pub enum {pascal}Error {{
#[error("manifest not found: {{0}}")]
ManifestNotFound(std::path::PathBuf),
#[error("other: {{0}}")]
Other(String),
}}
pub type Result<T> = std::result::Result<T, {pascal}Error>;
"#,
);
fs::write(crate_dir.join("src/error.rs"), error_rs)?;
let build_spec_rs = format!(
r#"//! Typed build spec for {name}. Implements `gen_types::Spec`
//! via `#[derive(SpecShape)]`.
use indexmap::IndexMap;
use serde::{{Deserialize, Serialize}};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Serialize, Deserialize, gen_macros::SpecShape)]
#[spec(
args = "PackageArgs",
quirk = "crate::quirks::{pascal}Quirk",
args_field = "args",
root_field = "root_package",
members_field = "workspace_members",
crates_field = "packages"
)]
pub struct BuildSpec {{
pub version: u32,
pub packages: IndexMap<String, PackageSpec>,
pub root_package: String,
pub workspace_members: Vec<String>,
}}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PackageSpec {{
pub name: String,
pub version: String,
pub args: PackageArgs,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub quirks: Vec<crate::quirks::{pascal}Quirk>,
}}
/// Pre-shaped builder args for one package. Substrate spreads this
/// verbatim into the ecosystem's nixpkgs builder. Adapter authors
/// fill in fields matching `buildXxxPackage`'s mkArgs signature.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct PackageArgs {{
// TODO: add ecosystem-specific fields here.
}}
"#,
);
fs::write(crate_dir.join("src/build_spec.rs"), build_spec_rs)?;
let quirks_rs = format!(
r#"//! Typed quirk registry for {name}. Implements
//! `gen_types::QuirkRegistry` via `#[derive(QuirkRegistry)]`.
//!
//! Each registered entry names an upstream {name} package that needs
//! a known-good build-time workaround. The substrate consumer's
//! `{name}-quirk-apply.nix` dispatches mechanically on the variant
//! tags. Adding a new entry: append to `registry()` below.
use serde::{{Deserialize, Serialize}};
/// Typed quirks for known third-party upstream {name} packages.
/// Add variants as needed; remember to mirror with a Nix dispatch
/// arm in `substrate/lib/build/{name}/quirk-apply.nix`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum {pascal}Quirk {{
// TODO: add ecosystem-specific quirk variants. Example shape:
// ForceFlag {{ flag: String }},
}}
pub fn registry() -> Vec<(&'static str, Vec<{pascal}Quirk>)> {{
// Hand-curated list of upstream-package quirks. Empty by
// default; populate as the adapter encounters real bugs.
Vec::new()
}}
#[derive(gen_macros::QuirkRegistry)]
#[quirks(enum_name = "{pascal}Quirk", registry_fn = "crate::quirks::registry")]
pub struct {pascal}Quirks;
"#,
);
fs::write(crate_dir.join("src/quirks.rs"), quirks_rs)?;
let invariants_rs = format!(
r#"//! Invariants over the {name} `BuildSpec`. Implements
//! `gen_types::Invariants` so cse-lint + gen confirm can call into
//! the adapter uniformly.
use serde::{{Deserialize, Serialize}};
use crate::build_spec::BuildSpec;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "rule", rename_all = "kebab-case")]
pub enum Violation {{
StaleSchemaVersion {{ found: u32, expected: u32 }},
// TODO: ecosystem-specific violations.
}}
pub fn check(spec: &BuildSpec) -> Vec<Violation> {{
let mut out = Vec::new();
if spec.version < crate::build_spec::SCHEMA_VERSION {{
out.push(Violation::StaleSchemaVersion {{
found: spec.version,
expected: crate::build_spec::SCHEMA_VERSION,
}});
}}
out
}}
pub struct {pascal}Invariants;
impl gen_types::Invariants for {pascal}Invariants {{
type Spec = BuildSpec;
type Violation = Violation;
fn check(spec: &Self::Spec) -> Vec<Self::Violation> {{
check(spec)
}}
}}
"#,
);
fs::write(crate_dir.join("src/invariants.rs"), invariants_rs)?;
let adapter_rs = format!(
r#"//! `{pascal}Adapter` — gen-{name}'s implementation of the canonical
//! `gen_types::Adapter` trait. Stub-level today; every verb
//! returns `Unsupported` until the {name}-side parser lands.
use std::path::PathBuf;
use gen_types::{{
Adapter, AdapterCtx, AdapterError, AdapterResult, ConfirmReport, DiffRef, DiffReport,
LockOutcome, Plan, PlanIntent, Sbom, SbomFormat,
}};
pub struct {pascal}Adapter;
impl Adapter for {pascal}Adapter {{
fn name(&self) -> &'static str {{ "{name}" }}
fn manifest_files(&self) -> &'static [&'static str] {{ &["{manifest}"] }}
fn lock(&self, _ctx: &AdapterCtx) -> AdapterResult<LockOutcome> {{
Err(AdapterError::Unsupported("{name} lock not implemented".into()))
}}
fn build(&self, _ctx: &AdapterCtx) -> AdapterResult<gen_types::AdapterBuildSpec> {{
Err(AdapterError::Unsupported("{name} build not implemented".into()))
}}
fn plan(&self, _ctx: &AdapterCtx, _intent: &PlanIntent) -> AdapterResult<Plan> {{
Err(AdapterError::Unsupported("{name} plan not implemented".into()))
}}
fn confirm(&self, _ctx: &AdapterCtx) -> AdapterResult<ConfirmReport> {{
Err(AdapterError::Unsupported("{name} confirm not implemented".into()))
}}
fn diff(&self, _ctx: &AdapterCtx, _against: &DiffRef) -> AdapterResult<DiffReport> {{
Err(AdapterError::Unsupported("{name} diff not implemented".into()))
}}
fn sbom(&self, _ctx: &AdapterCtx, _format: SbomFormat) -> AdapterResult<Sbom> {{
Err(AdapterError::Unsupported("{name} sbom not implemented".into()))
}}
fn quirks_registry(&self) -> Vec<gen_types::AdapterQuirkEntry> {{
use gen_types::QuirkRegistry;
<crate::quirks::{pascal}Quirks as QuirkRegistry>::registry()
.into_iter()
.map(|(p, qs)| gen_types::AdapterQuirkEntry {{
package: p.to_string(),
quirks: qs.into_iter().filter_map(|q| serde_json::to_value(&q).ok()).collect(),
}})
.collect()
}}
}}
pub fn ctx_for(workspace_root: PathBuf) -> AdapterCtx {{
AdapterCtx {{ workspace_root, target: None }}
}}
// Distributed-slice registration. gen-cli discovers this adapter
// at link time via the inventory iter — no per-adapter edit to
// gen-cli when this crate ships.
inventory::submit! {{
gen_types::AdapterRegistration {{
make: || Box::new({pascal}Adapter),
name: "{name}",
}}
}}
"#,
);
fs::write(crate_dir.join("src/adapter.rs"), adapter_rs)?;
let tests_dir = crate_dir.join("tests");
fs::create_dir_all(&tests_dir)?;
let trait_surface_test = format!(
r#"//! Trait-surface smoke tests for the scaffolded gen-{name} adapter.
//! Auto-emitted by `gen scaffold-adapter` — the four universal trait
//! surfaces (Adapter / Spec / QuirkRegistry / Invariants) must compile
//! + behave consistently from the day the crate is scaffolded.
use gen_{snake}::adapter::{pascal}Adapter;
use gen_{snake}::invariants::{pascal}Invariants;
use gen_{snake}::quirks::{pascal}Quirks;
use gen_types::{{Adapter, Invariants, QuirkRegistry}};
#[test]
fn adapter_name_matches_ecosystem() {{
let a = {pascal}Adapter;
assert_eq!(a.name(), "{name}");
assert_eq!(a.manifest_files(), &["{manifest}"]);
}}
#[test]
fn empty_quirk_registry_is_callable() {{
let names = <{pascal}Quirks as QuirkRegistry>::registered_names();
assert!(names.is_empty());
let q = <{pascal}Quirks as QuirkRegistry>::for_package("anything");
assert!(q.is_empty());
}}
#[test]
fn adapter_exposes_quirks_via_default_envelope() {{
let a = {pascal}Adapter;
let entries = a.quirks_registry();
assert!(entries.is_empty());
}}
#[test]
fn invariants_run_clean_against_minimal_spec() {{
use gen_{snake}::build_spec::BuildSpec;
use indexmap::IndexMap;
let spec = BuildSpec {{
version: gen_{snake}::build_spec::SCHEMA_VERSION,
packages: IndexMap::new(),
root_package: String::new(),
workspace_members: vec![],
}};
let violations = <{pascal}Invariants as Invariants>::check(&spec);
assert!(violations.is_empty(), "minimal spec violated: {{violations:?}}");
}}
"#,
snake = name.replace('-', "_"),
);
fs::write(tests_dir.join("trait_surface.rs"), trait_surface_test)?;
Ok(())
}
fn commit_spec_if_drifted(root: &std::path::Path) -> Result<(), CliError> {
use std::process::Command;
let git = |args: &[&str]| -> Result<std::process::Output, CliError> {
Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.map_err(|e| CliError::Other(format!("git {}: {e}", args.join(" "))))
};
if !root.join("Cargo.gen.lock").exists() {
eprintln!(
"gen build --commit: no Cargo.gen.lock emitted (single-target?) — \
skipping delta-only commit"
);
return Ok(());
}
if git(&["ls-files", "--error-unmatch", "Cargo.build-spec.json"])?
.status
.success()
{
git(&["rm", "--quiet", "--cached", "--", "Cargo.build-spec.json"])?;
}
ensure_gitignored(root, "Cargo.build-spec.json")?;
git(&["add", "--", "Cargo.gen.lock", ".gitignore"])?;
if git(&["diff", "--cached", "--quiet"])?.status.success() {
eprintln!("gen build --commit: delta unchanged; no commit");
return Ok(());
}
let commit = git(&[
"-c",
"user.name=gen-spec-bot",
"-c",
"user.email=gen-spec-bot@pleme-io.invalid",
"commit",
"--quiet",
"-m",
"gen: regen Cargo.gen.lock delta — delta-only (build-spec retired)",
])?;
if !commit.status.success() {
return Err(CliError::Other(format!(
"git commit failed: {}",
String::from_utf8_lossy(&commit.stderr).trim()
)));
}
eprintln!("gen build --commit: delta committed, build-spec retired (gen-spec-bot)");
Ok(())
}
fn ensure_gitignored(root: &std::path::Path, entry: &str) -> Result<(), CliError> {
let gi = root.join(".gitignore");
let cur = std::fs::read_to_string(&gi).unwrap_or_default();
if cur.lines().any(|l| l.trim() == entry) {
return Ok(());
}
let mut new = cur;
if !new.is_empty() && !new.ends_with('\n') {
new.push('\n');
}
new.push_str(entry);
new.push('\n');
std::fs::write(&gi, new).map_err(|e| CliError::Other(format!("write .gitignore: {e}")))
}
fn pascalize(s: &str) -> String {
let mut out = String::new();
let mut upper_next = true;
for c in s.chars() {
if c == '-' || c == '_' {
upper_next = true;
} else if upper_next {
out.push(c.to_ascii_uppercase());
upper_next = false;
} else {
out.push(c);
}
}
out
}
fn kanshou_dispatch(sub: &KanshouCmd) -> Result<(), Box<dyn std::error::Error>> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(async move { kanshou_run(sub).await })
}
async fn kanshou_run(sub: &KanshouCmd) -> Result<(), Box<dyn std::error::Error>> {
match sub {
KanshouCmd::List { app } => {
let mut instances = kanshou::discover(app.as_deref());
instances.sort_by(|a, b| a.app_name.cmp(&b.app_name).then(a.pid.cmp(&b.pid)));
#[derive(serde::Serialize)]
struct Row<'a> {
app: &'a str,
pid: u32,
socket: String,
}
let rows: Vec<Row> = instances
.iter()
.map(|i| Row {
app: &i.app_name,
pid: i.pid,
socket: i.socket_path.display().to_string(),
})
.collect();
println!("{}", serde_json::to_string_pretty(&rows)?);
Ok(())
}
KanshouCmd::Query { app, path, pid } => {
let target = pick_target(app, *pid)?;
let mut client = kanshou::Client::connect(&target.socket_path).await?;
let segments: Vec<String> = path.split('.').map(str::to_string).collect();
let result = client
.query(&kanshou::Query::field(segments))
.await?;
match result {
Ok(value) => println!("{}", serde_json::to_string_pretty(&value)?),
Err(e) => {
eprintln!(
"kanshou: query error from {} pid={}: {}",
target.app_name, target.pid, e
);
std::process::exit(1);
}
}
Ok(())
}
KanshouCmd::Schema { app, pid } => {
let target = pick_target(app, *pid)?;
let mut client = kanshou::Client::connect(&target.socket_path).await?;
let empty: Vec<String> = vec![];
let probe = client
.query(&kanshou::Query::field(empty))
.await?;
#[derive(serde::Serialize)]
struct SchemaOut<'a> {
app: &'a str,
pid: u32,
schema_probe: kanshou::QueryResult,
}
let out = SchemaOut {
app: &target.app_name,
pid: target.pid,
schema_probe: probe,
};
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(())
}
}
}
fn pick_target(
app: &str,
pid: Option<u32>,
) -> Result<kanshou::DiscoveredInstance, Box<dyn std::error::Error>> {
let mut instances = kanshou::discover(Some(app));
if let Some(want) = pid {
instances.retain(|i| i.pid == want);
}
instances.sort_by_key(|i| std::cmp::Reverse(i.pid));
instances.into_iter().next().ok_or_else(|| {
format!(
"no live kanshou consumer for app={app}{}; check `gen kanshou list`",
pid.map(|p| format!(" pid={p}")).unwrap_or_default()
)
.into()
})
}