Skip to main content

chalk_client/
config.rs

1//! Configuration resolution for the Chalk client.
2//!
3//! Chalk credentials and endpoints can come from three places (checked in
4//! this order):
5//!
6//! 1. **Explicit values** passed to the builder (highest priority).
7//! 2. **Environment variables** like `CHALK_CLIENT_ID` and `_CHALK_CLIENT_ID`.
8//! 3. **`~/.chalk.yml`** — a YAML file written by `chalk login`.
9//! 4. **Defaults** — e.g. the API server defaults to `https://api.chalk.ai`.
10//!
11//! This module implements the builder pattern — you create a
12//! [`ChalkClientConfigBuilder`](crate::config::ChalkClientConfigBuilder), set the fields you know, call `.build()`,
13//! and the builder fills in the rest from env/file/defaults.
14
15use std::collections::HashMap;
16use std::path::PathBuf;
17
18use serde::Deserialize;
19
20use crate::error::{ChalkClientError, Result};
21
22/// The default Chalk API server URL.
23const DEFAULT_API_SERVER: &str = "https://api.chalk.ai";
24
25/// Holds all resolved configuration needed to connect to Chalk.
26///
27/// You don't construct this directly — use [`ChalkClientConfigBuilder`].
28#[derive(Debug, Clone)]
29pub struct ChalkClientConfig {
30    /// OAuth2 client ID (required).
31    pub client_id: String,
32
33    /// OAuth2 client secret (required).
34    pub client_secret: String,
35
36    /// The API server URL (e.g. `https://api.chalk.ai`).
37    pub api_server: String,
38
39    /// The target environment (e.g. `"production"` or an environment ID).
40    pub environment: Option<String>,
41
42    /// A branch ID for branch deployments.
43    pub branch_id: Option<String>,
44
45    /// A deployment tag for routing to specific deployments.
46    pub deployment_tag: Option<String>,
47
48    /// Override for the query server URL.
49    pub query_server: Option<String>,
50}
51
52/// A builder for [`ChalkClientConfig`].
53///
54/// ## Example
55///
56/// ```rust,no_run
57/// use chalk_client::config::ChalkClientConfigBuilder;
58///
59/// let config = ChalkClientConfigBuilder::new()
60///     .client_id("my-client-id")
61///     .client_secret("my-secret")
62///     .environment("production")
63///     .build()
64///     .expect("failed to build config");
65/// ```
66#[derive(Debug, Default)]
67pub struct ChalkClientConfigBuilder {
68    client_id: Option<String>,
69    client_secret: Option<String>,
70    api_server: Option<String>,
71    environment: Option<String>,
72    branch_id: Option<String>,
73    deployment_tag: Option<String>,
74    query_server: Option<String>,
75}
76
77impl ChalkClientConfigBuilder {
78    /// Create a new, empty builder.
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    /// Set the OAuth2 client ID.
84    pub fn client_id(mut self, id: impl Into<String>) -> Self {
85        self.client_id = Some(id.into());
86        self
87    }
88
89    /// Set the OAuth2 client secret.
90    pub fn client_secret(mut self, secret: impl Into<String>) -> Self {
91        self.client_secret = Some(secret.into());
92        self
93    }
94
95    /// Set the API server URL.
96    pub fn api_server(mut self, url: impl Into<String>) -> Self {
97        self.api_server = Some(url.into());
98        self
99    }
100
101    /// Set the target environment.
102    pub fn environment(mut self, env: impl Into<String>) -> Self {
103        self.environment = Some(env.into());
104        self
105    }
106
107    /// Set the branch ID.
108    pub fn branch_id(mut self, id: impl Into<String>) -> Self {
109        self.branch_id = Some(id.into());
110        self
111    }
112
113    /// Set the deployment tag.
114    pub fn deployment_tag(mut self, tag: impl Into<String>) -> Self {
115        self.deployment_tag = Some(tag.into());
116        self
117    }
118
119    /// Set the query server URL directly (skips engine-map resolution).
120    pub fn query_server(mut self, url: impl Into<String>) -> Self {
121        self.query_server = Some(url.into());
122        self
123    }
124
125    /// Resolve all configuration and produce a [`ChalkClientConfig`].
126    ///
127    /// Returns an error if `client_id` or `client_secret` cannot be found
128    /// from any source.
129    pub fn build(self) -> Result<ChalkClientConfig> {
130        let yaml_config = load_yaml_config();
131
132        let client_id = self
133            .client_id
134            .or_else(|| get_env("CHALK_CLIENT_ID"))
135            .or_else(|| get_env("_CHALK_CLIENT_ID"))
136            .or_else(|| yaml_config.as_ref().map(|c| c.client_id.clone()))
137            .ok_or_else(|| {
138                ChalkClientError::Config(
139                    "client_id is required — set it explicitly, via CHALK_CLIENT_ID env var, \
140                     or by running `chalk login`"
141                        .into(),
142                )
143            })?;
144
145        let client_secret = self
146            .client_secret
147            .or_else(|| get_env("CHALK_CLIENT_SECRET"))
148            .or_else(|| get_env("_CHALK_CLIENT_SECRET"))
149            .or_else(|| yaml_config.as_ref().map(|c| c.client_secret.clone()))
150            .ok_or_else(|| {
151                ChalkClientError::Config(
152                    "client_secret is required — set it explicitly, via CHALK_CLIENT_SECRET \
153                     env var, or by running `chalk login`"
154                        .into(),
155                )
156            })?;
157
158        let api_server = self
159            .api_server
160            .or_else(|| get_env("CHALK_API_SERVER"))
161            .or_else(|| get_env("_CHALK_API_SERVER"))
162            .or_else(|| yaml_config.as_ref().and_then(|c| c.api_server.clone()))
163            .unwrap_or_else(|| DEFAULT_API_SERVER.to_string());
164
165        let environment = self
166            .environment
167            .or_else(|| get_env("CHALK_ACTIVE_ENVIRONMENT"))
168            .or_else(|| get_env("_CHALK_ACTIVE_ENVIRONMENT"))
169            .or_else(|| {
170                yaml_config
171                    .as_ref()
172                    .and_then(|c| c.active_environment.clone())
173            });
174
175        let branch_id = self
176            .branch_id
177            .or_else(|| get_env("CHALK_BRANCH_ID"))
178            .or_else(|| get_env("_CHALK_BRANCH_ID"));
179
180        let deployment_tag = self
181            .deployment_tag
182            .or_else(|| get_env("CHALK_DEPLOYMENT_TAG"))
183            .or_else(|| get_env("_CHALK_DEPLOYMENT_TAG"));
184
185        let query_server = self
186            .query_server
187            .or_else(|| get_env("CHALK_QUERY_SERVER"))
188            .or_else(|| get_env("_CHALK_QUERY_SERVER"));
189
190        Ok(ChalkClientConfig {
191            client_id,
192            client_secret,
193            api_server,
194            environment,
195            branch_id,
196            deployment_tag,
197            query_server,
198        })
199    }
200}
201
202/// Read an environment variable, returning `None` if it's unset or empty.
203fn get_env(key: &str) -> Option<String> {
204    std::env::var(key).ok().filter(|v| !v.is_empty())
205}
206
207/// The top-level structure of `~/.chalk.yml`.
208#[derive(Debug, Deserialize)]
209struct YamlConfig {
210    #[serde(default)]
211    tokens: HashMap<String, YamlProjectToken>,
212}
213
214/// Credentials for a single project in the YAML config.
215#[derive(Debug, Deserialize)]
216#[serde(rename_all = "camelCase")]
217struct YamlProjectToken {
218    client_id: String,
219    client_secret: String,
220    #[serde(default)]
221    api_server: Option<String>,
222    #[serde(default)]
223    active_environment: Option<String>,
224}
225
226/// Try to find and parse `~/.chalk.yml` (or `~/.chalk.yaml`).
227fn load_yaml_config() -> Option<YamlProjectToken> {
228    let home = dirs::home_dir()?;
229    let path = find_config_file(&home)?;
230    let contents = std::fs::read_to_string(&path).ok()?;
231    let config: YamlConfig = serde_yaml::from_str(&contents).ok()?;
232
233    // 1. Try a directory containing chalk.yml/chalk.yaml (project marker).
234    if let Some(root) = find_project_root() {
235        if let Some(token) = config.tokens.get(&root) {
236            return Some(token.clone());
237        }
238    }
239
240    // 2. Walk up from CWD looking for a matching key in ~/.chalk.yml.
241    //    This handles `chalk login` / `chalkadmin customer login`, which
242    //    store credentials keyed by the working directory.
243    if let Ok(mut dir) = std::env::current_dir() {
244        loop {
245            let key = dir.to_string_lossy().into_owned();
246            if let Some(token) = config.tokens.get(&key) {
247                return Some(token.clone());
248            }
249            if !dir.pop() {
250                break;
251            }
252        }
253    }
254
255    // 3. Fall back to the "default" key.
256    config.tokens.get("default").cloned()
257}
258
259/// Look for `.chalk.yml` or `.chalk.yaml` in the home directory.
260fn find_config_file(home: &std::path::Path) -> Option<PathBuf> {
261    let yml = home.join(".chalk.yml");
262    if yml.exists() {
263        return Some(yml);
264    }
265
266    let yaml = home.join(".chalk.yaml");
267    if yaml.exists() {
268        return Some(yaml);
269    }
270
271    if let Some(xdg) = get_env("XDG_CONFIG_HOME") {
272        let xdg_path = PathBuf::from(xdg).join(".chalk.yml");
273        if xdg_path.exists() {
274            return Some(xdg_path);
275        }
276    }
277
278    None
279}
280
281/// Walk up from the current directory looking for `chalk.yml` or `chalk.yaml`.
282fn find_project_root() -> Option<String> {
283    let mut dir = std::env::current_dir().ok()?;
284    loop {
285        if dir.join("chalk.yml").exists() || dir.join("chalk.yaml").exists() {
286            return Some(dir.to_string_lossy().into_owned());
287        }
288        if !dir.pop() {
289            break;
290        }
291    }
292    None
293}
294
295impl Clone for YamlProjectToken {
296    fn clone(&self) -> Self {
297        Self {
298            client_id: self.client_id.clone(),
299            client_secret: self.client_secret.clone(),
300            api_server: self.api_server.clone(),
301            active_environment: self.active_environment.clone(),
302        }
303    }
304}
305
306/// Prepend `https://` if the URL has no scheme. The token response may
307/// return bare hostnames for engine URLs (e.g. `host.chalk.ai:443`).
308pub(crate) fn ensure_scheme(url: String) -> String {
309    if url.starts_with("http://") || url.starts_with("https://") || url.is_empty() {
310        url
311    } else {
312        format!("https://{}", url)
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use std::sync::Mutex;
320
321    /// Tests in this module mutate process-global env vars (`CHALK_*`, `HOME`).
322    /// Acquiring this lock before touching env state prevents races when cargo
323    /// runs tests in parallel.
324    static ENV_LOCK: Mutex<()> = Mutex::new(());
325
326    const CHALK_ENV_VARS: &[&str] = &[
327        "CHALK_CLIENT_ID",
328        "_CHALK_CLIENT_ID",
329        "CHALK_CLIENT_SECRET",
330        "_CHALK_CLIENT_SECRET",
331        "CHALK_API_SERVER",
332        "_CHALK_API_SERVER",
333        "CHALK_ACTIVE_ENVIRONMENT",
334        "_CHALK_ACTIVE_ENVIRONMENT",
335        "CHALK_BRANCH_ID",
336        "_CHALK_BRANCH_ID",
337        "CHALK_DEPLOYMENT_TAG",
338        "_CHALK_DEPLOYMENT_TAG",
339        "CHALK_QUERY_SERVER",
340        "_CHALK_QUERY_SERVER",
341    ];
342
343    fn clear_chalk_env() {
344        for var in CHALK_ENV_VARS {
345            std::env::remove_var(var);
346        }
347    }
348
349    #[test]
350    fn test_builder_explicit_values() {
351        let config = ChalkClientConfigBuilder::new()
352            .client_id("test-id")
353            .client_secret("test-secret")
354            .api_server("https://custom.chalk.ai")
355            .environment("staging")
356            .branch_id("branch-1")
357            .deployment_tag("canary")
358            .query_server("https://query.chalk.ai")
359            .build()
360            .unwrap();
361
362        assert_eq!(config.client_id, "test-id");
363        assert_eq!(config.client_secret, "test-secret");
364        assert_eq!(config.api_server, "https://custom.chalk.ai");
365        assert_eq!(config.environment.as_deref(), Some("staging"));
366        assert_eq!(config.branch_id.as_deref(), Some("branch-1"));
367        assert_eq!(config.deployment_tag.as_deref(), Some("canary"));
368        assert_eq!(
369            config.query_server.as_deref(),
370            Some("https://query.chalk.ai")
371        );
372    }
373
374    #[test]
375    fn test_builder_default_api_server() {
376        let _lock = ENV_LOCK.lock().unwrap();
377        clear_chalk_env();
378
379        let config = ChalkClientConfigBuilder::new()
380            .client_id("id")
381            .client_secret("secret")
382            .api_server(DEFAULT_API_SERVER)
383            .build()
384            .unwrap();
385
386        assert_eq!(config.api_server, DEFAULT_API_SERVER);
387    }
388
389    #[test]
390    fn test_builder_missing_credentials() {
391        let _lock = ENV_LOCK.lock().unwrap();
392        clear_chalk_env();
393        // Use a real but empty temp dir so dirs::home_dir() resolves it
394        // but no .chalk.yml exists inside it.
395        let tmp = std::env::temp_dir().join("chalk_test_no_yaml_creds");
396        let _ = std::fs::create_dir_all(&tmp);
397        let original_home = std::env::var("HOME").ok();
398        std::env::set_var("HOME", &tmp);
399
400        // Missing client_id
401        let result = ChalkClientConfigBuilder::new()
402            .client_secret("secret")
403            .build();
404        assert!(result.is_err());
405        assert!(result.unwrap_err().to_string().contains("client_id"));
406
407        // Missing client_secret
408        let result = ChalkClientConfigBuilder::new()
409            .client_id("id")
410            .build();
411        assert!(result.is_err());
412        assert!(result.unwrap_err().to_string().contains("client_secret"));
413
414        if let Some(h) = original_home {
415            std::env::set_var("HOME", h);
416        }
417        let _ = std::fs::remove_dir_all(&tmp);
418    }
419
420    #[test]
421    fn test_get_env_helper() {
422        let var = "_CHALK_TEST_GET_ENV_HELPER";
423
424        std::env::remove_var(var);
425        assert_eq!(get_env(var), None);
426
427        std::env::set_var(var, "");
428        assert_eq!(get_env(var), None);
429
430        std::env::set_var(var, "hello");
431        assert_eq!(get_env(var), Some("hello".to_string()));
432
433        std::env::remove_var(var);
434    }
435
436    #[test]
437    fn test_builder_explicit_overrides_env() {
438        let _lock = ENV_LOCK.lock().unwrap();
439        clear_chalk_env();
440
441        std::env::set_var("CHALK_CLIENT_ID", "env-id");
442        std::env::set_var("CHALK_CLIENT_SECRET", "env-secret");
443
444        let config = ChalkClientConfigBuilder::new()
445            .client_id("explicit-id")
446            .client_secret("explicit-secret")
447            .build()
448            .unwrap();
449
450        assert_eq!(config.client_id, "explicit-id");
451        assert_eq!(config.client_secret, "explicit-secret");
452
453        clear_chalk_env();
454    }
455
456    #[test]
457    fn test_yaml_config_parsing() {
458        let yaml = r#"
459tokens:
460  default:
461    clientId: "yaml-id"
462    clientSecret: "yaml-secret"
463    apiServer: "https://yaml.chalk.ai"
464    activeEnvironment: "yaml-env"
465"#;
466
467        let config: YamlConfig = serde_yaml::from_str(yaml).unwrap();
468        let token = config.tokens.get("default").unwrap();
469
470        assert_eq!(token.client_id, "yaml-id");
471        assert_eq!(token.client_secret, "yaml-secret");
472        assert_eq!(token.api_server.as_deref(), Some("https://yaml.chalk.ai"));
473        assert_eq!(token.active_environment.as_deref(), Some("yaml-env"));
474    }
475}