use std::{collections::HashMap, str::FromStr, sync::RwLock};
use convert_case::{Case, Casing};
use lazy_static::lazy_static;
use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
use thiserror::Error;
use ts_rs_forge::TS;
use crate::executors::{BaseCodingAgent, CodingAgent, StandardCodingAgentExecutor};
pub const GENIE_VARIANT: &str = "GENIE";
pub fn canonical_variant_key<S: AsRef<str>>(raw: S) -> String {
let key = raw.as_ref();
if key.eq_ignore_ascii_case("DEFAULT") || key.eq_ignore_ascii_case("GENIE") {
GENIE_VARIANT.to_string()
} else {
key.to_case(Case::Snake).to_case(Case::ScreamingSnake)
}
}
#[derive(Error, Debug)]
pub enum ProfileError {
#[error("Built-in executor '{executor}' cannot be deleted")]
CannotDeleteExecutor { executor: BaseCodingAgent },
#[error("Built-in configuration '{executor}:{variant}' cannot be deleted")]
CannotDeleteBuiltInConfig {
executor: BaseCodingAgent,
variant: String,
},
#[error("Validation error: {0}")]
Validation(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Serde(#[from] serde_json::Error),
#[error("No available executor profile")]
NoAvailableExecutorProfile,
}
lazy_static! {
static ref EXECUTOR_PROFILES_CACHE: RwLock<ExecutorConfigs> =
RwLock::new(ExecutorConfigs::load());
}
const DEFAULT_PROFILES_JSON: &str = include_str!("../default_profiles.json");
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Hash, Eq)]
pub struct ExecutorProfileId {
#[serde(alias = "profile", deserialize_with = "de_base_coding_agent_kebab")]
pub executor: BaseCodingAgent,
#[serde(skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
}
fn de_base_coding_agent_kebab<'de, D>(de: D) -> Result<BaseCodingAgent, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(de)?;
let norm = raw.replace('-', "_").to_ascii_uppercase();
BaseCodingAgent::from_str(&norm)
.map_err(|_| D::Error::custom(format!("unknown executor '{raw}' (normalized to '{norm}')")))
}
impl ExecutorProfileId {
pub fn new(executor: BaseCodingAgent) -> Self {
Self {
executor,
variant: None,
}
}
pub fn with_variant(executor: BaseCodingAgent, variant: String) -> Self {
Self {
executor,
variant: Some(variant),
}
}
pub fn cache_key(&self) -> String {
match &self.variant {
Some(variant) => format!("{}:{}", self.executor, variant),
None => self.executor.clone().to_string(),
}
}
}
impl std::fmt::Display for ExecutorProfileId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.variant {
Some(variant) => write!(f, "{}:{}", self.executor, variant),
None => write!(f, "{}", self.executor),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
pub struct ExecutorConfig {
#[serde(flatten)]
pub configurations: HashMap<String, CodingAgent>,
}
impl ExecutorConfig {
pub fn get_variant(&self, variant: &str) -> Option<&CodingAgent> {
self.configurations.get(variant)
}
pub fn get_default(&self) -> Option<&CodingAgent> {
self.configurations.get(GENIE_VARIANT)
}
pub fn new_with_default(default_config: CodingAgent) -> Self {
let mut configurations = HashMap::new();
configurations.insert(GENIE_VARIANT.to_string(), default_config);
Self { configurations }
}
pub fn set_variant(
&mut self,
variant_name: String,
config: CodingAgent,
) -> Result<(), &'static str> {
let key = canonical_variant_key(&variant_name);
if key == GENIE_VARIANT {
return Err(
"Cannot override 'GENIE' variant using set_variant, use set_default instead",
);
}
self.configurations.insert(key, config);
Ok(())
}
pub fn set_default(&mut self, config: CodingAgent) {
self.configurations
.insert(GENIE_VARIANT.to_string(), config);
}
pub fn variant_names(&self) -> Vec<&String> {
self.configurations
.keys()
.filter(|k| *k != GENIE_VARIANT)
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
pub struct ExecutorConfigs {
pub executors: HashMap<BaseCodingAgent, ExecutorConfig>,
}
impl ExecutorConfigs {
fn canonicalise(&mut self) {
for profile in self.executors.values_mut() {
let mut replacements = Vec::new();
for key in profile.configurations.keys().cloned().collect::<Vec<_>>() {
let canon = canonical_variant_key(&key);
if canon != key {
replacements.push((key, canon));
}
}
for (old, new) in replacements {
if let Some(cfg) = profile.configurations.remove(&old) {
profile.configurations.entry(new).or_insert(cfg);
}
}
}
}
pub fn get_cached() -> ExecutorConfigs {
EXECUTOR_PROFILES_CACHE.read().unwrap().clone()
}
pub fn reload() {
let mut cache = EXECUTOR_PROFILES_CACHE.write().unwrap();
*cache = Self::load();
}
pub fn set_cached(configs: ExecutorConfigs) {
let mut cache = EXECUTOR_PROFILES_CACHE.write().unwrap();
*cache = configs;
}
pub fn load() -> Self {
let mut defaults = Self::from_defaults();
defaults.canonicalise();
tracing::debug!("Loaded default executor profiles (API-only mode, no file persistence)");
defaults
}
pub fn update_cache(&self) -> Result<(), ProfileError> {
let mut defaults = Self::from_defaults();
defaults.canonicalise();
let mut self_clone = self.clone();
self_clone.canonicalise();
let overrides = Self::compute_overrides(&defaults, &self_clone)?;
let merged = Self::merge_with_defaults(defaults, overrides.clone());
Self::validate_merged(&merged)?;
Self::set_cached(merged);
tracing::info!("Updated executor profiles cache (in-memory only, no file persistence)");
Ok(())
}
fn merge_with_defaults(mut defaults: Self, overrides: Self) -> Self {
for (executor_key, override_profile) in overrides.executors {
match defaults.executors.get_mut(&executor_key) {
Some(default_profile) => {
for (config_name, config) in override_profile.configurations {
default_profile.configurations.insert(config_name, config);
}
}
None => {
defaults.executors.insert(executor_key, override_profile);
}
}
}
defaults
}
fn compute_overrides(defaults: &Self, current: &Self) -> Result<Self, ProfileError> {
let mut overrides = Self {
executors: HashMap::new(),
};
for (executor_key, default_profile) in &defaults.executors {
if !current.executors.contains_key(executor_key) {
return Err(ProfileError::CannotDeleteExecutor {
executor: *executor_key,
});
}
let current_profile = ¤t.executors[executor_key];
for config_name in default_profile.configurations.keys() {
if !current_profile.configurations.contains_key(config_name) {
return Err(ProfileError::CannotDeleteBuiltInConfig {
executor: *executor_key,
variant: config_name.clone(),
});
}
}
}
for (executor_key, current_profile) in ¤t.executors {
if let Some(default_profile) = defaults.executors.get(executor_key) {
let mut override_configurations = HashMap::new();
for (config_name, current_config) in ¤t_profile.configurations {
if let Some(default_config) = default_profile.configurations.get(config_name) {
if current_config != default_config {
override_configurations
.insert(config_name.clone(), current_config.clone());
}
} else {
override_configurations.insert(config_name.clone(), current_config.clone());
}
}
if !override_configurations.is_empty() {
overrides.executors.insert(
*executor_key,
ExecutorConfig {
configurations: override_configurations,
},
);
}
} else {
overrides
.executors
.insert(*executor_key, current_profile.clone());
}
}
Ok(overrides)
}
fn validate_merged(merged: &Self) -> Result<(), ProfileError> {
for (executor_key, profile) in &merged.executors {
let default_config = profile.configurations.get(GENIE_VARIANT).ok_or_else(|| {
ProfileError::Validation(format!(
"Executor '{executor_key}' is missing required 'GENIE' configuration"
))
})?;
if BaseCodingAgent::from(default_config) != *executor_key {
return Err(ProfileError::Validation(format!(
"Executor key '{executor_key}' does not match the agent variant '{default_config}'"
)));
}
for config_name in profile.configurations.keys() {
if config_name.starts_with("__") {
return Err(ProfileError::Validation(format!(
"Configuration name '{config_name}' is reserved (starts with '__')"
)));
}
}
}
Ok(())
}
pub fn from_defaults() -> Self {
serde_json::from_str(DEFAULT_PROFILES_JSON).unwrap_or_else(|e| {
tracing::error!("Failed to parse embedded default_profiles.json: {}", e);
panic!("Default profiles v3 JSON is invalid")
})
}
pub fn get_coding_agent(&self, executor_profile_id: &ExecutorProfileId) -> Option<CodingAgent> {
self.executors
.get(&executor_profile_id.executor)
.and_then(|executor| {
executor.get_variant(
&executor_profile_id
.variant
.clone()
.unwrap_or(GENIE_VARIANT.to_string()),
)
})
.cloned()
}
pub fn get_coding_agent_or_default(
&self,
executor_profile_id: &ExecutorProfileId,
) -> CodingAgent {
self.get_coding_agent(executor_profile_id)
.unwrap_or_else(|| {
let mut default_executor_profile_id = executor_profile_id.clone();
default_executor_profile_id.variant = Some(GENIE_VARIANT.to_string());
self.get_coding_agent(&default_executor_profile_id)
.expect("No GENIE variant found")
})
}
pub async fn get_recommended_executor_profile(
&self,
) -> Result<ExecutorProfileId, ProfileError> {
for &base_agent in self.executors.keys() {
let profile_id = ExecutorProfileId::new(base_agent);
if let Some(coding_agent) = self.get_coding_agent(&profile_id)
&& coding_agent.check_availability().await
{
tracing::info!("Detected available executor: {}", base_agent);
return Ok(profile_id);
}
}
Err(ProfileError::NoAvailableExecutorProfile)
}
}
pub fn to_default_variant(id: &ExecutorProfileId) -> ExecutorProfileId {
ExecutorProfileId {
executor: id.executor,
variant: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_canonical_variant_key_default_to_genie() {
assert_eq!(canonical_variant_key("DEFAULT"), "GENIE");
assert_eq!(canonical_variant_key("default"), "GENIE");
assert_eq!(canonical_variant_key("DeFaUlT"), "GENIE");
}
#[test]
fn test_canonical_variant_key_genie_normalized() {
assert_eq!(canonical_variant_key("GENIE"), "GENIE");
assert_eq!(canonical_variant_key("genie"), "GENIE");
assert_eq!(canonical_variant_key("GeNiE"), "GENIE");
}
#[test]
fn test_canonical_variant_key_screaming_snake() {
assert_eq!(canonical_variant_key("PLAN"), "PLAN");
assert_eq!(canonical_variant_key("plan"), "PLAN");
assert_eq!(canonical_variant_key("PlanMode"), "PLAN_MODE");
assert_eq!(canonical_variant_key("plan-mode"), "PLAN_MODE");
assert_eq!(canonical_variant_key("router"), "ROUTER");
assert_eq!(canonical_variant_key("ROUTER"), "ROUTER");
}
}