1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12#[serde(rename_all = "kebab-case")]
13pub struct GrrConfig {
14 #[serde(default)]
15 pub oauth: OAuthConfig,
16
17 #[serde(default)]
18 pub systemone: SystemOneConfig,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28#[derive(Default)]
29pub struct OAuthConfig {
30 #[serde(alias = "client_id")]
31 pub client_id: String,
32
33 #[serde(default, alias = "client_secret")]
38 pub client_secret: Option<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50#[derive(Default)]
51pub struct SystemOneConfig {
52 #[serde(default)]
54 pub endpoint: String,
55
56 #[serde(default)]
58 pub model: String,
59
60 #[serde(default, alias = "api_key")]
65 pub api_key: Option<String>,
66
67 #[serde(default, alias = "confidence_threshold")]
70 pub confidence_threshold: Option<f64>,
71}
72
73pub const DEFAULT_SYSTEMONE_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
75
76pub const DEFAULT_SYSTEMONE_MODEL: &str = "jev-latest";
78
79pub const DEFAULT_CONFIDENCE_THRESHOLD: f64 = 0.6;
82
83impl SystemOneConfig {
84 pub fn endpoint_or_default(&self) -> &str {
86 match self.endpoint.trim() {
87 "" => DEFAULT_SYSTEMONE_ENDPOINT,
88 trimmed => trimmed,
89 }
90 }
91
92 pub fn model_or_default(&self) -> &str {
94 match self.model.trim() {
95 "" => DEFAULT_SYSTEMONE_MODEL,
96 trimmed => trimmed,
97 }
98 }
99
100 pub fn bearer_key(&self) -> Option<&str> {
102 self.api_key
103 .as_deref()
104 .map(str::trim)
105 .filter(|key| !key.is_empty())
106 }
107
108 pub fn threshold(&self) -> f64 {
110 self.confidence_threshold
111 .unwrap_or(DEFAULT_CONFIDENCE_THRESHOLD)
112 }
113}
114
115pub(crate) fn embedded_oauth_client() -> Option<(&'static str, &'static str)> {
128 let id = option_env!("GRR_CLIENT_ID")?.trim();
129 let secret = option_env!("GRR_CLIENT_SECRET")?.trim();
130 if id.is_empty() || secret.is_empty() {
131 return None;
132 }
133 Some((id, secret))
134}
135
136pub const NO_CLIENT_HELP: &str = "\
143no OAuth client is configured.
144
145Pick whichever fits:
146
147 1. Download a release build — it ships with a client already compiled in,
148 so `grr auth login` just works:
149 https://grr-cli.pages.dev/install/
150
151 2. Building from source? Put your client in a .env file next to
152 Cargo.toml (see .env.example), then rebuild.
153
154 3. Or create your own OAuth client and run `grr auth setup`, which walks
155 you through it and writes ~/.grr/config.toml for you. Takes about
156 five minutes: https://grr-cli.pages.dev/install/";
157
158pub const NO_API_KEY_HELP: &str = "\
163no System One API key is configured.
164
165Pick whichever fits:
166
167 1. Set the TYPESAFE_API_KEY environment variable (recommended: the key
168 never sits in a file that can be committed).
169
170 2. Or put it in ~/.grr/config.toml:
171
172 [systemone]
173 api-key = \"...\"
174
175 3. Or point [systemone] endpoint/model at any provider speaking the
176 same System One contract (its own key rules then apply).";
177
178#[cfg(test)]
179mod config_compat_tests {
180 use super::*;
181 use figment::{
182 Figment,
183 providers::{Format, Toml},
184 };
185
186 #[test]
187 fn oauth_accepts_snake_case_keys_from_ts_era_configs() {
188 let toml = r#"
189[oauth]
190client_id = "id-123"
191client_secret = "secret-456"
192"#;
193 let config = Figment::new()
194 .merge(Toml::string(toml))
195 .extract::<GrrConfig>()
196 .unwrap_or_else(|_| GrrConfig::default());
197 assert_eq!(config.oauth.client_id, "id-123");
198 assert_eq!(config.oauth.client_secret.as_deref(), Some("secret-456"));
199 }
200
201 #[test]
202 fn oauth_accepts_kebab_case_keys() {
203 let toml = r#"
204[oauth]
205client-id = "id-123"
206client-secret = "secret-456"
207"#;
208 let config = Figment::new()
209 .merge(Toml::string(toml))
210 .extract::<GrrConfig>()
211 .unwrap_or_else(|_| GrrConfig::default());
212 assert_eq!(config.oauth.client_id, "id-123");
213 }
214
215 #[test]
216 fn legacy_config_sections_are_ignored() {
217 let toml = r#"
220[oauth]
221client-id = "id-123"
222[performance]
223max-concurrent = 99
224[cache]
225cache-dir = "/tmp/x"
226"#;
227 let config = Figment::new()
228 .merge(Toml::string(toml))
229 .extract::<GrrConfig>()
230 .unwrap_or_else(|_| GrrConfig::default());
231 assert_eq!(config.oauth.client_id, "id-123");
232 }
233
234 #[test]
235 fn systemone_section_parses_with_defaults_and_snake_case_aliases() {
236 let toml = r#"
240[systemone]
241endpoint = "https://s1.example.com/v1/systemone"
242model = "my-jev-fork"
243api_key = "key-123"
244confidence_threshold = 0.75
245"#;
246 let config = Figment::new()
247 .merge(Toml::string(toml))
248 .extract::<GrrConfig>()
249 .unwrap_or_else(|_| GrrConfig::default());
250 assert_eq!(
251 config.systemone.endpoint,
252 "https://s1.example.com/v1/systemone"
253 );
254 assert_eq!(config.systemone.model, "my-jev-fork");
255 assert_eq!(config.systemone.bearer_key(), Some("key-123"));
256 assert_eq!(config.systemone.confidence_threshold, Some(0.75));
257
258 let empty = GrrConfig::default();
259 assert_eq!(
260 empty.systemone.endpoint_or_default(),
261 crate::core::config::DEFAULT_SYSTEMONE_ENDPOINT
262 );
263 assert_eq!(empty.systemone.model_or_default(), "jev-latest");
264 assert_eq!(empty.systemone.bearer_key(), None);
265 assert!((empty.systemone.threshold() - 0.6).abs() < f64::EPSILON);
266 }
267}