use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::credential::{CredentialSource, CredentialStore};
use crate::skill::SkillInstallSpec;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(tag = "resolver", rename_all = "lowercase")]
pub enum CredentialResolver {
#[default]
None,
Secret {
store_key: String,
env_var: String,
},
OAuth {
store_key: String,
provider: String,
#[serde(default)]
scopes: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Integration {
pub id: String,
pub label: String,
#[serde(default)]
pub cli: Option<String>,
#[serde(default)]
pub install: Vec<SkillInstallSpec>,
#[serde(default)]
pub credential: CredentialResolver,
}
#[derive(Debug, Deserialize)]
struct RawIntegration {
id: String,
label: String,
#[serde(default)]
cli: Option<String>,
#[serde(default)]
install: Vec<SkillInstallSpec>,
#[serde(default)]
credential: CredentialResolver,
}
impl From<RawIntegration> for Integration {
fn from(r: RawIntegration) -> Self {
Self {
id: r.id,
label: r.label,
cli: r.cli,
install: r.install,
credential: r.credential,
}
}
}
#[derive(Debug, Deserialize)]
struct RegistryFile {
#[serde(default, rename = "integration")]
integrations: Vec<RawIntegration>,
}
#[derive(Debug, Clone, Default)]
pub struct IntegrationRegistry {
by_id: HashMap<String, Integration>,
}
impl IntegrationRegistry {
fn parse_file(text: &str) -> Result<Vec<Integration>> {
let file: RegistryFile = toml::from_str(text).context("parsing integrations TOML")?;
Ok(file.integrations.into_iter().map(Into::into).collect())
}
pub fn load_text(defaults_text: &str, override_dir: &Path) -> Result<Self> {
let mut by_id: HashMap<String, Integration> = HashMap::new();
for it in Self::parse_file(defaults_text)? {
by_id.insert(it.id.clone(), it);
}
Self::layer_overrides(&mut by_id, override_dir);
Ok(Self { by_id })
}
pub fn load(defaults_path: &Path, override_dir: &Path) -> Result<Self> {
let mut by_id: HashMap<String, Integration> = HashMap::new();
if let Ok(text) = std::fs::read_to_string(defaults_path) {
for it in Self::parse_file(&text)? {
by_id.insert(it.id.clone(), it);
}
}
Self::layer_overrides(&mut by_id, override_dir);
Ok(Self { by_id })
}
fn layer_overrides(by_id: &mut HashMap<String, Integration>, override_dir: &Path) {
let Ok(entries) = std::fs::read_dir(override_dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
if let Ok(text) = std::fs::read_to_string(&path) {
match Self::parse_file(&text) {
Ok(items) => {
for it in items {
by_id.insert(it.id.clone(), it);
}
}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "skipping invalid integrations override");
}
}
}
}
}
pub fn all(&self) -> Vec<&Integration> {
let mut ids: Vec<&String> = self.by_id.keys().collect();
ids.sort();
ids.iter().filter_map(|id| self.by_id.get(*id)).collect()
}
pub fn get(&self, id: &str) -> Option<&Integration> {
self.by_id.get(id)
}
pub fn cli_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.by_id.values().filter_map(|i| i.cli.clone()).collect();
names.sort();
names.dedup();
names
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialStatus {
pub configured: bool,
pub source: String,
}
impl CredentialStatus {
pub fn resolve(cred: &CredentialResolver) -> Self {
match cred {
CredentialResolver::None => Self {
configured: false,
source: "none".into(),
},
CredentialResolver::Secret { store_key, env_var } => {
match CredentialStore::resolve_secret(store_key, env_var) {
Some((_, src)) => Self {
configured: true,
source: source_label(&src),
},
None => Self {
configured: false,
source: "none".into(),
},
}
}
CredentialResolver::OAuth { store_key, .. } => {
let present = oxi_sdk::load_token(store_key)
.map(|t| t.is_some() && !t.unwrap().access_token.is_empty())
.unwrap_or(false);
Self {
configured: present,
source: if present {
"oauth".into()
} else {
"none".into()
},
}
}
}
}
}
fn source_label(s: &CredentialSource) -> String {
match s {
CredentialSource::Config => "config",
CredentialSource::OxiAuthStore => "auth_store",
CredentialSource::EnvVar => "env",
}
.into()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_TOML: &str = r#"
[[integration]]
id = "brew"
label = "Homebrew"
cli = "brew"
credential = { resolver = "none" }
[[integration]]
id = "resend"
label = "Resend"
cli = "resend"
install = [{ kind = "node", package = "resend" }]
credential = { resolver = "secret", store_key = "resend", env_var = "RESEND_API_KEY" }
[[integration]]
id = "github"
label = "GitHub CLI"
cli = "gh"
credential = { resolver = "oauth", store_key = "github", provider = "github", scopes = ["repo"] }
"#;
#[test]
fn parses_integration_toml() {
let items = IntegrationRegistry::parse_file(SAMPLE_TOML).unwrap();
assert_eq!(items.len(), 3);
let brew = items.iter().find(|i| i.id == "brew").unwrap();
assert_eq!(brew.credential, CredentialResolver::None);
let resend = items.iter().find(|i| i.id == "resend").unwrap();
match &resend.credential {
CredentialResolver::Secret { store_key, env_var } => {
assert_eq!(store_key, "resend");
assert_eq!(env_var, "RESEND_API_KEY");
}
other => panic!("expected Secret, got {other:?}"),
}
assert_eq!(resend.install.len(), 1);
let gh = items.iter().find(|i| i.id == "github").unwrap();
match &gh.credential {
CredentialResolver::OAuth {
provider, scopes, ..
} => {
assert_eq!(provider, "github");
assert_eq!(scopes, &["repo"]);
}
other => panic!("expected OAuth, got {other:?}"),
}
}
#[test]
fn shipped_registry_parses() {
let text = include_str!("../../share/default-integrations.toml");
let items = IntegrationRegistry::parse_file(text).unwrap();
assert!(items.iter().any(|integration| integration.id == "github"));
assert!(items.iter().any(|integration| integration.id == "resend"));
}
#[test]
fn cli_names_dedups() {
let reg = IntegrationRegistry {
by_id: SAMPLE_TOML
.parse::<RegistryFile>()
.unwrap()
.integrations
.into_iter()
.map(Into::into)
.map(|i: Integration| (i.id.clone(), i))
.collect(),
};
let names = reg.cli_names();
assert_eq!(names, vec!["brew", "gh", "resend"]);
}
#[test]
fn none_resolver_is_unconfigured() {
let s = CredentialStatus::resolve(&CredentialResolver::None);
assert!(!s.configured);
assert_eq!(s.source, "none");
}
}