use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::fs;
use std::path::PathBuf;
use tracing::info;
use amalgam_codegen::{Codegen, nickel::NickelCodegen, go::GoCodegen};
use amalgam_parser::{
Parser as SchemaParser,
crd::{CRD, CRDParser},
openapi::OpenAPIParser,
};
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>,
},
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('/').last()
.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().map_or(false, |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_parser::imports::ImportResolver;
use amalgam_core::ir::Import;
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().map_or(false, |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_parser::imports::ImportResolver;
use amalgam_core::ir::Import;
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::K8s { .. } => {
anyhow::bail!("Kubernetes import not yet implemented. Build with --features kubernetes to enable.")
}
}
}
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().map_or(false, |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().map_or(false, |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(())
}