1use std::{
15 collections::{HashMap, HashSet},
16 env, fs,
17 path::{Path, PathBuf},
18};
19
20use serde::{Deserialize, Deserializer};
21
22#[derive(Debug, Clone, Default)]
23pub struct LoadOptions {
24 pub litellm_config: Option<PathBuf>,
25}
26
27#[derive(Debug, Clone, Default)]
28pub struct LoadedConfig {
29 pub env: HashMap<String, String>,
30 pub litellm_models: Vec<LiteLlmModel>,
31 pub warnings: Vec<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct LiteLlmModel {
36 pub model_name: String,
37 pub provider: LiteLlmProvider,
38 pub upstream_model: String,
39 pub api_base: Option<String>,
40 pub api_key: Option<String>,
41 pub api_version: Option<String>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum LiteLlmProvider {
46 OpenAI,
47 Anthropic,
48 Ollama,
49}
50
51#[derive(Debug, Deserialize, Default)]
53pub struct ConfigFile {
54 #[serde(default)]
56 pub env: HashMap<String, String>,
57}
58
59pub fn load() -> LoadedConfig {
61 load_with_options(LoadOptions::default())
62}
63
64pub fn load_with_options(options: LoadOptions) -> LoadedConfig {
66 let current_dir = env::current_dir().unwrap_or_default();
67 let mut merged_env: HashMap<String, String> = env::vars().collect();
68 let mut warnings = Vec::new();
69
70 apply_dotenv(¤t_dir.join(".env"), &mut merged_env, &mut warnings);
72
73 if let Some(path) = user_config_path(&merged_env) {
74 apply_toml_file(&path, &mut merged_env, "user config", &mut warnings);
75 }
76 let project_dir = current_dir.join(".yallm");
77 apply_toml_file(
78 &project_dir.join("config.toml"),
79 &mut merged_env,
80 "project config",
81 &mut warnings,
82 );
83 apply_toml_glob(
84 &project_dir,
85 ".local.toml",
86 &mut merged_env,
87 "local override",
88 &mut warnings,
89 );
90 apply_toml_file(
91 &project_dir.join("secrets.toml"),
92 &mut merged_env,
93 "project secrets",
94 &mut warnings,
95 );
96
97 let litellm_path = options.litellm_config.or_else(|| {
98 merged_env
99 .get("YALLM_LITELLM_CONFIG")
100 .map(String::as_str)
101 .map(str::trim)
102 .filter(|s| !s.is_empty())
103 .map(PathBuf::from)
104 });
105
106 let litellm_models = litellm_path
107 .as_deref()
108 .map(|path| parse_litellm_config_file(path, &merged_env, &mut warnings))
109 .unwrap_or_default();
110
111 LoadedConfig {
112 env: merged_env,
113 litellm_models,
114 warnings,
115 }
116}
117
118fn apply_toml_file(
119 path: &Path,
120 env_map: &mut HashMap<String, String>,
121 label: &str,
122 warnings: &mut Vec<String>,
123) {
124 let raw = match fs::read_to_string(path) {
125 Ok(s) => s,
126 Err(_) => return,
127 };
128 let cfg: ConfigFile = match toml::from_str(&raw) {
129 Ok(c) => c,
130 Err(e) => {
131 warnings.push(format!("yallm-config: {label} parse error: {e}"));
132 return;
133 }
134 };
135 for (key, val) in cfg.env {
136 env_map.insert(key, val);
137 }
138}
139
140fn apply_dotenv(path: &Path, env_map: &mut HashMap<String, String>, warnings: &mut Vec<String>) {
141 let raw = match fs::read_to_string(path) {
142 Ok(s) => s,
143 Err(_) => return,
144 };
145 for (key, val) in parse_dotenv(&raw, warnings) {
146 env_map.entry(key).or_insert(val);
148 }
149}
150
151fn apply_toml_glob(
154 dir: &Path,
155 suffix: &str,
156 env_map: &mut HashMap<String, String>,
157 label: &str,
158 warnings: &mut Vec<String>,
159) {
160 let entries = match fs::read_dir(dir) {
161 Ok(e) => e,
162 Err(_) => return,
163 };
164 let mut files: Vec<PathBuf> = entries
165 .filter_map(Result::ok)
166 .map(|e| e.path())
167 .filter(|p| {
168 p.is_file()
169 && p.file_name()
170 .and_then(|n| n.to_str())
171 .is_some_and(|n| n.ends_with(suffix))
172 })
173 .collect();
174 files.sort();
175 for path in files {
176 apply_toml_file(&path, env_map, label, warnings);
177 }
178}
179
180fn parse_dotenv(raw: &str, warnings: &mut Vec<String>) -> HashMap<String, String> {
181 let mut out = HashMap::new();
182 for (idx, line) in raw.lines().enumerate() {
183 let line = line.trim();
184 if line.is_empty() || line.starts_with('#') {
185 continue;
186 }
187 let Some((key, val)) = line.split_once('=') else {
188 warnings.push(format!(
189 "yallm-config: ignored malformed .env line {}",
190 idx + 1
191 ));
192 continue;
193 };
194 let key = key.trim();
195 if key.is_empty() {
196 warnings.push(format!(
197 "yallm-config: ignored empty .env key on line {}",
198 idx + 1
199 ));
200 continue;
201 }
202 out.insert(key.to_string(), unquote_env_value(val.trim()));
203 }
204 out
205}
206
207fn unquote_env_value(value: &str) -> String {
208 if value.len() >= 2
209 && ((value.starts_with('"') && value.ends_with('"'))
210 || (value.starts_with('\'') && value.ends_with('\'')))
211 {
212 value[1..value.len() - 1].to_string()
213 } else {
214 value.to_string()
215 }
216}
217
218fn user_config_path(env_map: &HashMap<String, String>) -> Option<PathBuf> {
219 let home = env_map
220 .get("HOME")
221 .or_else(|| env_map.get("USERPROFILE"))
222 .filter(|s| !s.is_empty())?;
223 Some(PathBuf::from(home).join(".yallm").join("config.toml"))
224}
225
226fn parse_litellm_config_file(
227 path: &Path,
228 env_map: &HashMap<String, String>,
229 warnings: &mut Vec<String>,
230) -> Vec<LiteLlmModel> {
231 let raw = match fs::read_to_string(path) {
232 Ok(s) => s,
233 Err(e) => {
234 warnings.push(format!(
235 "yallm-config: failed to read LiteLLM config {}: {e}",
236 path.display()
237 ));
238 return Vec::new();
239 }
240 };
241 parse_litellm_config_str(&raw, env_map, warnings)
242}
243
244pub fn parse_litellm_config_str(
245 raw: &str,
246 env_map: &HashMap<String, String>,
247 warnings: &mut Vec<String>,
248) -> Vec<LiteLlmModel> {
249 let cfg: LiteLlmConfigFile = match serde_yaml_ng::from_str(raw) {
250 Ok(cfg) => cfg,
251 Err(e) => {
252 warnings.push(format!("yallm-config: LiteLLM config parse error: {e}"));
253 return Vec::new();
254 }
255 };
256
257 let mut seen = HashSet::new();
258 let mut out = Vec::new();
259 for entry in cfg.model_list {
260 let model_name = entry.model_name.unwrap_or_default().trim().to_string();
261 if model_name.is_empty() {
262 warnings.push("yallm-config: skipped LiteLLM model with empty model_name".to_string());
263 continue;
264 }
265 if model_name == "*" {
266 warnings.push("yallm-config: skipped LiteLLM wildcard model_name '*'".to_string());
267 continue;
268 }
269
270 let resolved = match entry.yallm_params {
272 Some(yp) => resolve_from_yallm_params(&model_name, yp, env_map, warnings),
273 None => {
274 resolve_from_litellm_params(&model_name, entry.litellm_params, env_map, warnings)
275 }
276 };
277 let Some(model) = resolved else {
278 continue;
279 };
280
281 if !seen.insert(model.model_name.clone()) {
282 warnings.push(format!(
283 "yallm-config: skipped duplicate model_name '{}'",
284 model.model_name
285 ));
286 continue;
287 }
288
289 out.push(model);
290 }
291 out
292}
293
294fn resolve_from_yallm_params(
295 model_name: &str,
296 params: YallmParams,
297 env_map: &HashMap<String, String>,
298 warnings: &mut Vec<String>,
299) -> Option<LiteLlmModel> {
300 let upstream_model = params.model.unwrap_or_default().trim().to_string();
301 if upstream_model.is_empty() || upstream_model == "*" {
302 warnings.push(format!(
303 "yallm-config: skipped model '{model_name}' with empty or wildcard yallm_params.model"
304 ));
305 return None;
306 }
307 let Some(provider_str) = params
309 .provider
310 .as_deref()
311 .map(str::trim)
312 .filter(|s| !s.is_empty())
313 else {
314 warnings.push(format!(
315 "yallm-config: skipped model '{model_name}' with missing yallm_params.provider"
316 ));
317 return None;
318 };
319 let Some(provider) = parse_yallm_provider(provider_str) else {
320 warnings.push(format!(
321 "yallm-config: skipped model '{model_name}' with unsupported yallm_params.provider '{provider_str}'"
322 ));
323 return None;
324 };
325
326 let api_base = params
327 .api_base
328 .as_deref()
329 .and_then(|v| resolve_config_value(v, env_map, model_name, "api_base", false, warnings));
330 let api_key = params
331 .api_key
332 .as_deref()
333 .and_then(|v| resolve_config_value(v, env_map, model_name, "api_key", true, warnings));
334 let api_version = params
335 .api_version
336 .as_deref()
337 .and_then(|v| resolve_config_value(v, env_map, model_name, "api_version", false, warnings));
338
339 Some(LiteLlmModel {
340 model_name: model_name.to_string(),
341 provider,
342 upstream_model,
343 api_base,
344 api_key,
345 api_version,
346 })
347}
348
349fn resolve_from_litellm_params(
350 model_name: &str,
351 params: LiteLlmParams,
352 env_map: &HashMap<String, String>,
353 warnings: &mut Vec<String>,
354) -> Option<LiteLlmModel> {
355 let litellm_model = params.model.unwrap_or_default();
356 if litellm_model.trim().is_empty() || litellm_model.trim() == "*" {
357 warnings.push(format!(
358 "yallm-config: skipped LiteLLM model '{model_name}' with empty or wildcard upstream model"
359 ));
360 return None;
361 }
362
363 let Some((provider, upstream_model)) =
364 infer_provider_and_model(&litellm_model, params.custom_llm_provider.as_deref())
365 else {
366 warnings.push(format!(
367 "yallm-config: skipped unsupported LiteLLM model '{model_name}' ({litellm_model})"
368 ));
369 return None;
370 };
371
372 let api_base = params
373 .api_base
374 .as_deref()
375 .and_then(|v| resolve_config_value(v, env_map, model_name, "api_base", false, warnings));
376 let api_key = params
377 .api_key
378 .as_deref()
379 .and_then(|v| resolve_config_value(v, env_map, model_name, "api_key", true, warnings));
380 let api_version = params
381 .api_version
382 .as_deref()
383 .and_then(|v| resolve_config_value(v, env_map, model_name, "api_version", false, warnings));
384
385 Some(LiteLlmModel {
386 model_name: model_name.to_string(),
387 provider,
388 upstream_model,
389 api_base,
390 api_key,
391 api_version,
392 })
393}
394
395fn parse_yallm_provider(s: &str) -> Option<LiteLlmProvider> {
396 match s.trim().to_ascii_lowercase().as_str() {
397 "openai" => Some(LiteLlmProvider::OpenAI),
398 "anthropic" => Some(LiteLlmProvider::Anthropic),
399 "ollama" => Some(LiteLlmProvider::Ollama),
400 _ => None,
401 }
402}
403
404fn infer_provider_and_model(
405 model: &str,
406 custom_provider: Option<&str>,
407) -> Option<(LiteLlmProvider, String)> {
408 let model = model.trim();
409 let model_prefix = model.split_once('/').map(|(prefix, _)| prefix);
410 let provider = custom_provider
411 .map(str::trim)
412 .filter(|s| !s.is_empty())
413 .or(model_prefix)
414 .unwrap_or("openai")
415 .to_ascii_lowercase();
416
417 match provider.as_str() {
418 "openai" | "openai_compatible" | "openai-compatible" | "openai_like" | "openai-like" => {
419 Some((LiteLlmProvider::OpenAI, strip_model_prefix(model, "openai")))
420 }
421 "anthropic" => Some((
422 LiteLlmProvider::Anthropic,
423 strip_model_prefix(model, "anthropic"),
424 )),
425 "ollama" => Some((LiteLlmProvider::Ollama, strip_model_prefix(model, "ollama"))),
426 _ => None,
427 }
428}
429
430fn strip_model_prefix(model: &str, provider: &str) -> String {
431 model
432 .strip_prefix(&format!("{provider}/"))
433 .unwrap_or(model)
434 .to_string()
435}
436
437fn resolve_config_value(
438 value: &str,
439 env_map: &HashMap<String, String>,
440 model_name: &str,
441 field: &str,
442 warn_literal_secret: bool,
443 warnings: &mut Vec<String>,
444) -> Option<String> {
445 let value = value.trim();
446 if value.is_empty() || value.eq_ignore_ascii_case("none") {
447 return None;
448 }
449
450 if let Some(name) = value.strip_prefix("os.environ/") {
451 return env_lookup(name, env_map, model_name, field, warnings);
452 }
453
454 if let Some(name) = value.strip_prefix("${").and_then(|v| v.strip_suffix('}')) {
455 return env_lookup(name, env_map, model_name, field, warnings);
456 }
457
458 if warn_literal_secret {
459 warnings.push(format!(
460 "yallm-config: LiteLLM model '{model_name}' uses a literal {field}; prefer os.environ/VAR"
461 ));
462 }
463 Some(value.to_string())
464}
465
466fn env_lookup(
467 name: &str,
468 env_map: &HashMap<String, String>,
469 model_name: &str,
470 field: &str,
471 warnings: &mut Vec<String>,
472) -> Option<String> {
473 let name = name.trim();
474 match env_map.get(name).map(String::as_str).map(str::trim) {
475 Some(value) if !value.is_empty() => Some(value.to_string()),
476 _ => {
477 warnings.push(format!(
478 "yallm-config: LiteLLM model '{model_name}' references missing env var {name} for {field}"
479 ));
480 None
481 }
482 }
483}
484
485#[derive(Debug, Deserialize, Default)]
486struct LiteLlmConfigFile {
487 #[serde(default)]
488 model_list: Vec<LiteLlmEntry>,
489}
490
491#[derive(Debug, Deserialize, Default)]
492struct LiteLlmEntry {
493 #[serde(default, deserialize_with = "deserialize_optional_string")]
494 model_name: Option<String>,
495 #[serde(default)]
499 yallm_params: Option<YallmParams>,
500 #[serde(default)]
504 litellm_params: LiteLlmParams,
505}
506
507#[derive(Debug, Deserialize, Default)]
508struct YallmParams {
509 #[serde(default, deserialize_with = "deserialize_optional_string")]
510 provider: Option<String>,
511 #[serde(default, deserialize_with = "deserialize_optional_string")]
512 model: Option<String>,
513 #[serde(default, deserialize_with = "deserialize_optional_string")]
514 api_base: Option<String>,
515 #[serde(default, deserialize_with = "deserialize_optional_string")]
516 api_key: Option<String>,
517 #[serde(default, deserialize_with = "deserialize_optional_string")]
518 api_version: Option<String>,
519}
520
521#[derive(Debug, Deserialize, Default)]
522struct LiteLlmParams {
523 #[serde(default, deserialize_with = "deserialize_optional_string")]
524 model: Option<String>,
525 #[serde(default, deserialize_with = "deserialize_optional_string")]
526 api_base: Option<String>,
527 #[serde(default, deserialize_with = "deserialize_optional_string")]
528 api_key: Option<String>,
529 #[serde(default, deserialize_with = "deserialize_optional_string")]
530 api_version: Option<String>,
531 #[serde(default, deserialize_with = "deserialize_optional_string")]
532 custom_llm_provider: Option<String>,
533}
534
535fn deserialize_optional_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
536where
537 D: Deserializer<'de>,
538{
539 let value = Option::<serde_yaml_ng::Value>::deserialize(deserializer)?;
540 Ok(value.and_then(yaml_value_to_string))
541}
542
543fn yaml_value_to_string(value: serde_yaml_ng::Value) -> Option<String> {
544 match value {
545 serde_yaml_ng::Value::Null => None,
546 serde_yaml_ng::Value::Bool(v) => Some(v.to_string()),
547 serde_yaml_ng::Value::Number(v) => Some(v.to_string()),
548 serde_yaml_ng::Value::String(v) => Some(v),
549 _ => None,
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn parse_empty_toml() {
559 let c: ConfigFile = toml::from_str("").unwrap();
560 assert!(c.env.is_empty());
561 }
562
563 #[test]
564 fn parse_env_section() {
565 let c: ConfigFile = toml::from_str(
566 r#"
567[env]
568ANTHROPIC_API_KEY = "sk-ant-test"
569YALLM_MODE = "proxy"
570"#,
571 )
572 .unwrap();
573 assert_eq!(c.env.get("ANTHROPIC_API_KEY").unwrap(), "sk-ant-test");
574 assert_eq!(c.env.get("YALLM_MODE").unwrap(), "proxy");
575 }
576
577 #[test]
578 fn dotenv_parses_simple_values() {
579 let mut warnings = Vec::new();
580 let env = parse_dotenv(
581 r#"TEST_DOTENV_KEY=dotenv_value
582ANOTHER_KEY="quoted val"
583"#,
584 &mut warnings,
585 );
586
587 assert_eq!(env.get("TEST_DOTENV_KEY").unwrap(), "dotenv_value");
588 assert_eq!(env.get("ANOTHER_KEY").unwrap(), "quoted val");
589 assert!(warnings.is_empty());
590 }
591
592 #[test]
593 fn litellm_parses_supported_models_and_env_key() {
594 let mut env = HashMap::new();
595 env.insert("OPENAI_KEY".to_string(), "sk-env".to_string());
596 let mut warnings = Vec::new();
597 let models = parse_litellm_config_str(
598 r#"
599model_list:
600 - model_name: gpt-alias
601 litellm_params:
602 model: openai/gpt-4o
603 api_base: https://openai-compatible.test/v1
604 api_key: os.environ/OPENAI_KEY
605 ignored_field: true
606 - model_name: claude-alias
607 litellm_params:
608 model: anthropic/claude-3-haiku-20240307
609 api_key: none
610 - model_name: llama-alias
611 litellm_params:
612 model: ollama/llama3
613"#,
614 &env,
615 &mut warnings,
616 );
617
618 assert_eq!(models.len(), 3);
619 assert_eq!(models[0].model_name, "gpt-alias");
620 assert_eq!(models[0].provider, LiteLlmProvider::OpenAI);
621 assert_eq!(models[0].upstream_model, "gpt-4o");
622 assert_eq!(models[0].api_key.as_deref(), Some("sk-env"));
623 assert_eq!(models[1].provider, LiteLlmProvider::Anthropic);
624 assert_eq!(models[1].api_key, None);
625 assert_eq!(models[2].provider, LiteLlmProvider::Ollama);
626 assert!(warnings.is_empty());
627 }
628
629 #[test]
630 fn litellm_warns_for_literal_api_key() {
631 let mut warnings = Vec::new();
632 let models = parse_litellm_config_str(
633 r#"
634model_list:
635 - model_name: literal
636 litellm_params:
637 model: gpt-4o
638 api_key: sk-literal
639"#,
640 &HashMap::new(),
641 &mut warnings,
642 );
643
644 assert_eq!(models.len(), 1);
645 assert_eq!(models[0].api_key.as_deref(), Some("sk-literal"));
646 assert!(warnings.iter().any(|w| w.contains("literal api_key")));
647 }
648
649 #[test]
650 fn litellm_missing_env_reference_leaves_key_unset() {
651 let mut warnings = Vec::new();
652 let models = parse_litellm_config_str(
653 r#"
654model_list:
655 - model_name: missing
656 litellm_params:
657 model: gpt-4o
658 api_key: ${MISSING_OPENAI_KEY}
659"#,
660 &HashMap::new(),
661 &mut warnings,
662 );
663
664 assert_eq!(models.len(), 1);
665 assert_eq!(models[0].api_key, None);
666 assert!(warnings.iter().any(|w| w.contains("MISSING_OPENAI_KEY")));
667 }
668
669 #[test]
670 fn litellm_skips_unsupported_and_duplicate_models() {
671 let mut warnings = Vec::new();
672 let models = parse_litellm_config_str(
673 r#"
674model_list:
675 - model_name: gpt
676 litellm_params:
677 model: gpt-4o
678 - model_name: gpt
679 litellm_params:
680 model: openai/gpt-4o-mini
681 - model_name: azure-gpt
682 litellm_params:
683 model: azure/gpt-4o
684 api_key: os.environ/AZURE_API_KEY
685 extra: ignored
686 - model_name: mixed
687 litellm_params:
688 model: azure/gpt-4o
689 - model_name: mixed
690 litellm_params:
691 model: gpt-4o
692"#,
693 &HashMap::new(),
694 &mut warnings,
695 );
696
697 assert_eq!(models.len(), 2);
698 assert_eq!(models[0].model_name, "gpt");
699 assert_eq!(models[1].model_name, "mixed");
700 assert!(warnings.iter().any(|w| w.contains("duplicate")));
701 assert!(warnings.iter().any(|w| w.contains("unsupported")));
702 }
703
704 #[test]
705 fn yallm_params_takes_priority_over_litellm_params() {
706 let mut env = HashMap::new();
708 env.insert("YALLM_KEY".to_string(), "yallm-secret".to_string());
709 env.insert("LITELLM_KEY".to_string(), "litellm-secret".to_string());
710 let mut warnings = Vec::new();
711 let models = parse_litellm_config_str(
712 r#"
713model_list:
714 - model_name: dual
715 yallm_params:
716 provider: anthropic
717 model: claude-yallm
718 api_base: https://yallm.test
719 api_key: os.environ/YALLM_KEY
720 api_version: "2025-01-01"
721 litellm_params:
722 model: openai/gpt-litellm
723 api_base: https://litellm.test
724 api_key: os.environ/LITELLM_KEY
725 api_version: "2020-01-01"
726"#,
727 &env,
728 &mut warnings,
729 );
730
731 assert_eq!(models.len(), 1);
732 assert_eq!(models[0].provider, LiteLlmProvider::Anthropic);
733 assert_eq!(models[0].upstream_model, "claude-yallm");
734 assert_eq!(models[0].api_base.as_deref(), Some("https://yallm.test"));
735 assert_eq!(models[0].api_key.as_deref(), Some("yallm-secret"));
736 assert_eq!(models[0].api_version.as_deref(), Some("2025-01-01"));
737 assert!(warnings.is_empty());
738 }
739
740 #[test]
741 fn yallm_params_requires_explicit_provider() {
742 let mut warnings = Vec::new();
743 let models = parse_litellm_config_str(
744 r#"
745model_list:
746 - model_name: bad
747 yallm_params:
748 model: gpt-4o
749"#,
750 &HashMap::new(),
751 &mut warnings,
752 );
753
754 assert_eq!(models.len(), 0);
755 assert!(
756 warnings
757 .iter()
758 .any(|w| w.contains("missing yallm_params.provider"))
759 );
760 }
761
762 #[test]
763 fn yallm_params_rejects_unknown_provider() {
764 let mut warnings = Vec::new();
765 let models = parse_litellm_config_str(
766 r#"
767model_list:
768 - model_name: nope
769 yallm_params:
770 provider: bedrock
771 model: anthropic.claude-3
772"#,
773 &HashMap::new(),
774 &mut warnings,
775 );
776
777 assert_eq!(models.len(), 0);
778 assert!(warnings.iter().any(|w| w.contains("'bedrock'")));
779 }
780
781 #[test]
782 fn entry_without_yallm_params_falls_back_to_litellm_params() {
783 let mut warnings = Vec::new();
784 let models = parse_litellm_config_str(
785 r#"
786model_list:
787 - model_name: legacy
788 litellm_params:
789 model: anthropic/claude-x
790"#,
791 &HashMap::new(),
792 &mut warnings,
793 );
794
795 assert_eq!(models.len(), 1);
796 assert_eq!(models[0].provider, LiteLlmProvider::Anthropic);
797 assert_eq!(models[0].upstream_model, "claude-x");
798 }
799
800 #[test]
801 fn dotenv_does_not_override_existing_env() {
802 let dir = tmpdir("yallm_dotenv_no_override");
803 let env_path = dir.join(".env");
804 fs::write(&env_path, "FOO=from_dotenv\nBAR=from_dotenv\n").unwrap();
805
806 let mut env_map = HashMap::new();
807 env_map.insert("FOO".to_string(), "from_os".to_string());
808 let mut warnings = Vec::new();
809 apply_dotenv(&env_path, &mut env_map, &mut warnings);
810
811 assert_eq!(env_map.get("FOO").unwrap(), "from_os");
812 assert_eq!(env_map.get("BAR").unwrap(), "from_dotenv");
813 assert!(warnings.is_empty());
814 let _ = fs::remove_dir_all(&dir);
815 }
816
817 #[test]
818 fn toml_glob_applies_local_overrides_in_order() {
819 let dir = tmpdir("yallm_toml_glob");
820 fs::write(
821 dir.join("01.local.toml"),
822 "[env]\nFOO = \"layer1\"\nBAR = \"layer1\"\n",
823 )
824 .unwrap();
825 fs::write(dir.join("02.local.toml"), "[env]\nFOO = \"layer2\"\n").unwrap();
826 fs::write(dir.join("ignore.toml"), "[env]\nFOO = \"ignored\"\n").unwrap();
828
829 let mut env_map = HashMap::new();
830 let mut warnings = Vec::new();
831 apply_toml_glob(&dir, ".local.toml", &mut env_map, "local", &mut warnings);
832
833 assert_eq!(env_map.get("FOO").unwrap(), "layer2");
834 assert_eq!(env_map.get("BAR").unwrap(), "layer1");
835 assert!(warnings.is_empty());
836 let _ = fs::remove_dir_all(&dir);
837 }
838
839 #[test]
840 fn secrets_toml_overrides_config_toml() {
841 let dir = tmpdir("yallm_secrets_layer");
842 fs::write(
843 dir.join("config.toml"),
844 "[env]\nANTHROPIC_API_KEY = \"public\"\n",
845 )
846 .unwrap();
847 fs::write(
848 dir.join("secrets.toml"),
849 "[env]\nANTHROPIC_API_KEY = \"sk-real\"\n",
850 )
851 .unwrap();
852
853 let mut env_map = HashMap::new();
854 let mut warnings = Vec::new();
855 apply_toml_file(
856 &dir.join("config.toml"),
857 &mut env_map,
858 "project",
859 &mut warnings,
860 );
861 apply_toml_file(
862 &dir.join("secrets.toml"),
863 &mut env_map,
864 "secrets",
865 &mut warnings,
866 );
867
868 assert_eq!(env_map.get("ANTHROPIC_API_KEY").unwrap(), "sk-real");
869 let _ = fs::remove_dir_all(&dir);
870 }
871
872 fn tmpdir(name: &str) -> PathBuf {
873 let nonce = std::time::SystemTime::now()
874 .duration_since(std::time::UNIX_EPOCH)
875 .map(|d| d.as_nanos())
876 .unwrap_or(0);
877 let dir = env::temp_dir().join(format!("{name}_{nonce}_{}", std::process::id()));
878 fs::create_dir_all(&dir).unwrap();
879 dir
880 }
881}