use serde::Serialize;
use std::collections::BTreeSet;
use std::fmt;
use crate::provider::get_provider_config;
use crate::registry::TemplateRegistry;
#[derive(Debug, Clone)]
pub struct ResolvedDependency {
pub crate_name: String,
pub version: String,
pub features: Vec<String>,
pub default_features: bool,
}
#[derive(Debug, Clone)]
pub struct GeneratedFile {
pub path: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct CompositionManifest {
pub template_name: String,
pub addons: Vec<String>,
pub provider: String,
pub feature_set: BTreeSet<String>,
#[serde(skip)]
pub dependencies: Vec<ResolvedDependency>,
#[serde(skip)]
pub files: Vec<GeneratedFile>,
pub env_vars: Vec<(String, String)>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CompositionError {
UnknownTemplate(String),
UnknownAddon(String),
IncompatibleAddon { addon: String, template: String, reason: String },
ConflictingAddons { addon_a: String, addon_b: String },
}
impl fmt::Display for CompositionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CompositionError::UnknownTemplate(name) => {
write!(f, "unknown template '{name}'. Run 'cargo adk templates' to see options")
}
CompositionError::UnknownAddon(name) => {
write!(f, "unknown addon '{name}'. Run 'cargo adk addons' to see options")
}
CompositionError::IncompatibleAddon { addon, template, reason } => {
write!(f, "addon '{addon}' is incompatible with template '{template}': {reason}")
}
CompositionError::ConflictingAddons { addon_a, addon_b } => {
write!(f, "addons '{addon_a}' and '{addon_b}' cannot be used together")
}
}
}
}
impl std::error::Error for CompositionError {}
#[derive(Debug, Clone, Serialize)]
pub struct DryRunOutput {
pub files: Vec<DryRunFile>,
pub feature_set: Vec<String>,
pub dependencies: Vec<String>,
pub env_vars: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DryRunFile {
pub path: String,
pub size_bytes: usize,
}
pub fn resolve_composition(
registry: &TemplateRegistry,
template: &str,
addons: &[&str],
provider: &str,
) -> Result<CompositionManifest, CompositionError> {
let resolved_template = registry
.resolve_template(template)
.ok_or_else(|| CompositionError::UnknownTemplate(template.to_string()))?;
let provider_config = get_provider_config(provider).map_err(|_| {
CompositionError::UnknownTemplate(format!(
"unknown provider '{provider}'. Check supported providers"
))
})?;
let mut resolved_addons = Vec::with_capacity(addons.len());
for &addon_name in addons {
let addon = registry
.capability_addons
.iter()
.find(|a| a.name == addon_name)
.ok_or_else(|| CompositionError::UnknownAddon(addon_name.to_string()))?;
if resolved_template.incompatible_addons.contains(&addon_name) {
return Err(CompositionError::IncompatibleAddon {
addon: addon_name.to_string(),
template: resolved_template.name.to_string(),
reason: format!(
"template '{}' declares '{}' as incompatible",
resolved_template.name, addon_name
),
});
}
if addon.incompatible_with.contains(&resolved_template.name) {
return Err(CompositionError::IncompatibleAddon {
addon: addon_name.to_string(),
template: resolved_template.name.to_string(),
reason: format!(
"addon '{}' declares template '{}' as incompatible",
addon_name, resolved_template.name
),
});
}
resolved_addons.push(addon);
}
for i in 0..resolved_addons.len() {
for j in (i + 1)..resolved_addons.len() {
let addon_a = resolved_addons[i];
let addon_b = resolved_addons[j];
if addon_a.incompatible_with.contains(&addon_b.name)
|| addon_b.incompatible_with.contains(&addon_a.name)
{
return Err(CompositionError::ConflictingAddons {
addon_a: addon_a.name.to_string(),
addon_b: addon_b.name.to_string(),
});
}
}
}
let mut feature_set = BTreeSet::new();
for &feature in &resolved_template.required_features {
feature_set.insert(feature.to_string());
}
for addon in &resolved_addons {
for &feature in &addon.required_features {
feature_set.insert(feature.to_string());
}
}
feature_set.insert(provider_config.feature_flag.to_string());
let mut dependencies = Vec::new();
for addon in &resolved_addons {
for dep in &addon.additional_deps {
dependencies.push(ResolvedDependency {
crate_name: dep.crate_name.to_string(),
version: dep.version.to_string(),
features: dep.features.iter().map(|f| f.to_string()).collect(),
default_features: true,
});
}
}
let mut sorted_addons = resolved_addons.clone();
sorted_addons.sort_by_key(|a| a.init_priority);
let mut env_vars = Vec::new();
if provider_config.requires_api_key && !provider_config.env_var.is_empty() {
env_vars.push((
provider_config.env_var.to_string(),
format!("Your {} API key", provider_config.name),
));
}
for addon in &sorted_addons {
for &(key, description) in &addon.code_fragments.env_vars {
env_vars.push((key.to_string(), description.to_string()));
}
}
Ok(CompositionManifest {
template_name: resolved_template.name.to_string(),
addons: sorted_addons.iter().map(|a| a.name.to_string()).collect(),
provider: provider_config.name.to_string(),
feature_set,
dependencies,
files: Vec::new(),
env_vars,
warnings: Vec::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn registry() -> TemplateRegistry {
TemplateRegistry::builtin()
}
#[test]
fn test_unknown_template_returns_error() {
let reg = registry();
let result = resolve_composition(®, "nonexistent", &[], "gemini");
assert!(matches!(
result,
Err(CompositionError::UnknownTemplate(ref name)) if name == "nonexistent"
));
}
#[test]
fn test_unknown_addon_returns_error() {
let reg = registry();
let result = resolve_composition(®, "llm", &["nonexistent_addon"], "gemini");
assert!(matches!(
result,
Err(CompositionError::UnknownAddon(ref name)) if name == "nonexistent_addon"
));
}
#[test]
fn test_successful_resolution_with_no_addons() {
let reg = registry();
let manifest = resolve_composition(®, "llm", &[], "gemini").unwrap();
assert_eq!(manifest.template_name, "llm");
assert_eq!(manifest.provider, "gemini");
assert!(manifest.addons.is_empty());
assert!(manifest.feature_set.contains("minimal"));
assert!(manifest.feature_set.contains("gemini"));
}
#[test]
fn test_successful_resolution_with_addons() {
let reg = registry();
let manifest =
resolve_composition(®, "llm", &["telemetry", "sessions"], "openai").unwrap();
assert_eq!(manifest.template_name, "llm");
assert_eq!(manifest.provider, "openai");
assert_eq!(manifest.addons, vec!["telemetry", "sessions"]);
assert!(manifest.feature_set.contains("minimal"));
assert!(manifest.feature_set.contains("openai"));
assert!(manifest.feature_set.contains("telemetry"));
assert!(manifest.feature_set.contains("sessions"));
}
#[test]
fn test_alias_resolution() {
let reg = registry();
let manifest = resolve_composition(®, "basic", &[], "gemini").unwrap();
assert_eq!(manifest.template_name, "llm");
}
#[test]
fn test_addon_ordering_by_priority() {
let reg = registry();
let manifest =
resolve_composition(®, "llm", &["server", "telemetry", "auth"], "gemini").unwrap();
assert_eq!(manifest.addons, vec!["telemetry", "auth", "server"]);
}
#[test]
fn test_env_vars_collected_from_provider_and_addons() {
let reg = registry();
let manifest = resolve_composition(®, "llm", &[], "openai").unwrap();
assert!(manifest.env_vars.iter().any(|(key, _)| key == "OPENAI_API_KEY"));
}
#[test]
fn test_ollama_no_api_key_env_var() {
let reg = registry();
let manifest = resolve_composition(®, "llm", &[], "ollama").unwrap();
assert!(manifest.env_vars.is_empty());
}
#[test]
fn test_graph_template_features() {
let reg = registry();
let manifest = resolve_composition(®, "graph", &[], "gemini").unwrap();
assert!(manifest.feature_set.contains("minimal"));
assert!(manifest.feature_set.contains("graph"));
assert!(manifest.feature_set.contains("gemini"));
}
#[test]
fn test_feature_set_is_union() {
let reg = registry();
let manifest =
resolve_composition(®, "graph", &["mcp", "telemetry"], "anthropic").unwrap();
assert!(manifest.feature_set.contains("minimal"));
assert!(manifest.feature_set.contains("graph"));
assert!(manifest.feature_set.contains("tools"));
assert!(manifest.feature_set.contains("mcp"));
assert!(manifest.feature_set.contains("telemetry"));
assert!(manifest.feature_set.contains("anthropic"));
}
}