Skip to main content

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
41/// System One provider configuration — the only tunable surface of the
42/// natural-language entry point (`grr ask`).
43///
44/// Provider-agnostic by design: any endpoint speaking the System One
45/// contract (POST `state` + typed `questions`, back structured `answers`)
46/// works by pointing `endpoint` and `model` at it. The defaults are
47/// TypeSafe's hosted endpoint and its flagship Jev model.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50#[derive(Default)]
51pub struct SystemOneConfig {
52    /// Endpoint URL. Any provider speaking the same contract works.
53    #[serde(default)]
54    pub endpoint: String,
55
56    /// Model name, e.g. "jev-latest".
57    #[serde(default)]
58    pub model: String,
59
60    /// API key, sent as `Authorization: Bearer`. Empty behaves as absent —
61    /// the same convention as the OAuth client secret. It must never be
62    /// committed; the env route (`TYPESAFE_API_KEY`) is preferred because
63    /// a key in a config file can leak with the file.
64    #[serde(default, alias = "api_key")]
65    pub api_key: Option<String>,
66
67    /// Method-choice confidence below which `grr ask` flags the answer.
68    /// Code owns the threshold; the model supplies the probability.
69    #[serde(default, alias = "confidence_threshold")]
70    pub confidence_threshold: Option<f64>,
71}
72
73/// TypeSafe's hosted System One endpoint — the default `endpoint`.
74pub const DEFAULT_SYSTEMONE_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
75
76/// TypeSafe's flagship System One model — the default `model`.
77pub const DEFAULT_SYSTEMONE_MODEL: &str = "jev-latest";
78
79/// The method-choice confidence below which `grr ask` flags its answer.
80/// A cookbook-style starting point, to be re-tuned on real requests.
81pub const DEFAULT_CONFIDENCE_THRESHOLD: f64 = 0.6;
82
83impl SystemOneConfig {
84    /// The endpoint URL, filling TypeSafe's default when unset or blank.
85    pub fn endpoint_or_default(&self) -> &str {
86        match self.endpoint.trim() {
87            "" => DEFAULT_SYSTEMONE_ENDPOINT,
88            trimmed => trimmed,
89        }
90    }
91
92    /// The model name, filling TypeSafe's Jev default when unset or blank.
93    pub fn model_or_default(&self) -> &str {
94        match self.model.trim() {
95            "" => DEFAULT_SYSTEMONE_MODEL,
96            trimmed => trimmed,
97        }
98    }
99
100    /// The trimmed API key, or `None` when absent or blank.
101    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    /// The method-choice confidence threshold.
109    pub fn threshold(&self) -> f64 {
110        self.confidence_threshold
111            .unwrap_or(DEFAULT_CONFIDENCE_THRESHOLD)
112    }
113}
114
115/// OAuth client compiled into the binary at build time, if any.
116///
117/// `build.rs` reads `GRR_CLIENT_ID` / `GRR_CLIENT_SECRET` from the build
118/// environment (or a repo-root `.env`) and re-exports them via
119/// `cargo:rustc-env`, which is what `option_env!` reads here. Release
120/// binaries are built in CI with both set, so end users need no OAuth
121/// client of their own; source builds fall back to `~/.grr/config.toml`.
122///
123/// A half-populated pair is treated as absent: Google rejects a token
124/// exchange that carries an id without its secret, so falling through to
125/// the actionable "no client configured" error beats a confusing 400
126/// from the token endpoint.
127pub(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
136/// Explain how to supply an OAuth client, for when none could be resolved.
137///
138/// Release binaries carry a compiled-in client, so this only fires for
139/// source builds (`cargo install`, or a local `cargo build` with no
140/// `.env`). It is written for someone who has never used Google Cloud
141/// before: name all three routes and let them pick.
142pub 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
158/// Explain how to supply a System One API key, for when none could be
159/// resolved. Written for someone who has never used TypeSafe: name the
160/// env route first (the key cannot be committed either way), then the
161/// config-file route, then the provider-agnostic escape hatch.
162pub 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        // Pre-zero-config configs carry [performance]/[output]/[runtime]
218        // sections; they must still parse, just ignored.
219        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        // [systemone] is the `grr ask` surface. Kebab-case keys are native;
237        // snake_case aliases keep TS-era spellings working, and a config
238        // with no [systemone] section at all must extract to defaults.
239        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}