use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::commands::generate::Args as CliArgs;
#[derive(Debug, Default, Deserialize)]
#[non_exhaustive]
pub struct Manifest {
pub spec: Option<SpecConfig>,
pub output: Option<OutputConfig>,
pub sdks: Option<TargetList>,
pub servers: Option<TargetList>,
pub iac: Option<IacConfig>,
pub schemas: Option<TargetList>,
pub docs: Option<TargetList>,
pub helm: Option<HelmConfig>,
pub mcp: Option<McpConfig>,
pub grpc: Option<GrpcConfig>,
pub completions: Option<CompletionConfig>,
}
#[derive(Debug, Deserialize)]
pub struct HelmConfig {
pub targets: Vec<String>,
pub resources: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct McpConfig {
pub targets: Vec<String>,
pub name: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct GrpcConfig {
pub targets: Vec<String>,
pub name: Option<String>,
pub package: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CompletionConfig {
pub targets: Vec<String>,
pub name: Option<String>,
pub icon: Option<String>,
pub grouping: Option<String>,
#[serde(default)]
pub aliases: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct SpecConfig {
pub path: String,
#[serde(rename = "version")]
pub _version: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct OutputConfig {
pub dir: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TargetList {
pub targets: Vec<String>,
#[serde(default, rename = "overrides")]
pub _overrides: Option<BTreeMap<String, BTreeMap<String, String>>>,
}
#[derive(Debug, Deserialize)]
pub struct IacConfig {
pub backends: Vec<String>,
pub resources: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
#[must_use]
pub struct GenerateConfig {
pub spec: String,
pub output_dir: String,
pub sdks: Vec<String>,
pub servers: Vec<String>,
pub iac_backends: Vec<String>,
pub iac_resources: Option<String>,
pub iac_provider: Option<String>,
pub schemas: Vec<String>,
pub docs: Vec<String>,
pub helm_targets: Vec<String>,
pub helm_resources: Option<String>,
pub helm_provider: Option<String>,
pub mcp_targets: Vec<String>,
pub mcp_name: Option<String>,
pub grpc_targets: Vec<String>,
pub grpc_name: Option<String>,
pub grpc_package: Option<String>,
pub completion_targets: Vec<String>,
pub completion_name: Option<String>,
pub completion_icon: Option<String>,
pub completion_grouping: Option<String>,
pub completion_aliases: Vec<String>,
pub parallel: bool,
pub openapi_additional_properties: Option<String>,
pub openapi_git_user_id: Option<String>,
pub openapi_git_repo_id: Option<String>,
}
impl Default for GenerateConfig {
fn default() -> Self {
Self {
spec: String::new(),
output_dir: String::from("./generated"),
sdks: Vec::new(),
servers: Vec::new(),
iac_backends: Vec::new(),
iac_resources: None,
iac_provider: None,
schemas: Vec::new(),
docs: Vec::new(),
helm_targets: Vec::new(),
helm_resources: None,
helm_provider: None,
mcp_targets: Vec::new(),
mcp_name: None,
grpc_targets: Vec::new(),
grpc_name: None,
grpc_package: None,
completion_targets: Vec::new(),
completion_name: None,
completion_icon: None,
completion_grouping: None,
completion_aliases: Vec::new(),
parallel: true,
openapi_additional_properties: None,
openapi_git_user_id: None,
openapi_git_repo_id: None,
}
}
}
pub fn load(path: impl AsRef<Path>) -> Result<Manifest> {
let path = path.as_ref();
let content =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))
}
fn cli_or_manifest<F>(
cli_value: Option<&String>,
manifest: Option<&Manifest>,
f: F,
) -> Option<String>
where
F: FnOnce(&Manifest) -> Option<String>,
{
cli_value.cloned().or_else(|| manifest.and_then(f))
}
fn resolve_completion_fields(
manifest: Option<&Manifest>,
cli: &CliArgs,
) -> (Option<String>, Option<String>, Option<String>, Vec<String>) {
let name = cli_or_manifest(cli.completion_name.as_ref(), manifest, |m| {
m.completions.as_ref().and_then(|c| c.name.clone())
});
let icon = manifest.and_then(|m| m.completions.as_ref().and_then(|c| c.icon.clone()));
let grouping = manifest.and_then(|m| m.completions.as_ref().and_then(|c| c.grouping.clone()));
let aliases = manifest
.and_then(|m| m.completions.as_ref().map(|c| c.aliases.clone()))
.unwrap_or_default();
(name, icon, grouping, aliases)
}
pub fn merge_with_cli(manifest: Option<&Manifest>, cli: &CliArgs) -> GenerateConfig {
let spec = cli_or_manifest(cli.spec.as_ref(), manifest, |m| {
m.spec.as_ref().map(|s| s.path.clone())
})
.unwrap_or_default();
let output_dir = cli_or_manifest(cli.output.as_ref(), manifest, |m| {
m.output.as_ref().and_then(|o| o.dir.clone())
})
.unwrap_or_else(|| String::from("./generated"));
let sdks = parse_csv_or(cli.sdks.as_deref(), manifest.and_then(|m| m.sdks.as_ref()));
let servers = parse_csv_or(
cli.servers.as_deref(),
manifest.and_then(|m| m.servers.as_ref()),
);
let schemas = parse_csv_or(
cli.schemas.as_deref(),
manifest.and_then(|m| m.schemas.as_ref()),
);
let docs = parse_csv_or(cli.docs.as_deref(), manifest.and_then(|m| m.docs.as_ref()));
let mcp_targets = parse_csv_or(cli.mcp.as_deref(), manifest.and_then(|m| m.mcp.as_ref()));
let grpc_targets = parse_csv_or(cli.grpc.as_deref(), manifest.and_then(|m| m.grpc.as_ref()));
let helm_targets = parse_csv_or(cli.helm.as_deref(), manifest.and_then(|m| m.helm.as_ref()));
let iac_backends = parse_csv_or(cli.iac.as_deref(), manifest.and_then(|m| m.iac.as_ref()));
let completion_targets = parse_csv_or(
cli.completions.as_deref(),
manifest.and_then(|m| m.completions.as_ref()),
);
let mcp_name = cli_or_manifest(cli.mcp_name.as_ref(), manifest, |m| {
m.mcp.as_ref().and_then(|mc| mc.name.clone())
});
let grpc_name = cli_or_manifest(cli.grpc_name.as_ref(), manifest, |m| {
m.grpc.as_ref().and_then(|g| g.name.clone())
});
let grpc_package = cli_or_manifest(cli.grpc_package.as_ref(), manifest, |m| {
m.grpc.as_ref().and_then(|g| g.package.clone())
});
let helm_resources = cli_or_manifest(cli.helm_resources.as_ref(), manifest, |m| {
m.helm.as_ref().and_then(|h| h.resources.clone())
});
let helm_provider = cli_or_manifest(cli.helm_provider.as_ref(), manifest, |m| {
m.helm.as_ref().and_then(|h| h.provider.clone())
});
let iac_resources = cli_or_manifest(cli.resources.as_ref(), manifest, |m| {
m.iac.as_ref().and_then(|i| i.resources.clone())
});
let iac_provider = cli_or_manifest(cli.provider.as_ref(), manifest, |m| {
m.iac.as_ref().and_then(|i| i.provider.clone())
});
let (completion_name, completion_icon, completion_grouping, completion_aliases) =
resolve_completion_fields(manifest, cli);
GenerateConfig {
spec,
output_dir,
sdks,
servers,
schemas,
docs,
iac_backends,
iac_resources,
iac_provider,
helm_targets,
helm_resources,
helm_provider,
mcp_targets,
mcp_name,
grpc_targets,
grpc_name,
grpc_package,
completion_targets,
completion_name,
completion_icon,
completion_grouping,
completion_aliases,
parallel: cli.parallel,
openapi_additional_properties: cli.additional_properties.clone(),
openapi_git_user_id: cli.git_user_id.clone(),
openapi_git_repo_id: cli.git_repo_id.clone(),
}
}
trait HasTargets {
fn targets(&self) -> &[String];
}
impl HasTargets for TargetList {
fn targets(&self) -> &[String] {
&self.targets
}
}
impl HasTargets for McpConfig {
fn targets(&self) -> &[String] {
&self.targets
}
}
impl HasTargets for GrpcConfig {
fn targets(&self) -> &[String] {
&self.targets
}
}
impl HasTargets for HelmConfig {
fn targets(&self) -> &[String] {
&self.targets
}
}
impl HasTargets for IacConfig {
fn targets(&self) -> &[String] {
&self.backends
}
}
impl HasTargets for CompletionConfig {
fn targets(&self) -> &[String] {
&self.targets
}
}
fn parse_csv_or<T: HasTargets>(cli_value: Option<&str>, manifest: Option<&T>) -> Vec<String> {
if let Some(csv) = cli_value {
csv.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else if let Some(m) = manifest {
m.targets().to_vec()
} else {
Vec::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_cli() -> CliArgs {
CliArgs {
spec: None,
output: None,
sdks: None,
servers: None,
iac: None,
schemas: None,
docs: None,
helm: None,
helm_resources: None,
helm_provider: None,
mcp: None,
mcp_name: None,
grpc: None,
grpc_name: None,
grpc_package: None,
completions: None,
completion_name: None,
resources: None,
provider: None,
manifest: None,
parallel: true,
additional_properties: None,
git_user_id: None,
git_repo_id: None,
}
}
#[test]
fn parse_full_manifest() {
let toml = r#"
[spec]
path = "api.yaml"
version = "3.0"
[output]
dir = "./out"
[sdks]
targets = ["go", "python"]
[sdks.overrides.go]
packageName = "myapi"
[servers]
targets = ["rust-axum"]
[iac]
backends = ["terraform", "pulumi"]
resources = "./res"
provider = "./provider.toml"
[schemas]
targets = ["graphql-schema"]
[docs]
targets = ["markdown"]
[helm]
targets = ["helm"]
resources = "./helm-res"
provider = "./helm-provider.toml"
[mcp]
targets = ["mcp-rust"]
name = "my-mcp"
[completions]
targets = ["skim-tab", "fish"]
name = "my-cli"
icon = "☁"
grouping = "tag"
aliases = ["mc"]
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
let spec = m.spec.as_ref().unwrap();
assert_eq!(spec.path, "api.yaml");
assert_eq!(spec._version.as_deref(), Some("3.0"));
assert_eq!(m.output.as_ref().unwrap().dir.as_deref(), Some("./out"));
let sdks = m.sdks.as_ref().unwrap();
assert_eq!(sdks.targets, vec!["go", "python"]);
let overrides = sdks._overrides.as_ref().unwrap();
assert_eq!(overrides["go"]["packageName"], "myapi");
assert_eq!(m.servers.as_ref().unwrap().targets, vec!["rust-axum"]);
let iac = m.iac.as_ref().unwrap();
assert_eq!(iac.backends, vec!["terraform", "pulumi"]);
assert_eq!(iac.resources.as_deref(), Some("./res"));
assert_eq!(iac.provider.as_deref(), Some("./provider.toml"));
assert_eq!(m.schemas.as_ref().unwrap().targets, vec!["graphql-schema"]);
assert_eq!(m.docs.as_ref().unwrap().targets, vec!["markdown"]);
let helm = m.helm.as_ref().unwrap();
assert_eq!(helm.targets, vec!["helm"]);
assert_eq!(helm.resources.as_deref(), Some("./helm-res"));
assert_eq!(helm.provider.as_deref(), Some("./helm-provider.toml"));
let mcp = m.mcp.as_ref().unwrap();
assert_eq!(mcp.targets, vec!["mcp-rust"]);
assert_eq!(mcp.name.as_deref(), Some("my-mcp"));
let comp = m.completions.as_ref().unwrap();
assert_eq!(comp.targets, vec!["skim-tab", "fish"]);
assert_eq!(comp.name.as_deref(), Some("my-cli"));
assert_eq!(comp.icon.as_deref(), Some("☁"));
assert_eq!(comp.grouping.as_deref(), Some("tag"));
assert_eq!(comp.aliases, vec!["mc"]);
}
#[test]
fn parse_minimal_manifest() {
let toml = r#"
[spec]
path = "spec.json"
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
assert_eq!(m.spec.as_ref().unwrap().path, "spec.json");
assert!(m.spec.as_ref().unwrap()._version.is_none());
assert!(m.sdks.is_none());
assert!(m.servers.is_none());
assert!(m.iac.is_none());
assert!(m.schemas.is_none());
assert!(m.docs.is_none());
assert!(m.helm.is_none());
assert!(m.mcp.is_none());
}
#[test]
fn parse_empty_manifest() {
let m: Manifest = toml::from_str("").expect("empty TOML is valid");
assert!(m.spec.is_none());
assert!(m.output.is_none());
assert!(m.sdks.is_none());
}
#[test]
fn parse_target_list_without_overrides() {
let toml = r#"
[sdks]
targets = ["go"]
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
let sdks = m.sdks.as_ref().unwrap();
assert_eq!(sdks.targets, vec!["go"]);
assert!(sdks._overrides.is_none());
}
#[test]
fn parse_target_list_with_empty_overrides() {
let toml = r#"
[sdks]
targets = ["go"]
[sdks.overrides]
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
let sdks = m.sdks.as_ref().unwrap();
let overrides = sdks._overrides.as_ref().unwrap();
assert!(overrides.is_empty());
}
#[test]
fn load_valid_file() {
let dir = std::env::temp_dir().join("forge_gen_test_load");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("forge-gen.toml");
std::fs::write(
&path,
r#"
[spec]
path = "my-api.yaml"
[sdks]
targets = ["rust"]
"#,
)
.unwrap();
let m = load(&path).expect("should load");
assert_eq!(m.spec.as_ref().unwrap().path, "my-api.yaml");
assert_eq!(m.sdks.as_ref().unwrap().targets, vec!["rust"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_file_returns_error() {
let result = load(Path::new("/tmp/forge_gen_test_nonexistent.toml"));
assert!(result.is_err());
}
#[test]
fn load_invalid_toml_returns_error() {
let dir = std::env::temp_dir().join("forge_gen_test_invalid");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("bad.toml");
std::fs::write(&path, "[spec\npath = broken").unwrap();
let result = load(&path);
assert!(result.is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn merge_cli_overrides_spec() {
let manifest: Manifest = toml::from_str(
r#"
[spec]
path = "manifest-spec.yaml"
"#,
)
.unwrap();
let mut cli = empty_cli();
cli.spec = Some(String::from("cli-spec.yaml"));
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.spec, "cli-spec.yaml");
}
#[test]
fn merge_cli_overrides_output_dir() {
let manifest: Manifest = toml::from_str(
r#"
[output]
dir = "./manifest-out"
"#,
)
.unwrap();
let mut cli = empty_cli();
cli.output = Some(String::from("./cli-out"));
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.output_dir, "./cli-out");
}
#[test]
fn merge_cli_overrides_sdks() {
let manifest: Manifest = toml::from_str(
r#"
[sdks]
targets = ["go", "python"]
"#,
)
.unwrap();
let mut cli = empty_cli();
cli.sdks = Some(String::from("rust,java"));
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.sdks, vec!["rust", "java"]);
}
#[test]
fn merge_cli_overrides_iac() {
let manifest: Manifest = toml::from_str(
r#"
[iac]
backends = ["terraform"]
resources = "./manifest-res"
provider = "./manifest-prov.toml"
"#,
)
.unwrap();
let mut cli = empty_cli();
cli.iac = Some(String::from("pulumi"));
cli.resources = Some(String::from("./cli-res"));
cli.provider = Some(String::from("./cli-prov.toml"));
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.iac_backends, vec!["pulumi"]);
assert_eq!(config.iac_resources.as_deref(), Some("./cli-res"));
assert_eq!(config.iac_provider.as_deref(), Some("./cli-prov.toml"));
}
#[test]
fn merge_cli_overrides_helm() {
let manifest: Manifest = toml::from_str(
r#"
[helm]
targets = ["helm"]
resources = "./manifest-helm-res"
provider = "./manifest-helm-prov.toml"
"#,
)
.unwrap();
let mut cli = empty_cli();
cli.helm = Some(String::from("helm"));
cli.helm_resources = Some(String::from("./cli-helm-res"));
cli.helm_provider = Some(String::from("./cli-helm-prov.toml"));
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.helm_targets, vec!["helm"]);
assert_eq!(config.helm_resources.as_deref(), Some("./cli-helm-res"));
assert_eq!(
config.helm_provider.as_deref(),
Some("./cli-helm-prov.toml")
);
}
#[test]
fn merge_cli_overrides_mcp() {
let manifest: Manifest = toml::from_str(
r#"
[mcp]
targets = ["mcp-rust"]
name = "manifest-name"
"#,
)
.unwrap();
let mut cli = empty_cli();
cli.mcp = Some(String::from("mcp-rust"));
cli.mcp_name = Some(String::from("cli-name"));
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.mcp_targets, vec!["mcp-rust"]);
assert_eq!(config.mcp_name.as_deref(), Some("cli-name"));
}
#[test]
fn merge_manifest_used_when_cli_absent() {
let manifest: Manifest = toml::from_str(
r#"
[spec]
path = "manifest.yaml"
[output]
dir = "./manifest-out"
[sdks]
targets = ["go"]
[servers]
targets = ["rust-axum"]
[schemas]
targets = ["protobuf-schema"]
[docs]
targets = ["html"]
[iac]
backends = ["ansible"]
resources = "./iac-res"
provider = "./iac-prov.toml"
[helm]
targets = ["helm"]
resources = "./helm-res"
provider = "./helm-prov.toml"
[mcp]
targets = ["mcp-rust"]
name = "my-server"
[completions]
targets = ["skim-tab"]
name = "my-tool"
icon = "☁"
grouping = "tag"
"#,
)
.unwrap();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.spec, "manifest.yaml");
assert_eq!(config.output_dir, "./manifest-out");
assert_eq!(config.sdks, vec!["go"]);
assert_eq!(config.servers, vec!["rust-axum"]);
assert_eq!(config.schemas, vec!["protobuf-schema"]);
assert_eq!(config.docs, vec!["html"]);
assert_eq!(config.iac_backends, vec!["ansible"]);
assert_eq!(config.iac_resources.as_deref(), Some("./iac-res"));
assert_eq!(config.iac_provider.as_deref(), Some("./iac-prov.toml"));
assert_eq!(config.helm_targets, vec!["helm"]);
assert_eq!(config.helm_resources.as_deref(), Some("./helm-res"));
assert_eq!(config.helm_provider.as_deref(), Some("./helm-prov.toml"));
assert_eq!(config.mcp_targets, vec!["mcp-rust"]);
assert_eq!(config.mcp_name.as_deref(), Some("my-server"));
assert_eq!(config.completion_targets, vec!["skim-tab"]);
assert_eq!(config.completion_name.as_deref(), Some("my-tool"));
assert_eq!(config.completion_icon.as_deref(), Some("☁"));
assert_eq!(config.completion_grouping.as_deref(), Some("tag"));
}
#[test]
fn merge_no_manifest_uses_defaults() {
let cli = empty_cli();
let config = merge_with_cli(None, &cli);
assert_eq!(config.spec, "");
assert_eq!(config.output_dir, "./generated");
assert!(config.sdks.is_empty());
assert!(config.servers.is_empty());
assert!(config.iac_backends.is_empty());
assert!(config.iac_resources.is_none());
assert!(config.iac_provider.is_none());
assert!(config.schemas.is_empty());
assert!(config.docs.is_empty());
assert!(config.helm_targets.is_empty());
assert!(config.helm_resources.is_none());
assert!(config.helm_provider.is_none());
assert!(config.mcp_targets.is_empty());
assert!(config.mcp_name.is_none());
assert!(config.completion_targets.is_empty());
assert!(config.completion_name.is_none());
assert!(config.parallel);
}
#[test]
fn merge_no_manifest_cli_only() {
let mut cli = empty_cli();
cli.spec = Some(String::from("cli.yaml"));
cli.output = Some(String::from("./cli-out"));
cli.sdks = Some(String::from("go,python"));
cli.servers = Some(String::from("rust-axum"));
cli.iac = Some(String::from("terraform"));
cli.resources = Some(String::from("./r"));
cli.provider = Some(String::from("./p.toml"));
cli.schemas = Some(String::from("graphql-schema"));
cli.docs = Some(String::from("markdown,html"));
cli.helm = Some(String::from("helm"));
cli.helm_resources = Some(String::from("./hr"));
cli.helm_provider = Some(String::from("./hp.toml"));
cli.mcp = Some(String::from("mcp-rust"));
cli.mcp_name = Some(String::from("my-name"));
cli.completions = Some(String::from("skim-tab,fish"));
cli.completion_name = Some(String::from("my-tool"));
cli.parallel = false;
let config = merge_with_cli(None, &cli);
assert_eq!(config.spec, "cli.yaml");
assert_eq!(config.output_dir, "./cli-out");
assert_eq!(config.sdks, vec!["go", "python"]);
assert_eq!(config.servers, vec!["rust-axum"]);
assert_eq!(config.iac_backends, vec!["terraform"]);
assert_eq!(config.iac_resources.as_deref(), Some("./r"));
assert_eq!(config.iac_provider.as_deref(), Some("./p.toml"));
assert_eq!(config.schemas, vec!["graphql-schema"]);
assert_eq!(config.docs, vec!["markdown", "html"]);
assert_eq!(config.helm_targets, vec!["helm"]);
assert_eq!(config.helm_resources.as_deref(), Some("./hr"));
assert_eq!(config.helm_provider.as_deref(), Some("./hp.toml"));
assert_eq!(config.mcp_targets, vec!["mcp-rust"]);
assert_eq!(config.mcp_name.as_deref(), Some("my-name"));
assert_eq!(config.completion_targets, vec!["skim-tab", "fish"]);
assert_eq!(config.completion_name.as_deref(), Some("my-tool"));
assert!(!config.parallel);
}
#[test]
fn merge_empty_manifest_uses_defaults() {
let manifest = Manifest::default();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.spec, "");
assert_eq!(config.output_dir, "./generated");
assert!(config.sdks.is_empty());
assert!(config.servers.is_empty());
assert!(config.iac_backends.is_empty());
assert!(config.schemas.is_empty());
assert!(config.docs.is_empty());
assert!(config.helm_targets.is_empty());
assert!(config.mcp_targets.is_empty());
assert!(config.completion_targets.is_empty());
}
#[test]
fn parse_csv_or_target_list_with_csv() {
let result = parse_csv_or::<TargetList>(Some("go, python , rust"), None);
assert_eq!(result, vec!["go", "python", "rust"]);
}
#[test]
fn parse_csv_or_target_list_with_single_value() {
let result = parse_csv_or::<TargetList>(Some("go"), None);
assert_eq!(result, vec!["go"]);
}
#[test]
fn parse_csv_or_target_list_empty_csv() {
let result = parse_csv_or::<TargetList>(Some(""), None);
assert!(result.is_empty());
}
#[test]
fn parse_csv_or_target_list_csv_with_trailing_comma() {
let result = parse_csv_or::<TargetList>(Some("go,python,"), None);
assert_eq!(result, vec!["go", "python"]);
}
#[test]
fn parse_csv_or_target_list_csv_overrides_manifest() {
let tl = TargetList {
targets: vec![String::from("java")],
_overrides: None,
};
let result = parse_csv_or(Some("go"), Some(&tl));
assert_eq!(result, vec!["go"]);
}
#[test]
fn parse_csv_or_target_list_uses_manifest_fallback() {
let tl = TargetList {
targets: vec![String::from("java"), String::from("kotlin")],
_overrides: None,
};
let result = parse_csv_or(None, Some(&tl));
assert_eq!(result, vec!["java", "kotlin"]);
}
#[test]
fn parse_csv_or_target_list_both_none() {
let result = parse_csv_or::<TargetList>(None, None);
assert!(result.is_empty());
}
#[test]
fn parse_csv_or_mcp_with_csv() {
let result = parse_csv_or::<McpConfig>(Some("mcp-rust"), None);
assert_eq!(result, vec!["mcp-rust"]);
}
#[test]
fn parse_csv_or_mcp_uses_manifest_fallback() {
let mc = McpConfig {
targets: vec![String::from("mcp-rust")],
name: Some(String::from("test")),
};
let result = parse_csv_or(None, Some(&mc));
assert_eq!(result, vec!["mcp-rust"]);
}
#[test]
fn parse_csv_or_mcp_both_none() {
let result = parse_csv_or::<McpConfig>(None, None);
assert!(result.is_empty());
}
#[test]
fn parse_csv_or_helm_with_csv() {
let result = parse_csv_or::<HelmConfig>(Some("helm"), None);
assert_eq!(result, vec!["helm"]);
}
#[test]
fn parse_csv_or_helm_uses_manifest_fallback() {
let hc = HelmConfig {
targets: vec![String::from("helm")],
resources: None,
provider: None,
};
let result = parse_csv_or(None, Some(&hc));
assert_eq!(result, vec!["helm"]);
}
#[test]
fn parse_csv_or_helm_both_none() {
let result = parse_csv_or::<HelmConfig>(None, None);
assert!(result.is_empty());
}
#[test]
fn parse_csv_or_iac_with_csv() {
let result = parse_csv_or::<IacConfig>(Some("terraform,pulumi"), None);
assert_eq!(result, vec!["terraform", "pulumi"]);
}
#[test]
fn parse_csv_or_iac_uses_manifest_fallback() {
let ic = IacConfig {
backends: vec![String::from("crossplane")],
resources: None,
provider: None,
};
let result = parse_csv_or(None, Some(&ic));
assert_eq!(result, vec!["crossplane"]);
}
#[test]
fn parse_csv_or_iac_both_none() {
let result = parse_csv_or::<IacConfig>(None, None);
assert!(result.is_empty());
}
#[test]
fn parse_csv_or_completion_with_csv() {
let result = parse_csv_or::<CompletionConfig>(Some("skim-tab,fish"), None);
assert_eq!(result, vec!["skim-tab", "fish"]);
}
#[test]
fn parse_csv_or_completion_uses_manifest_fallback() {
let cc = CompletionConfig {
targets: vec![String::from("skim-tab")],
name: Some(String::from("test")),
icon: None,
grouping: None,
aliases: vec![],
};
let result = parse_csv_or(None, Some(&cc));
assert_eq!(result, vec!["skim-tab"]);
}
#[test]
fn parse_csv_or_completion_both_none() {
let result = parse_csv_or::<CompletionConfig>(None, None);
assert!(result.is_empty());
}
#[test]
fn merge_cli_overrides_completions() {
let manifest: Manifest = toml::from_str(
r#"
[completions]
targets = ["skim-tab"]
name = "manifest-name"
"#,
)
.unwrap();
let mut cli = empty_cli();
cli.completions = Some(String::from("fish"));
cli.completion_name = Some(String::from("cli-name"));
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.completion_targets, vec!["fish"]);
assert_eq!(config.completion_name.as_deref(), Some("cli-name"));
}
#[test]
fn merge_parallel_defaults_to_true() {
let cli = empty_cli();
let config = merge_with_cli(None, &cli);
assert!(config.parallel);
}
#[test]
fn merge_parallel_can_be_false() {
let mut cli = empty_cli();
cli.parallel = false;
let config = merge_with_cli(None, &cli);
assert!(!config.parallel);
}
#[test]
fn parse_csv_whitespace_only() {
let result = parse_csv_or::<TargetList>(Some(" , , "), None);
assert!(result.is_empty());
}
#[test]
fn parse_csv_preserves_case() {
let result = parse_csv_or::<TargetList>(Some("Go,PYTHON,Rust"), None);
assert_eq!(result, vec!["Go", "PYTHON", "Rust"]);
}
#[test]
fn merge_output_dir_default() {
let cli = empty_cli();
let config = merge_with_cli(None, &cli);
assert_eq!(config.output_dir, "./generated");
}
#[test]
fn merge_spec_default_is_empty_string() {
let cli = empty_cli();
let config = merge_with_cli(None, &cli);
assert_eq!(config.spec, "");
}
#[test]
fn parse_completion_config_without_optional_fields() {
let toml = r#"
[completions]
targets = ["fish"]
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
let comp = m.completions.as_ref().unwrap();
assert_eq!(comp.targets, vec!["fish"]);
assert!(comp.name.is_none());
assert!(comp.icon.is_none());
assert!(comp.grouping.is_none());
assert!(comp.aliases.is_empty());
}
#[test]
fn parse_completion_config_with_all_fields() {
let toml = r#"
[completions]
targets = ["skim-tab", "fish"]
name = "my-tool"
icon = "★"
grouping = "operation-id"
aliases = ["mt", "tool"]
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
let comp = m.completions.as_ref().unwrap();
assert_eq!(comp.name.as_deref(), Some("my-tool"));
assert_eq!(comp.icon.as_deref(), Some("★"));
assert_eq!(comp.grouping.as_deref(), Some("operation-id"));
assert_eq!(comp.aliases, vec!["mt", "tool"]);
}
#[test]
fn merge_completion_aliases_from_manifest() {
let manifest: Manifest = toml::from_str(
r#"
[completions]
targets = ["fish"]
aliases = ["f", "fi"]
"#,
)
.unwrap();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.completion_aliases, vec!["f", "fi"]);
}
#[test]
fn merge_completion_aliases_default_empty() {
let cli = empty_cli();
let config = merge_with_cli(None, &cli);
assert!(config.completion_aliases.is_empty());
}
#[test]
fn merge_completion_icon_from_manifest() {
let manifest: Manifest = toml::from_str(
r#"
[completions]
targets = ["skim-tab"]
icon = "🔥"
"#,
)
.unwrap();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.completion_icon.as_deref(), Some("🔥"));
}
#[test]
fn merge_completion_grouping_from_manifest() {
let manifest: Manifest = toml::from_str(
r#"
[completions]
targets = ["skim-tab"]
grouping = "path"
"#,
)
.unwrap();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.completion_grouping.as_deref(), Some("path"));
}
#[test]
fn parse_helm_without_resources() {
let toml = r#"
[helm]
targets = ["helm"]
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
let helm = m.helm.as_ref().unwrap();
assert_eq!(helm.targets, vec!["helm"]);
assert!(helm.resources.is_none());
assert!(helm.provider.is_none());
}
#[test]
fn merge_helm_manifest_resources_used_when_cli_absent() {
let manifest: Manifest = toml::from_str(
r#"
[helm]
targets = ["helm"]
resources = "./h-res"
provider = "./h-prov.toml"
"#,
)
.unwrap();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.helm_resources.as_deref(), Some("./h-res"));
assert_eq!(config.helm_provider.as_deref(), Some("./h-prov.toml"));
}
#[test]
fn parse_mcp_without_name() {
let toml = r#"
[mcp]
targets = ["mcp-rust"]
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
let mcp = m.mcp.as_ref().unwrap();
assert_eq!(mcp.targets, vec!["mcp-rust"]);
assert!(mcp.name.is_none());
}
#[test]
fn merge_mcp_name_from_manifest() {
let manifest: Manifest = toml::from_str(
r#"
[mcp]
targets = ["mcp-rust"]
name = "manifest-mcp-name"
"#,
)
.unwrap();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.mcp_name.as_deref(), Some("manifest-mcp-name"));
}
#[test]
fn parse_grpc_full() {
let toml = r#"
[grpc]
targets = ["grpc-rust"]
name = "breathe-grpc"
package = "breathe.v1"
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
let grpc = m.grpc.as_ref().unwrap();
assert_eq!(grpc.targets, vec!["grpc-rust"]);
assert_eq!(grpc.name.as_deref(), Some("breathe-grpc"));
assert_eq!(grpc.package.as_deref(), Some("breathe.v1"));
}
#[test]
fn parse_grpc_without_optional_fields() {
let toml = r#"
[grpc]
targets = ["grpc-rust"]
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
let grpc = m.grpc.as_ref().unwrap();
assert_eq!(grpc.targets, vec!["grpc-rust"]);
assert!(grpc.name.is_none());
assert!(grpc.package.is_none());
}
#[test]
fn merge_grpc_from_manifest_when_cli_absent() {
let manifest: Manifest = toml::from_str(
r#"
[grpc]
targets = ["grpc-rust"]
name = "manifest-grpc"
package = "manifest.v1"
"#,
)
.unwrap();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.grpc_targets, vec!["grpc-rust"]);
assert_eq!(config.grpc_name.as_deref(), Some("manifest-grpc"));
assert_eq!(config.grpc_package.as_deref(), Some("manifest.v1"));
}
#[test]
fn merge_cli_overrides_grpc() {
let manifest: Manifest = toml::from_str(
r#"
[grpc]
targets = ["grpc-rust"]
name = "manifest-grpc"
package = "manifest.v1"
"#,
)
.unwrap();
let mut cli = empty_cli();
cli.grpc = Some(String::from("grpc-rust"));
cli.grpc_name = Some(String::from("cli-grpc"));
cli.grpc_package = Some(String::from("cli.v2"));
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.grpc_targets, vec!["grpc-rust"]);
assert_eq!(config.grpc_name.as_deref(), Some("cli-grpc"));
assert_eq!(config.grpc_package.as_deref(), Some("cli.v2"));
}
#[test]
fn parse_csv_or_grpc_uses_manifest_fallback() {
let gc = GrpcConfig {
targets: vec![String::from("grpc-rust")],
name: Some(String::from("test")),
package: None,
};
let result = parse_csv_or(None, Some(&gc));
assert_eq!(result, vec!["grpc-rust"]);
}
#[test]
fn parse_csv_or_grpc_both_none() {
let result = parse_csv_or::<GrpcConfig>(None, None);
assert!(result.is_empty());
}
#[test]
fn parse_iac_without_optional_fields() {
let toml = r#"
[iac]
backends = ["terraform"]
"#;
let m: Manifest = toml::from_str(toml).expect("valid TOML");
let iac = m.iac.as_ref().unwrap();
assert_eq!(iac.backends, vec!["terraform"]);
assert!(iac.resources.is_none());
assert!(iac.provider.is_none());
}
#[test]
fn merge_iac_resources_from_manifest() {
let manifest: Manifest = toml::from_str(
r#"
[iac]
backends = ["pulumi"]
resources = "./iac-res"
provider = "./iac-prov.toml"
"#,
)
.unwrap();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert_eq!(config.iac_resources.as_deref(), Some("./iac-res"));
assert_eq!(config.iac_provider.as_deref(), Some("./iac-prov.toml"));
}
#[test]
fn parse_csv_multiple_commas() {
let result = parse_csv_or::<TargetList>(Some(",,go,,python,,"), None);
assert_eq!(result, vec!["go", "python"]);
}
#[test]
fn parse_csv_all_value() {
let result = parse_csv_or::<TargetList>(Some("all"), None);
assert_eq!(result, vec!["all"]);
}
#[test]
fn parse_csv_cli_takes_precedence_over_iac_manifest() {
let ic = IacConfig {
backends: vec![String::from("terraform")],
resources: None,
provider: None,
};
let result = parse_csv_or(Some("pulumi,crossplane"), Some(&ic));
assert_eq!(result, vec!["pulumi", "crossplane"]);
}
#[test]
fn parse_csv_cli_takes_precedence_over_completion_manifest() {
let cc = CompletionConfig {
targets: vec![String::from("skim-tab")],
name: None,
icon: None,
grouping: None,
aliases: vec![],
};
let result = parse_csv_or(Some("fish"), Some(&cc));
assert_eq!(result, vec!["fish"]);
}
#[test]
fn parse_csv_cli_takes_precedence_over_helm_manifest() {
let hc = HelmConfig {
targets: vec![String::from("helm")],
resources: Some(String::from("r")),
provider: None,
};
let result = parse_csv_or(Some("helm"), Some(&hc));
assert_eq!(result, vec!["helm"]);
}
#[test]
fn load_error_message_contains_path() {
let result = load(Path::new("/tmp/forge_gen_test_path_in_error_msg.toml"));
let err = result.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("forge_gen_test_path_in_error_msg"),
"error should include the file path, got: {msg}"
);
}
#[test]
fn load_invalid_toml_error_message_contains_path() {
let dir = std::env::temp_dir().join("forge_gen_test_load_err_ctx");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("broken.toml");
std::fs::write(&path, "[spec\npath = broken").unwrap();
let err = load(&path).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("broken.toml"),
"parse error should include the file path, got: {msg}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parse_manifest_ignores_unknown_top_level_keys() {
let toml = r#"
[spec]
path = "api.yaml"
[unknown_section]
foo = "bar"
"#;
let result: Result<Manifest, _> = toml::from_str(toml);
assert!(
result.is_err() || result.unwrap().spec.is_some(),
"either reject or at least parse spec"
);
}
#[test]
fn spec_config_version_is_optional() {
let toml = r#"
[spec]
path = "api.yaml"
"#;
let m: Manifest = toml::from_str(toml).unwrap();
assert!(m.spec.as_ref().unwrap()._version.is_none());
}
#[test]
fn spec_config_version_is_preserved() {
let toml = r#"
[spec]
path = "api.yaml"
version = "3.1"
"#;
let m: Manifest = toml::from_str(toml).unwrap();
assert_eq!(m.spec.as_ref().unwrap()._version.as_deref(), Some("3.1"));
}
#[test]
fn output_config_dir_is_optional() {
let toml = r#"
[output]
"#;
let m: Manifest = toml::from_str(toml).unwrap();
assert!(m.output.as_ref().unwrap().dir.is_none());
}
#[test]
fn generate_config_default_has_expected_values() {
let config = GenerateConfig::default();
assert_eq!(config.output_dir, "./generated");
assert!(config.spec.is_empty());
assert!(config.sdks.is_empty());
assert!(config.parallel);
}
#[test]
fn generate_config_equality() {
let a = GenerateConfig::default();
let b = GenerateConfig::default();
assert_eq!(a, b);
}
#[test]
fn merge_manifest_with_empty_sdk_targets() {
let manifest: Manifest = toml::from_str(
r#"
[sdks]
targets = []
"#,
)
.unwrap();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert!(config.sdks.is_empty());
}
#[test]
fn merge_manifest_with_empty_iac_backends() {
let manifest: Manifest = toml::from_str(
r#"
[iac]
backends = []
"#,
)
.unwrap();
let cli = empty_cli();
let config = merge_with_cli(Some(&manifest), &cli);
assert!(config.iac_backends.is_empty());
}
#[test]
fn cli_or_manifest_cli_wins() {
let cli_val = String::from("cli");
let manifest = Manifest::default();
let result = cli_or_manifest(Some(&cli_val), Some(&manifest), |_| {
Some(String::from("manifest"))
});
assert_eq!(result.as_deref(), Some("cli"));
}
#[test]
fn cli_or_manifest_fallback_to_manifest() {
let manifest = Manifest::default();
let result = cli_or_manifest(None, Some(&manifest), |_| Some(String::from("manifest")));
assert_eq!(result.as_deref(), Some("manifest"));
}
#[test]
fn cli_or_manifest_both_none() {
let result = cli_or_manifest(None, None, |_: &Manifest| -> Option<String> {
unreachable!()
});
assert!(result.is_none());
}
#[test]
fn cli_or_manifest_manifest_returns_none() {
let manifest = Manifest::default();
let result = cli_or_manifest(None, Some(&manifest), |_| None);
assert!(result.is_none());
}
#[test]
fn resolve_completion_fields_from_manifest() {
let manifest: Manifest = toml::from_str(
r#"
[completions]
targets = ["skim-tab"]
name = "tool"
icon = "★"
grouping = "tag"
aliases = ["t"]
"#,
)
.unwrap();
let cli = empty_cli();
let (name, icon, grouping, aliases) = resolve_completion_fields(Some(&manifest), &cli);
assert_eq!(name.as_deref(), Some("tool"));
assert_eq!(icon.as_deref(), Some("★"));
assert_eq!(grouping.as_deref(), Some("tag"));
assert_eq!(aliases, vec!["t"]);
}
#[test]
fn resolve_completion_fields_cli_name_overrides() {
let manifest: Manifest = toml::from_str(
r#"
[completions]
targets = ["skim-tab"]
name = "manifest-name"
"#,
)
.unwrap();
let mut cli = empty_cli();
cli.completion_name = Some(String::from("cli-name"));
let (name, _, _, _) = resolve_completion_fields(Some(&manifest), &cli);
assert_eq!(name.as_deref(), Some("cli-name"));
}
#[test]
fn resolve_completion_fields_no_manifest() {
let cli = empty_cli();
let (name, icon, grouping, aliases) = resolve_completion_fields(None, &cli);
assert!(name.is_none());
assert!(icon.is_none());
assert!(grouping.is_none());
assert!(aliases.is_empty());
}
#[test]
fn overrides_use_btreemap_deterministic_order() {
let toml_str = r#"
[sdks]
targets = ["go", "python"]
[sdks.overrides.python]
packageName = "pyapi"
[sdks.overrides.go]
packageName = "goapi"
"#;
let m: Manifest = toml::from_str(toml_str).unwrap();
let overrides = m.sdks.as_ref().unwrap()._overrides.as_ref().unwrap();
let keys: Vec<&String> = overrides.keys().collect();
assert_eq!(keys, vec!["go", "python"], "BTreeMap should sort keys");
}
}