use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct GrrConfig {
#[serde(default)]
pub oauth: OAuthConfig,
#[serde(default)]
pub systemone: SystemOneConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub struct OAuthConfig {
#[serde(alias = "client_id")]
pub client_id: String,
#[serde(default, alias = "client_secret")]
pub client_secret: Option<String>,
#[serde(default, alias = "device_client_id")]
pub device_client_id: Option<String>,
#[serde(default, alias = "device_client_secret")]
pub device_client_secret: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub struct SystemOneConfig {
#[serde(default)]
pub endpoint: String,
#[serde(default)]
pub model: String,
#[serde(default, alias = "api_key")]
pub api_key: Option<String>,
#[serde(default, alias = "confidence_threshold")]
pub confidence_threshold: Option<f64>,
}
pub const DEFAULT_SYSTEMONE_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
pub const DEFAULT_SYSTEMONE_MODEL: &str = "jev-latest";
pub const DEFAULT_CONFIDENCE_THRESHOLD: f64 = 0.6;
impl SystemOneConfig {
pub fn endpoint_or_default(&self) -> &str {
match self.endpoint.trim() {
"" => DEFAULT_SYSTEMONE_ENDPOINT,
trimmed => trimmed,
}
}
pub fn model_or_default(&self) -> &str {
match self.model.trim() {
"" => DEFAULT_SYSTEMONE_MODEL,
trimmed => trimmed,
}
}
pub fn bearer_key(&self) -> Option<&str> {
self.api_key
.as_deref()
.map(str::trim)
.filter(|key| !key.is_empty())
}
pub fn threshold(&self) -> f64 {
self.confidence_threshold
.unwrap_or(DEFAULT_CONFIDENCE_THRESHOLD)
}
}
pub(crate) fn embedded_oauth_client() -> Option<(&'static str, &'static str)> {
let id = option_env!("GRR_CLIENT_ID")?.trim();
let secret = option_env!("GRR_CLIENT_SECRET")?.trim();
if id.is_empty() || secret.is_empty() {
return None;
}
Some((id, secret))
}
pub(crate) fn embedded_device_client() -> Option<(&'static str, &'static str)> {
let id = option_env!("GRR_DEVICE_CLIENT_ID")?.trim();
let secret = option_env!("GRR_DEVICE_CLIENT_SECRET")?.trim();
if id.is_empty() || secret.is_empty() {
return None;
}
Some((id, secret))
}
pub const NO_CLIENT_HELP: &str = "\
no OAuth client is configured.
Pick whichever fits:
1. Download a release build — it ships with a client already compiled in,
so `grr auth login` just works:
https://grr-cli.pages.dev/install/
2. Building from source? Put your client in a .env file next to
Cargo.toml (see .env.example), then rebuild.
3. Or create your own OAuth client and run `grr auth setup`, which walks
you through it and writes ~/.grr/config.toml for you. Takes about
five minutes: https://grr-cli.pages.dev/install/";
pub const NO_API_KEY_HELP: &str = "\
no System One API key is configured.
Pick whichever fits:
1. Set the TYPESAFE_API_KEY environment variable (recommended: the key
never sits in a file that can be committed).
2. Or put it in ~/.grr/config.toml:
[systemone]
api-key = \"...\"
3. Or point [systemone] endpoint/model at any provider speaking the
same System One contract (its own key rules then apply).";
#[cfg(test)]
mod config_compat_tests {
use super::*;
use figment::{
Figment,
providers::{Format, Toml},
};
#[test]
fn oauth_accepts_snake_case_keys_from_ts_era_configs() {
let toml = r#"
[oauth]
client_id = "id-123"
client_secret = "secret-456"
"#;
let config = Figment::new()
.merge(Toml::string(toml))
.extract::<GrrConfig>()
.unwrap_or_else(|_| GrrConfig::default());
assert_eq!(config.oauth.client_id, "id-123");
assert_eq!(config.oauth.client_secret.as_deref(), Some("secret-456"));
}
#[test]
fn oauth_accepts_kebab_case_keys() {
let toml = r#"
[oauth]
client-id = "id-123"
client-secret = "secret-456"
"#;
let config = Figment::new()
.merge(Toml::string(toml))
.extract::<GrrConfig>()
.unwrap_or_else(|_| GrrConfig::default());
assert_eq!(config.oauth.client_id, "id-123");
}
#[test]
fn legacy_config_sections_are_ignored() {
let toml = r#"
[oauth]
client-id = "id-123"
[performance]
max-concurrent = 99
[cache]
cache-dir = "/tmp/x"
"#;
let config = Figment::new()
.merge(Toml::string(toml))
.extract::<GrrConfig>()
.unwrap_or_else(|_| GrrConfig::default());
assert_eq!(config.oauth.client_id, "id-123");
}
#[test]
fn systemone_section_parses_with_defaults_and_snake_case_aliases() {
let toml = r#"
[systemone]
endpoint = "https://s1.example.com/v1/systemone"
model = "my-jev-fork"
api_key = "key-123"
confidence_threshold = 0.75
"#;
let config = Figment::new()
.merge(Toml::string(toml))
.extract::<GrrConfig>()
.unwrap_or_else(|_| GrrConfig::default());
assert_eq!(
config.systemone.endpoint,
"https://s1.example.com/v1/systemone"
);
assert_eq!(config.systemone.model, "my-jev-fork");
assert_eq!(config.systemone.bearer_key(), Some("key-123"));
assert_eq!(config.systemone.confidence_threshold, Some(0.75));
let empty = GrrConfig::default();
assert_eq!(
empty.systemone.endpoint_or_default(),
crate::core::config::DEFAULT_SYSTEMONE_ENDPOINT
);
assert_eq!(empty.systemone.model_or_default(), "jev-latest");
assert_eq!(empty.systemone.bearer_key(), None);
assert!((empty.systemone.threshold() - 0.6).abs() < f64::EPSILON);
}
}