use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
#[derive(Debug, Deserialize, Serialize)]
pub struct Manifest {
pub config: ManifestConfig,
pub packages: Vec<PackageDefinition>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ManifestConfig {
pub output_base: PathBuf,
#[serde(default = "default_true")]
pub package_mode: bool,
pub base_package_id: String,
#[serde(default)]
pub local_package_prefix: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct PackageDefinition {
pub name: String,
#[serde(rename = "type")]
pub source_type: SourceType,
pub version: Option<String>,
pub url: Option<String>,
pub git_ref: Option<String>,
pub file: Option<PathBuf>,
pub output: String,
pub description: String,
pub keywords: Vec<String>,
#[serde(default)]
pub dependencies: HashMap<String, DependencySpec>,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum DependencySpec {
Simple(String),
Full {
version: String,
#[serde(skip_serializing_if = "Option::is_none")]
min_version: Option<String>,
},
}
#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum SourceType {
K8sCore,
Url,
Crd,
OpenApi,
}
impl std::fmt::Display for SourceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SourceType::K8sCore => write!(f, "k8s-core"),
SourceType::Url => write!(f, "url"),
SourceType::Crd => write!(f, "crd"),
SourceType::OpenApi => write!(f, "openapi"),
}
}
}
fn default_true() -> bool {
true
}
impl Manifest {
pub fn from_file(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read manifest file: {}", path.display()))?;
toml::from_str(&content)
.with_context(|| format!("Failed to parse manifest file: {}", path.display()))
}
pub async fn generate_all(&self) -> Result<GenerationReport> {
let mut report = GenerationReport::default();
fs::create_dir_all(&self.config.output_base).with_context(|| {
format!(
"Failed to create output directory: {}",
self.config.output_base.display()
)
})?;
for package in &self.packages {
if !package.enabled {
info!("Skipping disabled package: {}", package.name);
report.skipped.push(package.name.clone());
continue;
}
info!("Generating package: {}", package.name);
match self.generate_package(package).await {
Ok(output_path) => {
info!(
"✓ Successfully generated {} at {:?}",
package.name, output_path
);
report.successful.push(package.name.clone());
}
Err(e) => {
warn!("✗ Failed to generate {}: {}", package.name, e);
report.failed.push((package.name.clone(), e.to_string()));
}
}
}
Ok(report)
}
async fn generate_package(&self, package: &PackageDefinition) -> Result<PathBuf> {
use amalgam_parser::incremental::{detect_change_type, save_fingerprint, ChangeType};
let output_path = self.config.output_base.join(&package.output);
let source = self.create_fingerprint_source(package).await?;
let change_type = detect_change_type(&output_path, source.as_ref())
.map_err(|e| anyhow::anyhow!("Failed to detect changes: {}", e))?;
match change_type {
ChangeType::NoChange => {
info!("📦 {} - No changes detected, skipping", package.name);
return Ok(output_path);
}
ChangeType::MetadataOnly => {
info!(
"📦 {} - Only metadata changed, updating manifest",
package.name
);
if self.config.package_mode {
self.generate_package_manifest(package, &output_path)?;
}
save_fingerprint(&output_path, source.as_ref())
.map_err(|e| anyhow::anyhow!("Failed to save fingerprint: {}", e))?;
return Ok(output_path);
}
ChangeType::ContentChanged => {
info!("📦 {} - Content changed, regenerating", package.name);
}
ChangeType::FirstGeneration => {
info!("📦 {} - First generation", package.name);
}
}
let result = match package.source_type {
SourceType::K8sCore => self.generate_k8s_core(package, &output_path).await,
SourceType::Url => self.generate_from_url(package, &output_path).await,
SourceType::Crd => self.generate_from_crd(package, &output_path).await,
SourceType::OpenApi => self.generate_from_openapi(package, &output_path).await,
};
if result.is_ok() && self.config.package_mode {
self.generate_package_manifest(package, &output_path)?;
save_fingerprint(&output_path, source.as_ref())
.map_err(|e| anyhow::anyhow!("Failed to save fingerprint: {}", e))?;
}
result
}
async fn create_fingerprint_source(
&self,
package: &PackageDefinition,
) -> Result<Box<dyn amalgam_core::fingerprint::Fingerprintable>> {
use amalgam_parser::incremental::*;
match package.source_type {
SourceType::K8sCore => {
let version = package.version.as_deref().unwrap_or("v1.31.0");
let spec_url = format!(
"https://dl.k8s.io/{}/api/openapi-spec/swagger.json",
version
);
let source = K8sCoreSource {
version: version.to_string(),
openapi_spec: "".to_string(), spec_url,
};
Ok(Box::new(source))
}
SourceType::Url => {
let url = package
.url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("URL required for url type package"))?;
let fingerprint_url = if let Some(ref git_ref) = package.git_ref {
format!("{}@{}", url, git_ref)
} else if let Some(ref version) = package.version {
format!("{}@{}", url, version)
} else {
url.clone()
};
let source = UrlSource {
base_url: fingerprint_url.clone(),
urls: vec![fingerprint_url], contents: vec!["".to_string()], };
Ok(Box::new(source))
}
SourceType::Crd | SourceType::OpenApi => {
let file = package.file.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"File path required for {:?} type package",
package.source_type
)
})?;
let content = if std::path::Path::new(file).exists() {
std::fs::read_to_string(file).unwrap_or_default()
} else {
String::new()
};
let source = LocalFilesSource {
paths: vec![file.to_string_lossy().to_string()],
contents: vec![content],
};
Ok(Box::new(source))
}
}
}
async fn generate_k8s_core(
&self,
package: &PackageDefinition,
output: &Path,
) -> Result<PathBuf> {
use crate::handle_k8s_core_import;
let version = package.version.as_deref().unwrap_or("v1.31.0");
info!("Fetching Kubernetes {} core types...", version);
handle_k8s_core_import(version, output, true).await?;
Ok(output.to_path_buf())
}
async fn generate_from_url(
&self,
package: &PackageDefinition,
output: &Path,
) -> Result<PathBuf> {
let url = package
.url
.as_ref()
.ok_or_else(|| anyhow::anyhow!("URL required for url type package"))?;
let fetch_url = if let Some(ref git_ref) = package.git_ref {
if url.contains("/tree/") {
let parts: Vec<&str> = url.split("/tree/").collect();
if parts.len() == 2 {
let base = parts[0];
let path_parts: Vec<&str> = parts[1].split('/').collect();
if path_parts.len() > 1 {
format!("{}/tree/{}/{}", base, git_ref, path_parts[1..].join("/"))
} else {
format!("{}/tree/{}", base, git_ref)
}
} else {
url.clone()
}
} else {
format!("{}/tree/{}", url.trim_end_matches('/'), git_ref)
}
} else {
url.clone()
};
info!("Fetching CRDs from URL: {}", fetch_url);
if package.git_ref.is_some() {
info!("Using git ref: {}", package.git_ref.as_ref().unwrap());
}
use amalgam_parser::fetch::CRDFetcher;
use amalgam_parser::package::PackageGenerator;
let fetcher = CRDFetcher::new()?;
let crds = fetcher.fetch_from_url(&fetch_url).await?;
fetcher.finish();
info!("Found {} CRDs", crds.len());
let mut generator = PackageGenerator::new(package.name.clone(), output.to_path_buf());
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)?;
}
}
}
}
Ok(output.to_path_buf())
}
async fn generate_from_crd(
&self,
package: &PackageDefinition,
output: &Path,
) -> Result<PathBuf> {
let file = package
.file
.as_ref()
.ok_or_else(|| anyhow::anyhow!("File path required for crd type package"))?;
info!("Importing CRD from {:?}", file);
Ok(output.to_path_buf())
}
async fn generate_from_openapi(
&self,
package: &PackageDefinition,
output: &Path,
) -> Result<PathBuf> {
let file = package
.file
.as_ref()
.ok_or_else(|| anyhow::anyhow!("File path required for openapi type package"))?;
info!("Importing OpenAPI spec from {:?}", file);
Ok(output.to_path_buf())
}
fn generate_package_manifest(&self, package: &PackageDefinition, output: &Path) -> Result<()> {
use amalgam_codegen::package_mode::PackageMode;
use chrono::Utc;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
let manifest_path = PathBuf::from(".amalgam-manifest.toml");
let manifest = if manifest_path.exists() {
Some(&manifest_path)
} else {
None
};
let _package_mode = PackageMode::new_with_analyzer(manifest);
let package_map: HashMap<String, String> = self
.packages
.iter()
.map(|p| (p.output.clone(), p.name.clone()))
.collect();
let mut detected_deps = HashSet::new();
if output.exists() {
for entry in walkdir::WalkDir::new(output)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "ncl"))
{
if let Ok(content) = fs::read_to_string(entry.path()) {
for line in content.lines() {
for pkg_output in package_map.keys() {
let import_pattern = format!("import \"{}\"", pkg_output);
if line.contains(&import_pattern) {
detected_deps.insert(pkg_output.clone());
}
}
}
}
}
}
let deps_str = if detected_deps.is_empty() && package.dependencies.is_empty() {
"{}".to_string()
} else {
let mut dep_entries: Vec<String> = Vec::new();
for dep_output in &detected_deps {
let dep_package = self.packages.iter().find(|p| &p.output == dep_output);
let dep_entry = if let Some(dep_pkg) = dep_package {
let version = if let Some(ref constraint) =
package.dependencies.get(dep_output.as_str())
{
match constraint {
DependencySpec::Simple(v) => v.clone(),
DependencySpec::Full { version, .. } => version.clone(),
}
} else if let Some(ref dep_version) = dep_pkg.version {
dep_version
.strip_prefix('v')
.unwrap_or(dep_version)
.to_string()
} else {
"*".to_string()
};
let package_id = format!(
"{}/{}",
self.config.base_package_id.trim_end_matches('/'),
dep_pkg.name
);
format!(
" {} = 'Index {{ package = \"{}\", version = \"{}\" }}",
dep_output, package_id, version
)
} else {
let package_id = format!(
"{}/{}",
self.config.base_package_id.trim_end_matches('/'),
dep_output
);
format!(
" {} = 'Index {{ package = \"{}\", version = \"*\" }}",
dep_output, package_id
)
};
dep_entries.push(dep_entry);
}
for (dep_name, dep_spec) in &package.dependencies {
if !detected_deps.contains(dep_name.as_str()) {
let dep_package = self
.packages
.iter()
.find(|p| p.output == *dep_name || p.name == *dep_name);
let version = match dep_spec {
DependencySpec::Simple(v) => v.clone(),
DependencySpec::Full { version, .. } => version.clone(),
};
let package_id = if let Some(dep_pkg) = dep_package {
format!(
"{}/{}",
self.config.base_package_id.trim_end_matches('/'),
dep_pkg.name
)
} else {
format!(
"{}/{}",
self.config.base_package_id.trim_end_matches('/'),
dep_name
)
};
let dep_entry = format!(
" {} = 'Index {{ package = \"{}\", version = \"{}\" }}",
dep_name, package_id, version
);
dep_entries.push(dep_entry);
}
}
format!("{{\n{}\n }}", dep_entries.join(",\n"))
};
let version = package.version.as_deref().unwrap_or("0.1.0");
let clean_version = version.strip_prefix('v').unwrap_or(version);
let now = Utc::now();
let header = format!(
r#"# Amalgam Package Manifest
# Generated: {}
# Generator: amalgam v{}
# Source: {}{}
"#,
now.to_rfc3339(),
env!("CARGO_PKG_VERSION"),
package
.url
.as_deref()
.unwrap_or(&format!("{} (local)", package.source_type)),
if let Some(ref git_ref) = package.git_ref {
format!("\n# Git ref: {}", git_ref)
} else {
String::new()
}
);
let manifest_content = format!(
r#"{}{{
# Package identity
name = "{}",
version = "{}",
# Package information
description = "{}",
authors = ["amalgam"],
keywords = [{}],
license = "Apache-2.0",
# Dependencies
dependencies = {},
# Nickel version requirement
minimal_nickel_version = "1.9.0",
}} | std.package.Manifest
"#,
header,
package.name,
clean_version,
package.description,
package
.keywords
.iter()
.map(|k| format!("\"{}\"", k))
.collect::<Vec<_>>()
.join(", "),
deps_str
);
let manifest_path = output.join("Nickel-pkg.ncl");
fs::write(manifest_path, manifest_content)?;
Ok(())
}
}
#[derive(Debug, Default)]
pub struct GenerationReport {
pub successful: Vec<String>,
pub failed: Vec<(String, String)>,
pub skipped: Vec<String>,
}
impl GenerationReport {
pub fn print_summary(&self) {
println!("\n=== Package Generation Summary ===");
if !self.successful.is_empty() {
println!(
"\n✓ Successfully generated {} packages:",
self.successful.len()
);
for name in &self.successful {
println!(" - {}", name);
}
}
if !self.failed.is_empty() {
println!("\n✗ Failed to generate {} packages:", self.failed.len());
for (name, error) in &self.failed {
println!(" - {}: {}", name, error);
}
}
if !self.skipped.is_empty() {
println!("\n⊘ Skipped {} disabled packages:", self.skipped.len());
for name in &self.skipped {
println!(" - {}", name);
}
}
let total = self.successful.len() + self.failed.len() + self.skipped.len();
println!("\nTotal: {} packages processed", total);
}
}