grr_cli/core/config.rs
1//! Configuration types for grr-core.
2//!
3//! Zero-config by design: the only thing a user must supply is OAuth
4//! credentials. Every performance knob (concurrency, pooling, timeouts,
5//! compression, transport) is a compile-time constant — see
6//! [`crate::core::http`].
7
8use serde::{Deserialize, Serialize};
9
10/// Main configuration for the client
11#[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/// OAuth2 configuration.
22///
23/// Accepts both kebab-case (Rust-native) and snake_case (TypeScript-era
24/// `config.toml` migration) key spellings. Redirect URI and scopes are
25/// compile-time constants — there is nothing to tune here on purpose.
26#[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 /// Optional client secret. Sent at the token endpoint only when
34 /// present and non-empty. PKCE-only providers (no secret issued)
35 /// work with this absent; providers that mandate a secret
36 /// (currently including Google, even for Desktop clients) need it.
37 #[serde(default, alias = "client_secret")]
38 pub client_secret: Option<String>,
39
40 /// Client for the RFC 8628 device flow (`grr auth login --device`).
41 /// Google's device endpoint accepts a single client type — "TVs and
42 /// Limited Input devices" — and rejects the Desktop client above with
43 /// `invalid_client: Invalid client type`, so the device flow carries
44 /// credentials of its own. Absent means the flow refuses locally with
45 /// setup instructions instead of failing at Google.
46 #[serde(default, alias = "device_client_id")]
47 pub device_client_id: Option<String>,
48
49 /// Secret for [`Self::device_client_id`], sent when present.
50 #[serde(default, alias = "device_client_secret")]
51 pub device_client_secret: Option<String>,
52}
53
54/// System One provider configuration — the only tunable surface of the
55/// natural-language entry point (`grr ask`).
56///
57/// Provider-agnostic by design: any endpoint speaking the System One
58/// contract (POST `state` + typed `questions`, back structured `answers`)
59/// works by pointing `endpoint` and `model` at it. The defaults are
60/// TypeSafe's hosted endpoint and its flagship Jev model.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case")]
63#[derive(Default)]
64pub struct SystemOneConfig {
65 /// Endpoint URL. Any provider speaking the same contract works.
66 #[serde(default)]
67 pub endpoint: String,
68
69 /// Model name, e.g. "jev-latest".
70 #[serde(default)]
71 pub model: String,
72
73 /// API key, sent as `Authorization: Bearer`. Empty behaves as absent —
74 /// the same convention as the OAuth client secret. It must never be
75 /// committed; the env route (`TYPESAFE_API_KEY`) is preferred because
76 /// a key in a config file can leak with the file.
77 #[serde(default, alias = "api_key")]
78 pub api_key: Option<String>,
79
80 /// Method-choice confidence below which `grr ask` flags the answer.
81 /// Code owns the threshold; the model supplies the probability.
82 #[serde(default, alias = "confidence_threshold")]
83 pub confidence_threshold: Option<f64>,
84}
85
86/// TypeSafe's hosted System One endpoint — the default `endpoint`.
87pub const DEFAULT_SYSTEMONE_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
88
89/// TypeSafe's flagship System One model — the default `model`.
90pub const DEFAULT_SYSTEMONE_MODEL: &str = "jev-latest";
91
92/// The method-choice confidence below which `grr ask` flags its answer.
93/// A cookbook-style starting point, to be re-tuned on real requests.
94pub const DEFAULT_CONFIDENCE_THRESHOLD: f64 = 0.6;
95
96impl SystemOneConfig {
97 /// The endpoint URL, filling TypeSafe's default when unset or blank.
98 pub fn endpoint_or_default(&self) -> &str {
99 match self.endpoint.trim() {
100 "" => DEFAULT_SYSTEMONE_ENDPOINT,
101 trimmed => trimmed,
102 }
103 }
104
105 /// The model name, filling TypeSafe's Jev default when unset or blank.
106 pub fn model_or_default(&self) -> &str {
107 match self.model.trim() {
108 "" => DEFAULT_SYSTEMONE_MODEL,
109 trimmed => trimmed,
110 }
111 }
112
113 /// The trimmed API key, or `None` when absent or blank.
114 pub fn bearer_key(&self) -> Option<&str> {
115 self.api_key
116 .as_deref()
117 .map(str::trim)
118 .filter(|key| !key.is_empty())
119 }
120
121 /// The method-choice confidence threshold.
122 pub fn threshold(&self) -> f64 {
123 self.confidence_threshold
124 .unwrap_or(DEFAULT_CONFIDENCE_THRESHOLD)
125 }
126}
127
128/// OAuth client compiled into the binary at build time, if any.
129///
130/// `build.rs` reads `GRR_CLIENT_ID` / `GRR_CLIENT_SECRET` from the build
131/// environment (or a repo-root `.env`) and re-exports them via
132/// `cargo:rustc-env`, which is what `option_env!` reads here. Release
133/// binaries are built in CI with both set, so end users need no OAuth
134/// client of their own; source builds fall back to `~/.grr/config.toml`.
135///
136/// A half-populated pair is treated as absent: Google rejects a token
137/// exchange that carries an id without its secret, so falling through to
138/// the actionable "no client configured" error beats a confusing 400
139/// from the token endpoint.
140pub(crate) fn embedded_oauth_client() -> Option<(&'static str, &'static str)> {
141 let id = option_env!("GRR_CLIENT_ID")?.trim();
142 let secret = option_env!("GRR_CLIENT_SECRET")?.trim();
143 if id.is_empty() || secret.is_empty() {
144 return None;
145 }
146 Some((id, secret))
147}
148
149/// The device-flow client compiled in at build time, from
150/// `GRR_DEVICE_CLIENT_ID` / `GRR_DEVICE_CLIENT_SECRET` — the same route as
151/// the browser client above (process environment or repo-root `.env`,
152/// re-exported by `build.rs`). Deliberately separate credentials: Google's
153/// device endpoint takes one client type ("TVs and Limited Input devices")
154/// and refuses the Desktop client, and a build that ships only the Desktop
155/// client keeps working — `grr auth login --device` then explains what to
156/// create instead of failing at Google.
157///
158/// Half a pair is treated as absent, exactly like the browser client.
159pub(crate) fn embedded_device_client() -> Option<(&'static str, &'static str)> {
160 let id = option_env!("GRR_DEVICE_CLIENT_ID")?.trim();
161 let secret = option_env!("GRR_DEVICE_CLIENT_SECRET")?.trim();
162 if id.is_empty() || secret.is_empty() {
163 return None;
164 }
165 Some((id, secret))
166}
167
168/// Explain how to supply an OAuth client, for when none could be resolved.
169///
170/// Release binaries carry a compiled-in client, so this only fires for
171/// source builds (`cargo install`, or a local `cargo build` with no
172/// `.env`). It is written for someone who has never used Google Cloud
173/// before: name all three routes and let them pick.
174pub const NO_CLIENT_HELP: &str = "\
175no OAuth client is configured.
176
177Pick whichever fits:
178
179 1. Download a release build — it ships with a client already compiled in,
180 so `grr auth login` just works:
181 https://grr-cli.pages.dev/install/
182
183 2. Building from source? Put your client in a .env file next to
184 Cargo.toml (see .env.example), then rebuild.
185
186 3. Or create your own OAuth client and run `grr auth setup`, which walks
187 you through it and writes ~/.grr/config.toml for you. Takes about
188 five minutes: https://grr-cli.pages.dev/install/";
189
190/// Explain how to supply a System One API key, for when none could be
191/// resolved. Written for someone who has never used TypeSafe: name the
192/// env route first (the key cannot be committed either way), then the
193/// config-file route, then the provider-agnostic escape hatch.
194pub const NO_API_KEY_HELP: &str = "\
195no System One API key is configured.
196
197Pick whichever fits:
198
199 1. Set the TYPESAFE_API_KEY environment variable (recommended: the key
200 never sits in a file that can be committed).
201
202 2. Or put it in ~/.grr/config.toml:
203
204 [systemone]
205 api-key = \"...\"
206
207 3. Or point [systemone] endpoint/model at any provider speaking the
208 same System One contract (its own key rules then apply).";
209
210#[cfg(test)]
211mod config_compat_tests {
212 use super::*;
213 use figment::{
214 Figment,
215 providers::{Format, Toml},
216 };
217
218 #[test]
219 fn oauth_accepts_snake_case_keys_from_ts_era_configs() {
220 let toml = r#"
221[oauth]
222client_id = "id-123"
223client_secret = "secret-456"
224"#;
225 let config = Figment::new()
226 .merge(Toml::string(toml))
227 .extract::<GrrConfig>()
228 .unwrap_or_else(|_| GrrConfig::default());
229 assert_eq!(config.oauth.client_id, "id-123");
230 assert_eq!(config.oauth.client_secret.as_deref(), Some("secret-456"));
231 }
232
233 #[test]
234 fn oauth_accepts_kebab_case_keys() {
235 let toml = r#"
236[oauth]
237client-id = "id-123"
238client-secret = "secret-456"
239"#;
240 let config = Figment::new()
241 .merge(Toml::string(toml))
242 .extract::<GrrConfig>()
243 .unwrap_or_else(|_| GrrConfig::default());
244 assert_eq!(config.oauth.client_id, "id-123");
245 }
246
247 #[test]
248 fn legacy_config_sections_are_ignored() {
249 // Pre-zero-config configs carry [performance]/[output]/[runtime]
250 // sections; they must still parse, just ignored.
251 let toml = r#"
252[oauth]
253client-id = "id-123"
254[performance]
255max-concurrent = 99
256[cache]
257cache-dir = "/tmp/x"
258"#;
259 let config = Figment::new()
260 .merge(Toml::string(toml))
261 .extract::<GrrConfig>()
262 .unwrap_or_else(|_| GrrConfig::default());
263 assert_eq!(config.oauth.client_id, "id-123");
264 }
265
266 #[test]
267 fn systemone_section_parses_with_defaults_and_snake_case_aliases() {
268 // [systemone] is the `grr ask` surface. Kebab-case keys are native;
269 // snake_case aliases keep TS-era spellings working, and a config
270 // with no [systemone] section at all must extract to defaults.
271 let toml = r#"
272[systemone]
273endpoint = "https://s1.example.com/v1/systemone"
274model = "my-jev-fork"
275api_key = "key-123"
276confidence_threshold = 0.75
277"#;
278 let config = Figment::new()
279 .merge(Toml::string(toml))
280 .extract::<GrrConfig>()
281 .unwrap_or_else(|_| GrrConfig::default());
282 assert_eq!(
283 config.systemone.endpoint,
284 "https://s1.example.com/v1/systemone"
285 );
286 assert_eq!(config.systemone.model, "my-jev-fork");
287 assert_eq!(config.systemone.bearer_key(), Some("key-123"));
288 assert_eq!(config.systemone.confidence_threshold, Some(0.75));
289
290 let empty = GrrConfig::default();
291 assert_eq!(
292 empty.systemone.endpoint_or_default(),
293 crate::core::config::DEFAULT_SYSTEMONE_ENDPOINT
294 );
295 assert_eq!(empty.systemone.model_or_default(), "jev-latest");
296 assert_eq!(empty.systemone.bearer_key(), None);
297 assert!((empty.systemone.threshold() - 0.6).abs() < f64::EPSILON);
298 }
299}