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},
k8s_types::K8sTypesFetcher,
openapi::OpenAPIParser,
Parser as SchemaParser,
};
mod vendor;
#[derive(Parser)]
#[command(name = "amalgam")]
#[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: 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,
},
}
#[derive(Subcommand)]
enum ImportSource {
Crd {
#[arg(short, long)]
file: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
Url {
#[arg(short, long)]
url: String,
#[arg(short, long)]
output: PathBuf,
#[arg(short, long)]
package: Option<String>,
},
OpenApi {
#[arg(short, long)]
file: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
K8sCore {
#[arg(short, long, default_value = "v1.31.0")]
version: String,
#[arg(short, long, default_value = "k8s_io")]
output: PathBuf,
#[arg(short, long)]
types: Vec<String>,
},
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 {
Commands::Import { source } => handle_import(source).await,
Commands::Generate {
input,
output,
target,
} => handle_generate(input, output, &target),
Commands::Convert {
input,
from,
output,
to,
} => handle_convert(input, &from, output, &to),
Commands::Vendor { command } => {
let project_root = std::env::current_dir()?;
let manager = vendor::VendorManager::new(project_root);
manager.execute(command).await
}
}
}
async fn handle_import(source: ImportSource) -> Result<()> {
match source {
ImportSource::Url {
url,
output,
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)?;
}
}
}
}
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());
}
}
Ok(())
}
ImportSource::Crd { file, output } => {
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 = 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: _ } => {
handle_k8s_core_import(&version, &output).await?;
Ok(())
}
ImportSource::K8s { .. } => {
anyhow::bail!("Kubernetes import not yet implemented. Build with --features kubernetes to enable.")
}
}
}
fn apply_type_replacements(ty: &mut amalgam_core::types::Type, replacements: &std::collections::HashMap<String, String>) {
use amalgam_core::types::Type;
match ty {
Type::Reference(name) => {
if let Some(replacement) = replacements.get(name) {
*name = replacement.clone();
}
}
Type::Array(inner) => apply_type_replacements(inner, replacements),
Type::Optional(inner) => apply_type_replacements(inner, replacements),
Type::Map { value, .. } => apply_type_replacements(value, replacements),
Type::Record { fields, .. } => {
for field in fields.values_mut() {
apply_type_replacements(&mut field.ty, replacements);
}
}
Type::Union(types) => {
for t in types {
apply_type_replacements(t, replacements);
}
}
Type::TaggedUnion { variants, .. } => {
for t in variants.values_mut() {
apply_type_replacements(t, replacements);
}
}
Type::Contract { base, .. } => apply_type_replacements(base, replacements),
_ => {}
}
}
fn collect_type_references(ty: &amalgam_core::types::Type, refs: &mut std::collections::HashSet<String>) {
use amalgam_core::types::Type;
match ty {
Type::Reference(name) => {
refs.insert(name.clone());
}
Type::Array(inner) => collect_type_references(inner, refs),
Type::Optional(inner) => collect_type_references(inner, refs),
Type::Map { value, .. } => collect_type_references(value, refs),
Type::Record { fields, .. } => {
for field in fields.values() {
collect_type_references(&field.ty, refs);
}
}
Type::Union(types) => {
for t in types {
collect_type_references(t, refs);
}
}
Type::TaggedUnion { variants, .. } => {
for t in variants.values() {
collect_type_references(t, refs);
}
}
Type::Contract { base, .. } => collect_type_references(base, refs),
_ => {}
}
}
async fn handle_k8s_core_import(version: &str, output_dir: &PathBuf) -> Result<()> {
info!("Fetching Kubernetes {} core types...", version);
let fetcher = K8sTypesFetcher::new();
let openapi = fetcher.fetch_k8s_openapi(version).await?;
let types = fetcher.extract_core_types(&openapi)?;
let total_types = types.len();
info!("Extracted {} core types", total_types);
let mut types_by_version: std::collections::HashMap<String, Vec<(amalgam_parser::imports::TypeReference, amalgam_core::ir::TypeDefinition)>> = std::collections::HashMap::new();
for (type_ref, type_def) in types {
types_by_version
.entry(type_ref.version.clone())
.or_default()
.push((type_ref, type_def));
}
for (version, version_types) in &types_by_version {
let version_dir = output_dir.join(version);
fs::create_dir_all(&version_dir)?;
let mut mod_imports = Vec::new();
for (type_ref, type_def) in version_types {
let mut imports = Vec::new();
let mut type_replacements = std::collections::HashMap::new();
let mut referenced_types = std::collections::HashSet::new();
collect_type_references(&type_def.ty, &mut referenced_types);
for referenced in &referenced_types {
if !referenced.contains('.') && referenced != &type_ref.kind {
if version_types.iter().any(|(tr, _)| tr.kind == *referenced) {
let alias = referenced.to_lowercase();
imports.push(amalgam_core::ir::Import {
path: format!("./{}.ncl", alias),
alias: Some(alias.clone()),
items: vec![referenced.clone()],
});
type_replacements.insert(referenced.clone(), format!("{}.{}", alias, referenced));
}
}
}
let mut updated_type_def = type_def.clone();
apply_type_replacements(&mut updated_type_def.ty, &type_replacements);
let module = amalgam_core::ir::Module {
name: format!("k8s.io.{}.{}", type_ref.version, type_ref.kind.to_lowercase()),
imports,
types: vec![updated_type_def],
constants: vec![],
metadata: Default::default(),
};
let mut ir = amalgam_core::IR::new();
ir.add_module(module);
let mut codegen = NickelCodegen::new();
let code = codegen.generate(&ir)?;
let filename = format!("{}.ncl", type_ref.kind.to_lowercase());
let file_path = version_dir.join(&filename);
fs::write(&file_path, code)?;
info!("Generated {:?}", file_path);
mod_imports.push(format!(" {} = (import \"./{}\").{},",
type_ref.kind,
filename,
type_ref.kind
));
}
let mod_content = format!(
"# Kubernetes core {} types\n{{\n{}\n}}\n",
version,
mod_imports.join("\n")
);
fs::write(version_dir.join("mod.ncl"), mod_content)?;
}
let mut version_imports = Vec::new();
for version in types_by_version.keys() {
version_imports.push(format!(" {} = import \"./{}/mod.ncl\",", version, version));
}
let root_mod_content = format!(
"# Kubernetes core types\n{{\n{}\n}}\n",
version_imports.join("\n")
);
fs::write(output_dir.join("mod.ncl"), root_mod_content)?;
info!("Successfully generated {} k8s core types in {:?}", total_types, output_dir);
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(())
}