use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::fs;
use std::path::PathBuf;
use tracing::info;
use amalgam_codegen::{go::GoCodegen, nickel::NickelCodegen, Codegen};
use amalgam_parser::{
crd::{CRDParser, CRD},
openapi::OpenAPIParser,
Parser as SchemaParser,
};
mod manifest;
mod validate;
mod vendor;
#[derive(Parser)]
#[command(name = "amalgam")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Generate type-safe Nickel configurations from any schema source", long_about = None)]
struct Cli {
#[arg(short, long)]
verbose: bool,
#[arg(short, long)]
debug: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Import {
#[command(subcommand)]
source: ImportSource,
},
Generate {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(short, long, default_value = "nickel")]
target: String,
},
Convert {
#[arg(short, long)]
input: PathBuf,
#[arg(short = 'f', long)]
from: String,
#[arg(short, long)]
output: PathBuf,
#[arg(short, long)]
to: String,
},
Vendor {
#[command(subcommand)]
command: vendor::VendorCommand,
},
Validate {
#[arg(short, long)]
path: PathBuf,
#[arg(long)]
package_path: Option<PathBuf>,
#[arg(short, long)]
verbose: bool,
},
GenerateFromManifest {
#[arg(short, long, default_value = ".amalgam-manifest.toml")]
manifest: PathBuf,
#[arg(short, long)]
packages: Vec<String>,
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand)]
enum ImportSource {
Crd {
#[arg(short, long)]
file: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
package_mode: bool,
},
Url {
#[arg(short, long)]
url: String,
#[arg(short, long)]
output: PathBuf,
#[arg(short, long)]
package: Option<String>,
#[arg(long)]
nickel_package: bool,
},
OpenApi {
#[arg(short, long)]
file: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
K8sCore {
#[arg(short, long, default_value = "v1.33.4")]
version: String,
#[arg(short, long, default_value = "k8s_io")]
output: PathBuf,
#[arg(short, long)]
types: Vec<String>,
#[arg(long)]
nickel_package: bool,
},
K8s {
#[arg(short, long)]
context: Option<String>,
#[arg(short, long)]
group: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let level = if cli.debug {
tracing::Level::TRACE
} else if cli.verbose {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
};
tracing_subscriber::fmt()
.with_max_level(level)
.with_target(cli.debug) .init();
match cli.command {
Some(Commands::Import { source }) => handle_import(source).await,
Some(Commands::Generate {
input,
output,
target,
}) => handle_generate(input, output, &target),
Some(Commands::Convert {
input,
from,
output,
to,
}) => handle_convert(input, &from, output, &to),
Some(Commands::Vendor { command }) => {
let project_root = std::env::current_dir()?;
let manager = vendor::VendorManager::new(project_root);
manager.execute(command).await
}
Some(Commands::Validate {
path,
package_path,
verbose: _,
}) => validate::run_validation_with_package_path(&path, package_path.as_deref()),
Some(Commands::GenerateFromManifest {
manifest,
packages,
dry_run,
}) => handle_manifest_generation(manifest, packages, dry_run).await,
None => {
use clap::CommandFactory;
Cli::command().print_help()?;
Ok(())
}
}
}
async fn handle_import(source: ImportSource) -> Result<()> {
match source {
ImportSource::Url {
url,
output,
package,
nickel_package,
} => {
info!("Fetching CRDs from URL: {}", url);
let package_name = package.unwrap_or_else(|| {
url.split('/')
.next_back()
.unwrap_or("generated")
.trim_end_matches(".yaml")
.trim_end_matches(".yml")
.to_string()
});
let fetcher = amalgam_parser::fetch::CRDFetcher::new()?;
let crds = fetcher.fetch_from_url(&url).await?;
fetcher.finish();
info!("Found {} CRDs", crds.len());
let mut generator = amalgam_parser::package::PackageGenerator::new(
package_name.clone(),
output.clone(),
);
generator.add_crds(crds);
let package_structure = generator.generate_package()?;
fs::create_dir_all(&output)?;
let main_module = package_structure.generate_main_module();
fs::write(output.join("mod.ncl"), main_module)?;
for group in package_structure.groups() {
let group_dir = output.join(&group);
fs::create_dir_all(&group_dir)?;
if let Some(group_mod) = package_structure.generate_group_module(&group) {
fs::write(group_dir.join("mod.ncl"), group_mod)?;
}
for version in package_structure.versions(&group) {
let version_dir = group_dir.join(&version);
fs::create_dir_all(&version_dir)?;
if let Some(version_mod) =
package_structure.generate_version_module(&group, &version)
{
fs::write(version_dir.join("mod.ncl"), version_mod)?;
}
for kind in package_structure.kinds(&group, &version) {
if let Some(kind_content) =
package_structure.generate_kind_file(&group, &version, &kind)
{
fs::write(version_dir.join(format!("{}.ncl", kind)), kind_content)?;
}
}
}
}
if nickel_package {
info!("Generating Nickel package manifest (experimental)");
let manifest = package_structure.generate_nickel_manifest(None);
fs::write(output.join("Nickel-pkg.ncl"), manifest)?;
info!("✓ Generated Nickel-pkg.ncl");
}
info!("Generated package '{}' in {:?}", package_name, output);
info!("Package structure:");
for group in package_structure.groups() {
info!(" {}/", group);
for version in package_structure.versions(&group) {
let kinds = package_structure.kinds(&group, &version);
info!(" {}/: {} types", version, kinds.len());
}
}
if nickel_package {
info!(" Nickel-pkg.ncl (package manifest)");
}
Ok(())
}
ImportSource::Crd {
file,
output,
package_mode,
} => {
info!("Importing CRD from {:?}", file);
let content = fs::read_to_string(&file)
.with_context(|| format!("Failed to read CRD file: {:?}", file))?;
let crd: CRD = if file.extension().is_some_and(|ext| ext == "json") {
serde_json::from_str(&content)?
} else {
serde_yaml::from_str(&content)?
};
let parser = CRDParser::new();
let mut ir = parser.parse(crd.clone())?;
use amalgam_core::ir::Import;
use amalgam_parser::imports::ImportResolver;
for module in &mut ir.modules {
let mut import_resolver = ImportResolver::new();
for type_def in &module.types {
import_resolver.analyze_type(&type_def.ty);
}
for type_ref in import_resolver.references() {
let group = &crd.spec.group;
let version = crd
.spec
.versions
.first()
.map(|v| v.name.as_str())
.unwrap_or("v1");
let import_path = type_ref.import_path(group, version);
let alias = Some(type_ref.module_alias());
tracing::debug!(
"Adding import for {:?} -> path: {}, alias: {:?}",
type_ref,
import_path,
alias
);
module.imports.push(Import {
path: import_path,
alias,
items: vec![], });
}
tracing::debug!(
"Module {} has {} imports",
module.name,
module.imports.len()
);
}
let mut codegen = if package_mode {
use amalgam_codegen::package_mode::PackageMode;
use std::path::PathBuf;
let manifest_path = if PathBuf::from(".amalgam-manifest.toml").exists() {
PathBuf::from(".amalgam-manifest.toml")
} else if PathBuf::from("amalgam-manifest.toml").exists() {
PathBuf::from("amalgam-manifest.toml")
} else {
PathBuf::from("does-not-exist")
};
let manifest = if manifest_path.exists() {
Some(&manifest_path)
} else {
None
};
let mut package_mode = PackageMode::new_with_analyzer(manifest);
let package_name = crd.spec.group.split('.').next().unwrap_or("unknown");
let mut all_types: Vec<amalgam_core::types::Type> = Vec::new();
for module in &ir.modules {
for type_def in &module.types {
all_types.push(type_def.ty.clone());
}
}
package_mode.analyze_and_update_dependencies(&all_types, package_name);
NickelCodegen::new().with_package_mode(package_mode)
} else {
NickelCodegen::new()
};
let code = codegen.generate(&ir)?;
if let Some(output_path) = output {
fs::write(&output_path, code)
.with_context(|| format!("Failed to write output: {:?}", output_path))?;
info!("Generated Nickel code written to {:?}", output_path);
} else {
println!("{}", code);
}
Ok(())
}
ImportSource::OpenApi { file, output } => {
info!("Importing OpenAPI spec from {:?}", file);
let content = fs::read_to_string(&file)
.with_context(|| format!("Failed to read OpenAPI file: {:?}", file))?;
let spec: openapiv3::OpenAPI = if file.extension().is_some_and(|ext| ext == "json") {
serde_json::from_str(&content)?
} else {
serde_yaml::from_str(&content)?
};
let parser = OpenAPIParser::new();
let mut ir = parser.parse(spec)?;
use amalgam_core::ir::Import;
use amalgam_parser::imports::ImportResolver;
for module in &mut ir.modules {
let mut import_resolver = ImportResolver::new();
for type_def in &module.types {
import_resolver.analyze_type(&type_def.ty);
}
for type_ref in import_resolver.references() {
let group = "api"; let version = "v1";
let import_path = type_ref.import_path(group, version);
let alias = Some(type_ref.module_alias());
tracing::debug!(
"Adding import for {:?} -> path: {}, alias: {:?}",
type_ref,
import_path,
alias
);
module.imports.push(Import {
path: import_path,
alias,
items: vec![], });
}
tracing::debug!(
"Module {} has {} imports",
module.name,
module.imports.len()
);
}
let mut codegen = NickelCodegen::new();
let code = codegen.generate(&ir)?;
if let Some(output_path) = output {
fs::write(&output_path, code)
.with_context(|| format!("Failed to write output: {:?}", output_path))?;
info!("Generated Nickel code written to {:?}", output_path);
} else {
println!("{}", code);
}
Ok(())
}
ImportSource::K8sCore {
version,
output,
types: _,
nickel_package,
} => {
handle_k8s_core_import(&version, &output, nickel_package).await?;
Ok(())
}
ImportSource::K8s { .. } => {
anyhow::bail!("Kubernetes import not yet implemented. Build with --features kubernetes to enable.")
}
}
}
use amalgam::handle_k8s_core_import;
async fn handle_manifest_generation(
manifest_path: PathBuf,
packages: Vec<String>,
dry_run: bool,
) -> Result<()> {
use crate::manifest::Manifest;
info!("Loading manifest from {:?}", manifest_path);
let mut manifest = Manifest::from_file(&manifest_path)?;
if !packages.is_empty() {
manifest.packages.retain(|p| packages.contains(&p.name));
if manifest.packages.is_empty() {
anyhow::bail!("No matching packages found for: {:?}", packages);
}
}
if dry_run {
info!("Dry run mode - showing what would be generated:");
for package in &manifest.packages {
if package.enabled {
info!(" - {} -> {}", package.name, package.output);
}
}
return Ok(());
}
let report = manifest.generate_all().await?;
report.print_summary();
if !report.failed.is_empty() {
anyhow::bail!("Some packages failed to generate");
}
Ok(())
}
fn handle_generate(input: PathBuf, output: PathBuf, target: &str) -> Result<()> {
info!("Generating {} code from {:?}", target, input);
let ir_content = fs::read_to_string(&input)
.with_context(|| format!("Failed to read IR file: {:?}", input))?;
let ir: amalgam_core::IR =
serde_json::from_str(&ir_content).with_context(|| "Failed to parse IR JSON")?;
let code = match target {
"nickel" => {
let mut codegen = NickelCodegen::new();
codegen.generate(&ir)?
}
"go" => {
let mut codegen = GoCodegen::new();
codegen.generate(&ir)?
}
_ => {
anyhow::bail!("Unsupported target language: {}", target);
}
};
fs::write(&output, code).with_context(|| format!("Failed to write output: {:?}", output))?;
info!("Generated code written to {:?}", output);
Ok(())
}
fn handle_convert(input: PathBuf, from: &str, output: PathBuf, to: &str) -> Result<()> {
info!("Converting from {} to {}", from, to);
let content = fs::read_to_string(&input)
.with_context(|| format!("Failed to read input file: {:?}", input))?;
let ir = match from {
"crd" => {
let crd: CRD = if input.extension().is_some_and(|ext| ext == "json") {
serde_json::from_str(&content)?
} else {
serde_yaml::from_str(&content)?
};
CRDParser::new().parse(crd)?
}
"openapi" => {
let spec: openapiv3::OpenAPI = if input.extension().is_some_and(|ext| ext == "json") {
serde_json::from_str(&content)?
} else {
serde_yaml::from_str(&content)?
};
OpenAPIParser::new().parse(spec)?
}
_ => {
anyhow::bail!("Unsupported input format: {}", from);
}
};
let output_content = match to {
"nickel" => {
let mut codegen = NickelCodegen::new();
codegen.generate(&ir)?
}
"go" => {
let mut codegen = GoCodegen::new();
codegen.generate(&ir)?
}
"ir" => serde_json::to_string_pretty(&ir)?,
_ => {
anyhow::bail!("Unsupported output format: {}", to);
}
};
fs::write(&output, output_content)
.with_context(|| format!("Failed to write output: {:?}", output))?;
info!("Conversion complete. Output written to {:?}", output);
Ok(())
}