use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::Config;
pub const KNOWN_TECHNIQUES: &[&str] = &[
"knowledge_base", "verify_gate", "retry", ];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ProfileConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub techniques: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify_gate: Option<VerifyGateKnobs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry: Option<RetryKnobs>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct VerifyGateKnobs {
#[serde(default)]
pub surface_match: crate::verify_gate::SurfaceMatch,
#[serde(default)]
pub tier: crate::verify_gate::VerifyTier,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryKnobs {
#[serde(default = "default_max_retries")]
pub max_retries: u32,
}
const fn default_max_retries() -> u32 {
2
}
impl Default for RetryKnobs {
fn default() -> Self {
Self {
max_retries: default_max_retries(),
}
}
}
impl ProfileConfig {
pub fn validate(&self) -> std::result::Result<(), String> {
for t in &self.techniques {
let Some(entry) = crate::kit::component(t) else {
return Err(format!(
"unknown technique '{t}' in profile (known: {})",
KNOWN_TECHNIQUES.join(", ")
));
};
for pre in entry.presupposes {
if !self.techniques.iter().any(|x| x == pre) {
return Err(format!(
"technique '{t}' presupposes '{pre}', which the profile does not enable"
));
}
}
}
Ok(())
}
#[must_use]
pub fn enables(&self, technique: &str) -> bool {
self.techniques.iter().any(|t| t == technique)
}
#[must_use]
pub fn verify_gate_knobs(&self) -> VerifyGateKnobs {
self.verify_gate.unwrap_or_default()
}
#[must_use]
pub fn retry_knobs(&self) -> RetryKnobs {
self.retry.unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct BundleConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applies_to: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_profile: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub families: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfilePick {
pub name: String,
pub via: PickVia,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickVia {
Profile,
Bundle(String),
InferredBundle(String),
}
impl Config {
pub fn resolve_profile(&self, name: &str) -> std::result::Result<&ProfileConfig, String> {
let profile = self.profiles.get(name).ok_or_else(|| {
let known = if self.profiles.is_empty() {
"none defined".to_string()
} else {
self.profiles.keys().cloned().collect::<Vec<_>>().join(", ")
};
format!("no such profile (known: {known})")
})?;
profile.validate()?;
Ok(profile)
}
pub fn resolve_bundle(&self, name: &str) -> std::result::Result<&BundleConfig, String> {
self.bundles.get(name).ok_or_else(|| {
let known = if self.bundles.is_empty() {
"none defined".to_string()
} else {
self.bundles.keys().cloned().collect::<Vec<_>>().join(", ")
};
format!("no such bundle (known: {known})")
})
}
#[must_use]
pub fn bundle_profile_for_family<'a>(
&self,
bundle: &'a BundleConfig,
family: Option<&str>,
) -> Option<&'a str> {
family
.and_then(|fam| {
bundle
.families
.iter()
.find(|(key, _)| key.as_str() == fam)
.map(|(_, p)| p.as_str())
})
.or(bundle.default_profile.as_deref())
}
#[must_use]
pub fn infer_bundle_for_family(&self, family: Option<&str>) -> Option<(&str, &BundleConfig)> {
let fam = family?;
self.bundles
.iter()
.find(|(_, b)| b.applies_to.iter().any(|a| a == fam))
.map(|(name, b)| (name.as_str(), b))
}
pub fn pick_active_profile(
&self,
profile_flag: Option<&str>,
bundle_flag: Option<&str>,
family: Option<&str>,
) -> std::result::Result<Option<ProfilePick>, String> {
if let Some(p) = profile_flag.filter(|s| !s.is_empty()) {
return Ok(Some(ProfilePick {
name: p.to_string(),
via: PickVia::Profile,
}));
}
if let Some(b) = bundle_flag.filter(|s| !s.is_empty()) {
let bundle = self.resolve_bundle(b)?;
return Ok(self
.bundle_profile_for_family(bundle, family)
.map(|p| ProfilePick {
name: p.to_string(),
via: PickVia::Bundle(b.to_string()),
}));
}
if let Some((name, bundle)) = self.infer_bundle_for_family(family) {
return Ok(self
.bundle_profile_for_family(bundle, family)
.map(|p| ProfilePick {
name: p.to_string(),
via: PickVia::InferredBundle(name.to_string()),
}));
}
Ok(None)
}
pub(super) fn merge_disk_bundles(&mut self) {
if let Some(dir) = Self::user_config_dir() {
self.merge_bundles_from_dir(&dir.join("bundles"));
}
if let Some(proj) = Self::project_config_path() {
if let Some(parent) = proj.parent() {
self.merge_bundles_from_dir(&parent.join("bundles"));
}
}
}
pub(super) fn merge_bundles_from_dir(&mut self, dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return; };
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
paths.sort();
for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
match std::fs::read_to_string(&path).map(|t| toml::from_str::<BundleConfig>(&t)) {
Ok(Ok(bundle)) => {
self.bundles.insert(stem.to_string(), bundle);
}
Ok(Err(e)) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed bundle file");
}
Err(_) => {}
}
}
}
}