use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::SubagentProfile;
pub const DEFAULT_FALLBACK_PROFILE_ID: &str = "general-purpose";
#[derive(Debug, Error)]
pub enum SubagentProfileRegistryError {
#[error("duplicate subagent profile id: {0}")]
DuplicateId(String),
#[error("fallback profile id `{0}` is not present in the registry")]
MissingFallback(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubagentProfileFile {
#[serde(default)]
pub profiles: Vec<SubagentProfile>,
}
#[derive(Debug, Clone)]
pub struct SubagentProfileRegistry {
profiles: HashMap<String, SubagentProfile>,
order: Vec<String>,
fallback_id: String,
}
impl SubagentProfileRegistry {
pub fn builder() -> SubagentProfileRegistryBuilder {
SubagentProfileRegistryBuilder::default()
}
pub fn get(&self, id: &str) -> Option<&SubagentProfile> {
self.profiles.get(id)
}
pub fn resolve(&self, subagent_type: &str) -> &SubagentProfile {
let trimmed = subagent_type.trim();
if !trimmed.is_empty() {
if let Some(profile) = self.profiles.get(trimmed) {
return profile;
}
let lower = trimmed.to_ascii_lowercase();
for id in &self.order {
if id.eq_ignore_ascii_case(&lower) {
if let Some(profile) = self.profiles.get(id) {
return profile;
}
}
}
}
self.profiles
.get(&self.fallback_id)
.expect("fallback profile always present (validated at build time)")
}
pub fn iter(&self) -> impl Iterator<Item = &SubagentProfile> {
self.order
.iter()
.filter_map(|id| self.profiles.get(id.as_str()))
}
pub fn len(&self) -> usize {
self.profiles.len()
}
pub fn is_empty(&self) -> bool {
self.profiles.is_empty()
}
pub fn fallback_id(&self) -> &str {
&self.fallback_id
}
}
#[derive(Debug, Clone, Default)]
pub struct SubagentProfileRegistryBuilder {
profiles: HashMap<String, SubagentProfile>,
order: Vec<String>,
fallback_id: Option<String>,
}
impl SubagentProfileRegistryBuilder {
pub fn fallback_id(mut self, id: impl Into<String>) -> Self {
self.fallback_id = Some(id.into());
self
}
pub fn extend<I: IntoIterator<Item = SubagentProfile>>(mut self, layer: I) -> Self {
for profile in layer {
let id = profile.id.clone();
if self.profiles.insert(id.clone(), profile).is_none() {
self.order.push(id);
}
}
self
}
pub fn extend_from_file(self, file: SubagentProfileFile) -> Self {
self.extend(file.profiles)
}
pub fn build(self) -> Result<SubagentProfileRegistry, SubagentProfileRegistryError> {
let fallback_id = self
.fallback_id
.unwrap_or_else(|| DEFAULT_FALLBACK_PROFILE_ID.to_string());
if !self.profiles.contains_key(&fallback_id) {
return Err(SubagentProfileRegistryError::MissingFallback(fallback_id));
}
Ok(SubagentProfileRegistry {
profiles: self.profiles,
order: self.order,
fallback_id,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::subagent::ToolPolicy;
fn p(id: &str) -> SubagentProfile {
SubagentProfile {
id: id.to_string(),
display_name: id.to_string(),
description: String::new(),
system_prompt: format!("prompt for {id}"),
tools: ToolPolicy::Inherit,
model_hint: None,
default_responsibility: None,
ui: Default::default(),
}
}
#[test]
fn build_requires_fallback_present() {
let err = SubagentProfileRegistry::builder()
.extend(vec![p("researcher")])
.build()
.unwrap_err();
assert!(matches!(
err,
SubagentProfileRegistryError::MissingFallback(_)
));
}
#[test]
fn resolve_known_id() {
let reg = SubagentProfileRegistry::builder()
.extend(vec![p("general-purpose"), p("researcher")])
.build()
.unwrap();
assert_eq!(reg.resolve("researcher").id, "researcher");
}
#[test]
fn resolve_unknown_id_falls_back() {
let reg = SubagentProfileRegistry::builder()
.extend(vec![p("general-purpose"), p("researcher")])
.build()
.unwrap();
assert_eq!(reg.resolve("does-not-exist").id, "general-purpose");
assert_eq!(reg.resolve("").id, "general-purpose");
assert_eq!(reg.resolve(" ").id, "general-purpose");
}
#[test]
fn resolve_is_case_insensitive() {
let reg = SubagentProfileRegistry::builder()
.extend(vec![p("general-purpose"), p("researcher")])
.build()
.unwrap();
assert_eq!(reg.resolve("Researcher").id, "researcher");
assert_eq!(reg.resolve("RESEARCHER").id, "researcher");
}
#[test]
fn later_layer_overrides_earlier() {
let mut overridden = p("researcher");
overridden.system_prompt = "OVERRIDDEN".to_string();
let reg = SubagentProfileRegistry::builder()
.extend(vec![p("general-purpose"), p("researcher")])
.extend(vec![overridden])
.build()
.unwrap();
assert_eq!(reg.resolve("researcher").system_prompt, "OVERRIDDEN");
let ids: Vec<&str> = reg.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, vec!["general-purpose", "researcher"]);
}
#[test]
fn extend_from_file_works() {
let file = SubagentProfileFile {
profiles: vec![p("general-purpose"), p("tester")],
};
let reg = SubagentProfileRegistry::builder()
.extend_from_file(file)
.build()
.unwrap();
assert!(reg.get("tester").is_some());
assert_eq!(reg.len(), 2);
}
#[test]
fn custom_fallback_id() {
let reg = SubagentProfileRegistry::builder()
.fallback_id("researcher")
.extend(vec![p("researcher"), p("coder")])
.build()
.unwrap();
assert_eq!(reg.fallback_id(), "researcher");
assert_eq!(reg.resolve("nope").id, "researcher");
}
#[test]
fn file_roundtrip_via_serde() {
let file = SubagentProfileFile {
profiles: vec![p("general-purpose"), p("researcher")],
};
let json = serde_json::to_string(&file).unwrap();
let parsed: SubagentProfileFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.profiles.len(), 2);
assert_eq!(parsed.profiles[1].id, "researcher");
}
}