1use std::collections::HashMap;
16use std::path::PathBuf;
17
18use serde::Deserialize;
19
20use crate::error::{ChalkClientError, Result};
21
22const DEFAULT_API_SERVER: &str = "https://api.chalk.ai";
24
25#[derive(Debug, Clone)]
29pub struct ChalkClientConfig {
30 pub client_id: String,
32
33 pub client_secret: String,
35
36 pub api_server: String,
38
39 pub environment: Option<String>,
41
42 pub branch_id: Option<String>,
44
45 pub deployment_tag: Option<String>,
47
48 pub query_server: Option<String>,
50}
51
52#[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 pub fn new() -> Self {
80 Self::default()
81 }
82
83 pub fn client_id(mut self, id: impl Into<String>) -> Self {
85 self.client_id = Some(id.into());
86 self
87 }
88
89 pub fn client_secret(mut self, secret: impl Into<String>) -> Self {
91 self.client_secret = Some(secret.into());
92 self
93 }
94
95 pub fn api_server(mut self, url: impl Into<String>) -> Self {
97 self.api_server = Some(url.into());
98 self
99 }
100
101 pub fn environment(mut self, env: impl Into<String>) -> Self {
103 self.environment = Some(env.into());
104 self
105 }
106
107 pub fn branch_id(mut self, id: impl Into<String>) -> Self {
109 self.branch_id = Some(id.into());
110 self
111 }
112
113 pub fn deployment_tag(mut self, tag: impl Into<String>) -> Self {
115 self.deployment_tag = Some(tag.into());
116 self
117 }
118
119 pub fn query_server(mut self, url: impl Into<String>) -> Self {
121 self.query_server = Some(url.into());
122 self
123 }
124
125 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
202fn get_env(key: &str) -> Option<String> {
204 std::env::var(key).ok().filter(|v| !v.is_empty())
205}
206
207#[derive(Debug, Deserialize)]
209struct YamlConfig {
210 #[serde(default)]
211 tokens: HashMap<String, YamlProjectToken>,
212}
213
214#[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
226fn 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 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 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 config.tokens.get("default").cloned()
257}
258
259fn 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
281fn 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
306pub(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 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 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 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 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}