1use std::collections::BTreeMap;
37use std::io::Write;
38use std::path::{Path, PathBuf};
39
40use rpi_ai::{Api, InputModality, Model, StreamingProtocolCompat};
41
42pub const CONFIG_DIR_NAME: &str = ".rpi";
44
45pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
48
49pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
52
53#[derive(Debug, thiserror::Error)]
60pub enum ConfigError {
61 #[error("could not resolve home directory (set {env} to override)")]
62 NoHomeDir { env: &'static str },
63 #[error("config dir override {env}={val:?} is not an absolute path")]
64 RelativeOverride { env: &'static str, val: String },
65 #[error("could not read {path}: {source}")]
66 Read {
67 path: PathBuf,
68 #[source]
69 source: std::io::Error,
70 },
71 #[error("could not write {path}: {source}")]
72 Write {
73 path: PathBuf,
74 #[source]
75 source: std::io::Error,
76 },
77 #[error("invalid JSON in {path}: {source}")]
78 Json {
79 path: PathBuf,
80 #[source]
81 source: serde_json::Error,
82 },
83}
84
85pub fn agent_dir() -> Result<PathBuf, ConfigError> {
94 if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
95 let p = PathBuf::from(&val);
96 if !p.is_absolute() {
97 return Err(ConfigError::RelativeOverride {
98 env: CONFIG_DIR_ENV,
99 val: val.to_string_lossy().into_owned(),
100 });
101 }
102 return Ok(p);
103 }
104 let home = dirs::home_dir().ok_or(ConfigError::NoHomeDir {
105 env: CONFIG_DIR_ENV,
106 })?;
107 Ok(home.join(CONFIG_DIR_NAME).join("agent"))
108}
109
110fn config_root_dir() -> Result<PathBuf, ConfigError> {
114 let agent = agent_dir()?;
115 agent
116 .parent()
117 .map(Path::to_path_buf)
118 .ok_or(ConfigError::NoHomeDir {
119 env: CONFIG_DIR_ENV,
120 })
121}
122
123pub fn auth_path() -> Result<PathBuf, ConfigError> {
125 Ok(agent_dir()?.join("auth.json"))
126}
127
128pub fn models_path() -> Result<PathBuf, ConfigError> {
130 Ok(agent_dir()?.join("models.json"))
131}
132
133pub fn settings_path() -> Result<PathBuf, ConfigError> {
135 Ok(agent_dir()?.join("settings.json"))
136}
137
138pub fn trust_path() -> Result<PathBuf, ConfigError> {
140 Ok(agent_dir()?.join("trust.json"))
141}
142
143fn read_text_with_fallback(
144 primary: PathBuf,
145 fallback: Option<PathBuf>,
146) -> Result<Option<(PathBuf, String)>, ConfigError> {
147 match std::fs::read_to_string(&primary) {
148 Ok(text) => Ok(Some((primary, text))),
149 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
150 let Some(fallback) = fallback.filter(|path| path != &primary) else {
151 return Ok(None);
152 };
153 match std::fs::read_to_string(&fallback) {
154 Ok(text) => Ok(Some((fallback, text))),
155 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
156 Err(source) => Err(ConfigError::Read {
157 path: fallback,
158 source,
159 }),
160 }
161 }
162 Err(source) => Err(ConfigError::Read {
163 path: primary,
164 source,
165 }),
166 }
167}
168
169pub fn migrate_legacy_layout() -> Result<usize, ConfigError> {
177 if std::env::var_os(CONFIG_DIR_ENV).is_some() {
179 return Ok(0);
180 }
181 let root = match config_root_dir() {
182 Ok(p) => p,
183 Err(_) => return Ok(0),
184 };
185 let agent = agent_dir()?;
186 migrate_legacy_layout_in(&root, &agent)
187}
188
189fn migrate_legacy_layout_in(root: &Path, agent: &Path) -> Result<usize, ConfigError> {
196 if agent.exists() {
198 return Ok(0);
199 }
200 let flat_auth = root.join("auth.json");
202 let flat_models = root.join("models.json");
203 if !flat_auth.exists() && !flat_models.exists() {
204 return Ok(0);
205 }
206 std::fs::create_dir_all(agent).map_err(|e| ConfigError::Write {
207 path: agent.to_path_buf(),
208 source: e,
209 })?;
210 let mut moved = 0usize;
211 for leaf in ["auth.json", "models.json", ".setup_done"] {
212 let from = root.join(leaf);
213 let to = agent.join(leaf);
214 if from.exists() && !to.exists() {
215 if let Err(_e) = std::fs::rename(&from, &to) {
218 if std::fs::copy(&from, &to).is_ok() {
219 let _ = std::fs::remove_file(&from);
220 }
221 }
222 moved += 1;
223 }
224 }
225 Ok(moved)
226}
227
228#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
237#[serde(rename_all = "snake_case", tag = "type")]
238pub enum Credential {
239 ApiKey {
242 key: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 env: Option<BTreeMap<String, String>>,
245 },
246 Oauth {
248 access: String,
249 refresh: String,
250 expires: i64,
252 },
253}
254
255pub type AuthStore = BTreeMap<String, Credential>;
258
259pub fn read_auth() -> Result<AuthStore, ConfigError> {
262 let path = auth_path()?;
263 match read_text_with_fallback(path, None)? {
264 Some((path, text)) => {
265 serde_json::from_str(&text).map_err(|source| ConfigError::Json { path, source })
266 }
267 None => Ok(AuthStore::new()),
268 }
269}
270
271pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
274 let path = auth_path()?;
275 let dir = agent_dir()?;
276 ensure_dir(&dir)?;
277 let json = serde_json::to_string_pretty(store).unwrap();
278 atomic_write(&path, json.as_bytes())?;
279 set_owner_only(&path);
280 Ok(())
281}
282
283pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
285 let mut store = read_auth()?;
286 store.insert(provider_id.to_string(), cred);
287 write_auth(&store)
288}
289
290pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
295 let mut store = read_auth()?;
296 if store.remove(provider_id).is_some() {
297 write_auth(&store)?;
298 Ok(true)
299 } else {
300 Ok(false)
301 }
302}
303
304#[derive(serde::Deserialize, Default, Debug, Clone)]
311#[serde(rename_all = "camelCase")]
312pub struct ModelsConfig {
313 #[serde(default)]
314 pub providers: indexmap::IndexMap<String, ProviderConfig>,
317}
318
319#[derive(serde::Deserialize, Debug, Clone)]
323#[serde(rename_all = "camelCase")]
324pub struct ProviderConfig {
325 #[serde(default)]
326 pub name: Option<String>,
327 #[serde(default)]
328 pub base_url: Option<String>,
329 #[serde(default)]
330 pub api_key: Option<String>,
331 #[serde(default)]
332 pub api: Option<String>,
333 #[serde(default)]
334 pub headers: Option<BTreeMap<String, String>>,
335 #[serde(default)]
338 pub auth_header: Option<bool>,
339 #[serde(default)]
340 pub models: Vec<ModelDefinition>,
341}
342
343#[derive(serde::Deserialize, Debug, Clone)]
345#[serde(rename_all = "camelCase")]
346pub struct ModelDefinition {
347 pub id: String,
348 #[serde(default)]
349 pub name: Option<String>,
350 #[serde(default)]
351 pub base_url: Option<String>,
352 #[serde(default)]
353 pub reasoning: Option<bool>,
354 #[serde(default)]
355 pub context_window: Option<u64>,
356 #[serde(default)]
357 pub max_tokens: Option<u64>,
358 #[serde(default)]
361 pub input: Option<Vec<String>>,
362 #[serde(default)]
363 pub headers: Option<BTreeMap<String, String>>,
364 #[serde(default)]
365 pub compat: Option<serde_json::Value>,
366}
367
368pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
370 let path = models_path()?;
371 match read_text_with_fallback(path, None)? {
372 Some((path, text)) => {
373 parse_models_json(&text).map_err(|source| ConfigError::Json { path, source })
374 }
375 None => Ok(ModelsConfig::default()),
376 }
377}
378
379pub type TrustStore = BTreeMap<String, Option<bool>>;
389
390pub fn read_trust() -> Result<TrustStore, ConfigError> {
394 let path = trust_path()?;
395 match std::fs::read_to_string(&path) {
396 Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
397 path: path.clone(),
398 source: e,
399 }),
400 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
401 Err(e) => Err(ConfigError::Read { path, source: e }),
402 }
403}
404
405pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
408 let key = std::fs::canonicalize(cwd)
409 .unwrap_or_else(|_| cwd.to_path_buf())
410 .to_string_lossy()
411 .into_owned();
412 let mut store = read_trust()?;
413 if let Some(decision) = trusted {
414 store.insert(key, Some(decision));
415 } else {
416 store.remove(&key);
417 }
418 let path = trust_path()?;
419 ensure_dir(&agent_dir()?)?;
420 let json = serde_json::to_string_pretty(&store).unwrap();
421 atomic_write(&path, json.as_bytes())?;
422 set_owner_only(&path);
423 Ok(())
424}
425
426pub fn project_trust_decision(cwd: &Path) -> Result<Option<bool>, ConfigError> {
429 let key = std::fs::canonicalize(cwd)
430 .unwrap_or_else(|_| cwd.to_path_buf())
431 .to_string_lossy()
432 .into_owned();
433 Ok(read_trust()?.get(&key).copied().flatten())
434}
435
436fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
440 match serde_json::from_str(text) {
441 Ok(c) => Ok(c),
442 Err(first) => {
443 let stripped = strip_line_comments(text);
448 serde_json::from_str(&stripped).map_err(|_| first)
449 }
450 }
451}
452
453pub(crate) fn strip_line_comments(text: &str) -> String {
458 text.lines()
459 .map(|line| match find_line_comment(line) {
460 Some(idx) => line[..idx].to_string(),
461 None => line.to_string(),
462 })
463 .collect::<Vec<_>>()
464 .join("\n")
465}
466
467fn find_line_comment(line: &str) -> Option<usize> {
469 let mut in_str = false;
470 let mut esc = false;
471 for (i, ch) in line.char_indices() {
472 if esc {
473 esc = false;
474 continue;
475 }
476 match ch {
477 '\\' if in_str => esc = true,
478 '"' => in_str = !in_str,
479 '/' if !in_str => {
480 if line.as_bytes().get(i + 1) == Some(&b'/') {
481 return Some(i);
482 }
483 }
484 _ => {}
485 }
486 }
487 None
488}
489
490fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
497 static CACHE: std::sync::OnceLock<
498 std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
499 > = std::sync::OnceLock::new();
500 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
501}
502
503pub fn resolve_config_value(
518 config: &str,
519 env_overlay: Option<&BTreeMap<String, String>>,
520) -> Option<String> {
521 if let Some(cmd) = config.strip_prefix('!') {
522 return resolve_command(cmd);
523 }
524 resolve_template(config, env_overlay)
525}
526
527pub fn resolve_config_value_uncached(
531 config: &str,
532 env_overlay: Option<&BTreeMap<String, String>>,
533) -> Option<String> {
534 if let Some(cmd) = config.strip_prefix('!') {
535 return resolve_command_uncached(cmd);
536 }
537 resolve_template(config, env_overlay)
538}
539
540pub fn resolve_headers(
544 headers: &BTreeMap<String, String>,
545 env_overlay: Option<&BTreeMap<String, String>>,
546) -> BTreeMap<String, String> {
547 let mut out = BTreeMap::new();
548 for (k, v) in headers {
549 if let Some(resolved) = resolve_config_value_uncached(v, env_overlay) {
550 out.insert(k.clone(), resolved);
551 }
552 }
553 out
554}
555
556fn env_lookup(name: &str, env_overlay: Option<&BTreeMap<String, String>>) -> Option<String> {
559 if let Some(overlay) = env_overlay {
560 if let Some(v) = overlay.get(name) {
561 return Some(v.clone());
562 }
563 }
564 std::env::var(name).ok()
565}
566
567enum TemplatePart {
569 Literal(String),
570 Env(String),
571}
572
573fn parse_template(config: &str) -> Vec<TemplatePart> {
578 let mut parts: Vec<TemplatePart> = Vec::new();
579 let bytes = config.as_bytes();
580 let mut i = 0usize;
581 while i < bytes.len() {
582 match config[i..].find('$') {
584 None => {
585 push_literal(&mut parts, &config[i..]);
586 break;
587 }
588 Some(offset) => {
589 let dollar = i + offset;
590 push_literal(&mut parts, &config[i..dollar]);
591 let after = dollar + 1;
592 let next = bytes.get(after).copied();
593 if next == Some(b'$') || next == Some(b'!') {
594 push_literal(&mut parts, &config[after..after + 1]);
595 i = after + 1;
596 continue;
597 }
598 if next == Some(b'{') {
599 if let Some(end_rel) = config[after + 1..].find('}') {
601 let end = after + 1 + end_rel;
602 let name = &config[after + 1..end];
603 if is_env_name(name) {
604 parts.push(TemplatePart::Env(name.to_string()));
605 } else {
606 push_literal(&mut parts, &config[dollar..=end]);
608 }
609 i = end + 1;
610 continue;
611 }
612 push_literal(&mut parts, "$");
614 i = after;
615 continue;
616 }
617 if let Some(name) = env_name_prefix(&config[after..]) {
619 parts.push(TemplatePart::Env(name.to_string()));
620 i = after + name.len();
621 } else {
622 push_literal(&mut parts, "$");
623 i = after;
624 }
625 }
626 }
627 }
628 parts
629}
630
631fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
632 if value.is_empty() {
633 return;
634 }
635 if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
636 s.push_str(value);
637 } else {
638 parts.push(TemplatePart::Literal(value.to_string()));
639 }
640}
641
642fn is_env_name(s: &str) -> bool {
643 let mut chars = s.chars();
644 match chars.next() {
645 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
646 _ => return false,
647 }
648 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
649}
650
651fn env_name_prefix(s: &str) -> Option<&str> {
654 let mut chars = s.char_indices();
655 match chars.next() {
656 Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
657 _ => return None,
658 }
659 let end = chars
660 .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
661 .map(|(idx, _)| idx)
662 .unwrap_or(s.len());
663 Some(&s[..end])
664}
665
666fn resolve_template(
669 config: &str,
670 env_overlay: Option<&BTreeMap<String, String>>,
671) -> Option<String> {
672 let parts = parse_template(config);
673 let mut out = String::with_capacity(config.len());
674 for part in parts {
675 match part {
676 TemplatePart::Literal(s) => out.push_str(&s),
677 TemplatePart::Env(name) => match env_lookup(&name, env_overlay) {
678 Some(v) => out.push_str(&v),
679 None => return None,
680 },
681 }
682 }
683 Some(out)
684}
685
686fn resolve_command(cmd: &str) -> Option<String> {
690 let key = format!("!{cmd}");
691 if let Some(v) = command_cache().lock().ok()?.get(&key) {
692 return v.clone();
693 }
694 let result = resolve_command_uncached(cmd);
695 if let Ok(mut cache) = command_cache().lock() {
696 cache.insert(key, result.clone());
697 }
698 result
699}
700
701#[cfg(unix)]
702fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
703 std::process::Command::new("sh")
704 .arg("-c")
705 .arg(cmd)
706 .stdin(std::process::Stdio::null())
707 .stdout(std::process::Stdio::piped())
708 .stderr(std::process::Stdio::null())
709 .output()
710 .ok()
711}
712
713#[cfg(windows)]
714fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
715 use std::os::windows::process::CommandExt;
716 std::process::Command::new("cmd")
717 .arg("/C")
718 .arg(cmd)
719 .stdin(std::process::Stdio::null())
720 .stdout(std::process::Stdio::piped())
721 .stderr(std::process::Stdio::null())
722 .creation_flags(0x0800_0000) .output()
724 .ok()
725}
726
727fn resolve_command_uncached(cmd: &str) -> Option<String> {
729 let output = spawn_shell_command(cmd)?;
730 if !output.status.success() {
731 return None;
732 }
733 let stdout = String::from_utf8_lossy(&output.stdout);
734 let trimmed = stdout.trim();
735 if trimmed.is_empty() {
736 None
737 } else {
738 Some(trimmed.to_string())
739 }
740}
741
742pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
747 match cfg.api.as_deref() {
748 None | Some("") | Some("anthropic-messages") => true,
749 _ => false,
750 }
751}
752
753pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
755 matches!(cfg.api.as_deref(), Some("openai-completions"))
756}
757
758pub fn provider_is_openai_responses(cfg: &ProviderConfig) -> bool {
759 matches!(cfg.api.as_deref(), Some("openai-responses"))
760}
761
762pub fn openai_provider_api_key(provider_id: &str, cfg: &ProviderConfig) -> Option<String> {
767 if let Some(raw) = cfg.api_key.as_deref().filter(|key| !key.is_empty()) {
768 if let Some(value) = resolve_config_value(raw, None).filter(|value| !value.is_empty()) {
769 return Some(value);
770 }
771 }
772
773 let name = native_provider_api_key_env_var(provider_id)?;
774 std::env::var(name).ok().filter(|value| !value.is_empty())
775}
776
777fn native_provider_api_key_env_var(provider_id: &str) -> Option<&'static str> {
781 match provider_id {
782 "ant-ling" => Some("ANT_LING_API_KEY"),
783 "qwen-token-plan" | "qwen-token-plan-individual" => Some("QWEN_TOKEN_PLAN_API_KEY"),
784 "qwen-token-plan-cn" => Some("QWEN_TOKEN_PLAN_CN_API_KEY"),
785 "openai" => Some("OPENAI_API_KEY"),
786 "azure-openai-responses" => Some("AZURE_OPENAI_API_KEY"),
787 "nvidia" => Some("NVIDIA_API_KEY"),
788 "deepseek" => Some("DEEPSEEK_API_KEY"),
789 "google" => Some("GEMINI_API_KEY"),
790 "google-vertex" => Some("GOOGLE_CLOUD_API_KEY"),
791 "groq" => Some("GROQ_API_KEY"),
792 "cerebras" => Some("CEREBRAS_API_KEY"),
793 "xai" => Some("XAI_API_KEY"),
794 "radius" => Some("RADIUS_API_KEY"),
795 "openrouter" => Some("OPENROUTER_API_KEY"),
796 "vercel-ai-gateway" => Some("AI_GATEWAY_API_KEY"),
797 "zai" => Some("ZAI_API_KEY"),
798 "zai-coding-cn" => Some("ZAI_CODING_CN_API_KEY"),
799 "mistral" => Some("MISTRAL_API_KEY"),
800 "minimax" => Some("MINIMAX_API_KEY"),
801 "minimax-cn" => Some("MINIMAX_CN_API_KEY"),
802 "moonshotai" | "moonshotai-cn" => Some("MOONSHOT_API_KEY"),
803 "huggingface" => Some("HF_TOKEN"),
804 "fireworks" => Some("FIREWORKS_API_KEY"),
805 "together" => Some("TOGETHER_API_KEY"),
806 "baseten" => Some("BASETEN_API_KEY"),
807 "opencode" | "opencode-go" => Some("OPENCODE_API_KEY"),
808 "kimi-coding" => Some("KIMI_API_KEY"),
809 "cloudflare-workers-ai" | "cloudflare-ai-gateway" => Some("CLOUDFLARE_API_KEY"),
810 "xiaomi" => Some("XIAOMI_API_KEY"),
811 "xiaomi-token-plan-cn" => Some("XIAOMI_TOKEN_PLAN_CN_API_KEY"),
812 "xiaomi-token-plan-ams" => Some("XIAOMI_TOKEN_PLAN_AMS_API_KEY"),
813 "xiaomi-token-plan-sgp" => Some("XIAOMI_TOKEN_PLAN_SGP_API_KEY"),
814 _ => None,
815 }
816}
817
818pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
822 let api = if provider_is_anthropic_compatible(cfg) {
823 Api::AnthropicMessages
824 } else if provider_is_openai_completions(cfg) {
825 Api::OpenaiCompletions
826 } else if provider_is_openai_responses(cfg) {
827 Api::OpenaiResponses
828 } else {
829 return None;
830 };
831 let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
832 Api::OpenaiCompletions | Api::OpenaiResponses => "https://api.openai.com".to_string(),
833 _ => default_anthropic_base_url(),
834 });
835 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
836 for def in &cfg.models {
837 let base_url = def
838 .base_url
839 .clone()
840 .unwrap_or_else(|| provider_base.clone());
841 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
842 let mut m = Model::new(
843 def.id.clone(),
844 name,
845 api.clone(),
846 provider_id.to_string(),
847 base_url,
848 );
849 m.reasoning = def.reasoning.unwrap_or(false);
850 m.context_window = def.context_window.unwrap_or(0);
851 m.max_tokens = def.max_tokens.unwrap_or(0);
852 m.input = parse_input_modalities(def.input.as_deref());
853 let mut headers: BTreeMap<String, String> = BTreeMap::new();
867 if let Some(h) = def.headers.clone() {
868 for (k, v) in resolve_headers(&h, None) {
869 headers.insert(k, v);
870 }
871 }
872 if let Some(h) = cfg.headers.clone() {
873 for (k, v) in resolve_headers(&h, None) {
874 headers.insert(k, v);
875 }
876 }
877 if matches!(api, Api::OpenaiCompletions | Api::OpenaiResponses) {
878 if let Some(key) = openai_provider_api_key(provider_id, cfg) {
879 headers.retain(|name, _| !name.eq_ignore_ascii_case("authorization"));
880 headers.insert("authorization".to_string(), format!("Bearer {key}"));
881 }
882 if let Some(value) = def.compat.clone() {
883 if matches!(api, Api::OpenaiResponses) {
884 if let Ok(compat) = serde_json::from_value(value) {
885 m.compat = Some(StreamingProtocolCompat::OpenaiResponses(compat));
886 }
887 } else if let Ok(compat) = serde_json::from_value(value) {
888 m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
889 }
890 }
891 }
892 if !headers.is_empty() {
893 m.headers = Some(headers);
894 }
895 merged.push(m);
896 }
897 Some(merged)
898}
899
900fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
903 match input {
904 None => vec![InputModality::Text],
905 Some(list) if list.is_empty() => vec![InputModality::Text],
906 Some(list) => list
907 .iter()
908 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
909 "text" => Some(InputModality::Text),
910 "image" => Some(InputModality::Image),
911 _ => None,
912 })
913 .collect::<Vec<_>>()
914 .pipe(|v| {
915 if v.is_empty() {
916 vec![InputModality::Text]
917 } else {
918 v
919 }
920 }),
921 }
922}
923
924pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
930
931pub fn default_anthropic_base_url() -> String {
935 ANTHROPIC_DEFAULT_BASE_URL.to_string()
936}
937
938#[cfg(unix)]
943use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
944
945fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
948 if dir.exists() {
949 return Ok(());
950 }
951 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
952 path: dir.to_path_buf(),
953 source: e,
954 })?;
955 #[cfg(unix)]
956 {
957 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
958 }
959 Ok(())
960}
961
962pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
965 let dir = path.parent().ok_or_else(|| ConfigError::Write {
966 path: path.to_path_buf(),
967 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
968 })?;
969 let tmp = dir.join(format!(
970 ".{}.{}.tmp",
971 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi"),
972 uuid::Uuid::new_v4().simple()
973 ));
974 let mut options = std::fs::OpenOptions::new();
975 options.write(true).create_new(true);
976 #[cfg(unix)]
977 options.mode(0o600);
978 let mut file = options.open(&tmp).map_err(|source| ConfigError::Write {
979 path: tmp.clone(),
980 source,
981 })?;
982 if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
983 let _ = std::fs::remove_file(&tmp);
987 return Err(ConfigError::Write {
988 path: tmp,
989 source: error,
990 });
991 }
992 drop(file);
993 if let Err(error) = std::fs::rename(&tmp, path) {
994 let _ = std::fs::remove_file(&tmp);
997 return Err(ConfigError::Write {
998 path: path.to_path_buf(),
999 source: error,
1000 });
1001 }
1002 Ok(())
1003}
1004
1005fn set_owner_only(_path: &Path) {
1008 #[cfg(unix)]
1009 {
1010 let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
1011 }
1012}
1013
1014trait Pipe: Sized {
1017 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
1018 f(self)
1019 }
1020}
1021impl<T> Pipe for T {}
1022
1023#[cfg(test)]
1028pub(crate) mod test_support {
1029 use std::sync::{Mutex, OnceLock};
1035 pub(crate) fn env_lock() -> &'static Mutex<()> {
1036 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1037 LOCK.get_or_init(|| Mutex::new(()))
1038 }
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044 use crate::config::test_support::env_lock;
1045
1046 struct TempConfig {
1050 _guard: std::sync::MutexGuard<'static, ()>,
1051 _tmp: tempfile::TempDir,
1052 prev: Option<std::ffi::OsString>,
1053 }
1054 impl TempConfig {
1055 fn new() -> Self {
1056 let guard = env_lock().lock().unwrap();
1057 let prev = std::env::var_os(CONFIG_DIR_ENV);
1058 let tmp = tempfile::TempDir::new().unwrap();
1059 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1060 Self {
1061 _guard: guard,
1062 _tmp: tmp,
1063 prev,
1064 }
1065 }
1066 }
1067 impl Drop for TempConfig {
1068 fn drop(&mut self) {
1069 restore_env(CONFIG_DIR_ENV, self.prev.take());
1070 }
1071 }
1072
1073 #[test]
1074 fn read_auth_missing_file_is_empty() {
1075 let _cfg = TempConfig::new();
1076 let store = read_auth().unwrap();
1077 assert!(store.is_empty());
1078 }
1079
1080 #[test]
1081 fn upsert_then_read_roundtrip() {
1082 let _cfg = TempConfig::new();
1083 upsert_credential(
1084 "anthropic",
1085 Credential::ApiKey {
1086 key: Some("sk-test-123".into()),
1087 env: None,
1088 },
1089 )
1090 .unwrap();
1091 let store = read_auth().unwrap();
1092 match store.get("anthropic") {
1093 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
1094 other => panic!("unexpected cred: {other:?}"),
1095 }
1096 let path = auth_path().unwrap();
1098 assert!(path.exists(), "auth.json should exist after upsert");
1099 let raw = std::fs::read_to_string(&path).unwrap();
1100 assert!(raw.contains("\"anthropic\""));
1101 assert!(raw.contains("api_key"));
1102 }
1103
1104 #[test]
1105 fn delete_credential_removes_entry() {
1106 let _cfg = TempConfig::new();
1107 upsert_credential(
1108 "anthropic",
1109 Credential::ApiKey {
1110 key: Some("k".into()),
1111 env: None,
1112 },
1113 )
1114 .unwrap();
1115 assert!(delete_credential("anthropic").unwrap());
1116 assert!(!delete_credential("anthropic").unwrap());
1118 assert!(read_auth().unwrap().is_empty());
1119 }
1120
1121 #[test]
1122 fn load_models_config_missing_is_empty() {
1123 let _cfg = TempConfig::new();
1124 let c = load_models_config().unwrap();
1125 assert!(c.providers.is_empty());
1126 }
1127
1128 #[test]
1129 fn fallback_reader_prefers_primary_and_only_falls_back_when_missing() {
1130 let tmp = tempfile::tempdir().unwrap();
1131 let primary = tmp.path().join("primary.json");
1132 let fallback = tmp.path().join("fallback.json");
1133 std::fs::write(&fallback, "fallback").unwrap();
1134
1135 let (_, text) = read_text_with_fallback(primary.clone(), Some(fallback.clone()))
1136 .unwrap()
1137 .expect("fallback should be read");
1138 assert_eq!(text, "fallback");
1139
1140 std::fs::write(&primary, "primary").unwrap();
1141 let (path, text) = read_text_with_fallback(primary.clone(), Some(fallback))
1142 .unwrap()
1143 .expect("primary should be read");
1144 assert_eq!(path, primary);
1145 assert_eq!(text, "primary");
1146 }
1147
1148 #[test]
1149 fn fallback_reader_does_not_mask_primary_read_errors() {
1150 let tmp = tempfile::tempdir().unwrap();
1151 let primary = tmp.path().join("primary.json");
1152 let fallback = tmp.path().join("fallback.json");
1153 std::fs::create_dir(&primary).unwrap();
1154 std::fs::write(&fallback, "fallback").unwrap();
1155
1156 let error = read_text_with_fallback(primary.clone(), Some(fallback)).unwrap_err();
1157 assert!(matches!(error, ConfigError::Read { path, .. } if path == primary));
1158 }
1159
1160 #[test]
1161 fn load_models_config_parses_with_comments() {
1162 let _cfg = TempConfig::new();
1163 let json = r#"{
1164 // a one-api style gateway
1165 "providers": {
1166 "gateway": {
1167 "baseUrl": "https://gw.example.com",
1168 "authHeader": true,
1169 "apiKey": "gw-secret",
1170 "models": [
1171 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
1172 ]
1173 }
1174 }
1175}"#;
1176 std::fs::write(models_path().unwrap(), json).unwrap();
1177 let c = load_models_config().unwrap();
1178 let gw = c
1179 .providers
1180 .get("gateway")
1181 .expect("gateway provider present");
1182 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
1183 assert!(gw.auth_header.unwrap_or(false));
1184 assert_eq!(gw.models.len(), 1);
1185 assert_eq!(gw.models[0].id, "claude-sonnet-5");
1186 }
1187
1188 #[test]
1189 fn provider_to_models_merges_headers_without_synth_bearer() {
1190 let cfg = ProviderConfig {
1196 name: None,
1197 base_url: Some("https://gw.example.com".into()),
1198 api_key: Some("gw-secret".into()),
1199 api: None,
1200 headers: Some({
1201 let mut h = BTreeMap::new();
1202 h.insert("x-portkey-key".into(), "portkey-secret".into());
1203 h
1204 }),
1205 auth_header: Some(true),
1206 models: vec![ModelDefinition {
1207 id: "claude-sonnet-5".into(),
1208 name: None,
1209 base_url: None,
1210 reasoning: None,
1211 context_window: None,
1212 max_tokens: None,
1213 input: None,
1214 headers: None,
1215 compat: None,
1216 }],
1217 };
1218 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1219 assert_eq!(models.len(), 1);
1220 let m = &models[0];
1221 assert_eq!(m.id, "claude-sonnet-5");
1222 assert_eq!(m.base_url, "https://gw.example.com");
1223 assert_eq!(m.provider, "gateway");
1224 let headers = m.headers.as_ref().expect("provider headers merged");
1225 assert_eq!(
1227 headers.get("x-portkey-key").map(|s| s.as_str()),
1228 Some("portkey-secret")
1229 );
1230 assert!(
1235 headers.get("authorization").is_none(),
1236 "provider_to_models must not synthesize the Bearer; resolve does"
1237 );
1238 }
1239
1240 #[test]
1241 fn provider_to_models_supports_openai_completions() {
1242 let config: ModelsConfig = serde_json::from_str(
1243 r#"{
1244 "providers": {
1245 "oai": {
1246 "api": "openai-completions",
1247 "baseUrl": "https://gateway.example.com/v1",
1248 "apiKey": "secret",
1249 "models": [{"id":"gpt-test","maxTokens":4096}]
1250 }
1251 }
1252 }"#,
1253 )
1254 .unwrap();
1255 let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
1256 assert_eq!(models.len(), 1);
1257 assert_eq!(models[0].api, Api::OpenaiCompletions);
1258 assert_eq!(models[0].provider, "oai");
1259 assert_eq!(models[0].max_tokens, 4096);
1260 assert_eq!(
1261 models[0]
1262 .headers
1263 .as_ref()
1264 .and_then(|headers| headers.get("authorization"))
1265 .map(String::as_str),
1266 Some("Bearer secret")
1267 );
1268 }
1269
1270 #[test]
1271 fn models_json_compat_reaches_the_model_for_openai_completions() {
1272 let config: ModelsConfig = serde_json::from_str(
1279 r#"{
1280 "providers": {
1281 "qwen": {
1282 "api": "openai-completions",
1283 "baseUrl": "https://coding.example.com/v1",
1284 "apiKey": "secret",
1285 "models": [{
1286 "id": "qwen3.7-plus",
1287 "maxTokens": 131072,
1288 "compat": { "requiresThinkingAsText": true }
1289 }]
1290 }
1291 }
1292 }"#,
1293 )
1294 .unwrap();
1295 let models = provider_to_models("qwen", &config.providers["qwen"]).unwrap();
1296 match models[0].compat.as_ref().expect("compat must survive") {
1297 StreamingProtocolCompat::OpenaiCompletions(compat) => {
1298 assert_eq!(compat.requires_thinking_as_text, Some(true));
1299 }
1300 other => panic!("expected OpenaiCompletions compat, got {other:?}"),
1301 }
1302
1303 let plain: ModelsConfig = serde_json::from_str(
1305 r#"{
1306 "providers": {
1307 "qwen": {
1308 "api": "openai-completions",
1309 "baseUrl": "https://coding.example.com/v1",
1310 "apiKey": "secret",
1311 "models": [{"id":"qwen3.7-plus"}]
1312 }
1313 }
1314 }"#,
1315 )
1316 .unwrap();
1317 let plain_models = provider_to_models("qwen", &plain.providers["qwen"]).unwrap();
1318 assert!(plain_models[0].compat.is_none());
1319 }
1320
1321 #[test]
1322 fn custom_openai_provider_resolves_explicit_api_key_environment_reference() {
1323 let _guard = env_lock().lock().unwrap();
1324 let env_name = "RPI_FAKE_PROVIDER_API_KEY";
1325 std::env::set_var(env_name, "env-secret");
1326 let cfg = ProviderConfig {
1327 name: None,
1328 base_url: Some("https://gateway.example.com/v1".into()),
1329 api_key: Some("$RPI_FAKE_PROVIDER_API_KEY".into()),
1330 api: Some("openai-completions".into()),
1331 headers: None,
1332 auth_header: None,
1333 models: vec![ModelDefinition {
1334 id: "fake-model".into(),
1335 name: None,
1336 base_url: None,
1337 reasoning: None,
1338 context_window: None,
1339 max_tokens: None,
1340 input: None,
1341 headers: None,
1342 compat: None,
1343 }],
1344 };
1345 let models = provider_to_models("rpi-fake-provider", &cfg).unwrap();
1346 assert_eq!(
1347 models[0]
1348 .headers
1349 .as_ref()
1350 .and_then(|headers| headers.get("authorization"))
1351 .map(String::as_str),
1352 Some("Bearer env-secret")
1353 );
1354 std::env::remove_var(env_name);
1355 }
1356
1357 #[test]
1358 fn openai_protocol_ids_do_not_inherit_the_official_env_key() {
1359 let _guard = env_lock().lock().unwrap();
1360 let prev_openai = std::env::var_os("OPENAI_API_KEY");
1361 let prev_completions = std::env::var_os("OPENAI_COMPLETIONS_API_KEY");
1362 let prev_responses = std::env::var_os("OPENAI_RESPONSES_API_KEY");
1363 std::env::set_var("OPENAI_API_KEY", "official-key");
1364 std::env::remove_var("OPENAI_COMPLETIONS_API_KEY");
1365 std::env::remove_var("OPENAI_RESPONSES_API_KEY");
1366 let config = ProviderConfig {
1367 name: None,
1368 base_url: Some("https://gateway.example.com/v1".into()),
1369 api_key: None,
1370 api: Some("openai-responses".into()),
1371 headers: None,
1372 auth_header: None,
1373 models: Vec::new(),
1374 };
1375
1376 assert_eq!(
1377 openai_provider_api_key("openai", &config).as_deref(),
1378 Some("official-key")
1379 );
1380 assert!(openai_provider_api_key("openai-completions", &config).is_none());
1381 assert!(openai_provider_api_key("openai-responses", &config).is_none());
1382 assert!(openai_provider_api_key("OpenAI", &config).is_none());
1383
1384 restore_env("OPENAI_API_KEY", prev_openai);
1385 restore_env("OPENAI_COMPLETIONS_API_KEY", prev_completions);
1386 restore_env("OPENAI_RESPONSES_API_KEY", prev_responses);
1387 }
1388
1389 #[test]
1390 fn native_provider_api_key_env_mapping_uses_exact_provider_ids() {
1391 for (provider, env_var) in [
1392 ("ant-ling", "ANT_LING_API_KEY"),
1393 ("qwen-token-plan", "QWEN_TOKEN_PLAN_API_KEY"),
1394 ("qwen-token-plan-cn", "QWEN_TOKEN_PLAN_CN_API_KEY"),
1395 ("qwen-token-plan-individual", "QWEN_TOKEN_PLAN_API_KEY"),
1396 ("openai", "OPENAI_API_KEY"),
1397 ("azure-openai-responses", "AZURE_OPENAI_API_KEY"),
1398 ("nvidia", "NVIDIA_API_KEY"),
1399 ("deepseek", "DEEPSEEK_API_KEY"),
1400 ("google", "GEMINI_API_KEY"),
1401 ("google-vertex", "GOOGLE_CLOUD_API_KEY"),
1402 ("groq", "GROQ_API_KEY"),
1403 ("cerebras", "CEREBRAS_API_KEY"),
1404 ("xai", "XAI_API_KEY"),
1405 ("radius", "RADIUS_API_KEY"),
1406 ("openrouter", "OPENROUTER_API_KEY"),
1407 ("vercel-ai-gateway", "AI_GATEWAY_API_KEY"),
1408 ("zai", "ZAI_API_KEY"),
1409 ("zai-coding-cn", "ZAI_CODING_CN_API_KEY"),
1410 ("mistral", "MISTRAL_API_KEY"),
1411 ("minimax", "MINIMAX_API_KEY"),
1412 ("minimax-cn", "MINIMAX_CN_API_KEY"),
1413 ("moonshotai", "MOONSHOT_API_KEY"),
1414 ("moonshotai-cn", "MOONSHOT_API_KEY"),
1415 ("huggingface", "HF_TOKEN"),
1416 ("fireworks", "FIREWORKS_API_KEY"),
1417 ("together", "TOGETHER_API_KEY"),
1418 ("baseten", "BASETEN_API_KEY"),
1419 ("opencode", "OPENCODE_API_KEY"),
1420 ("opencode-go", "OPENCODE_API_KEY"),
1421 ("kimi-coding", "KIMI_API_KEY"),
1422 ("cloudflare-workers-ai", "CLOUDFLARE_API_KEY"),
1423 ("cloudflare-ai-gateway", "CLOUDFLARE_API_KEY"),
1424 ("xiaomi", "XIAOMI_API_KEY"),
1425 ("xiaomi-token-plan-cn", "XIAOMI_TOKEN_PLAN_CN_API_KEY"),
1426 ("xiaomi-token-plan-ams", "XIAOMI_TOKEN_PLAN_AMS_API_KEY"),
1427 ("xiaomi-token-plan-sgp", "XIAOMI_TOKEN_PLAN_SGP_API_KEY"),
1428 ] {
1429 assert_eq!(
1430 native_provider_api_key_env_var(provider),
1431 Some(env_var),
1432 "unexpected env mapping for {provider}"
1433 );
1434 }
1435
1436 for alias in [
1437 "togetherai",
1438 "perplexity",
1439 "moonshot",
1440 "kimi",
1441 "qwen",
1442 "zhipu",
1443 "OpenAI",
1444 ] {
1445 assert_eq!(
1446 native_provider_api_key_env_var(alias),
1447 None,
1448 "non-native alias {alias} must not inherit credentials"
1449 );
1450 }
1451 }
1452
1453 #[test]
1454 fn malformed_auth_json_is_an_error_not_silent_empty() {
1455 let _cfg = TempConfig::new();
1456 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1457 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1458 }
1459
1460 #[test]
1461 fn agent_dir_nests_under_agent_by_default() {
1462 let _guard = env_lock().lock().unwrap();
1466 let prev = std::env::var_os(CONFIG_DIR_ENV);
1467 std::env::remove_var(CONFIG_DIR_ENV);
1468 let dir = agent_dir().unwrap();
1469 restore_env(CONFIG_DIR_ENV, prev);
1470 assert!(dir.ends_with("agent"));
1471 assert!(dir
1472 .parent()
1473 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1474 .unwrap_or(false));
1475 }
1476
1477 #[test]
1478 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1479 let tmp = tempfile::TempDir::new().unwrap();
1483 let root = tmp.path().to_path_buf();
1484 let agent = root.join("agent");
1485 std::fs::write(root.join("auth.json"), "{}").unwrap();
1486 std::fs::write(root.join("models.json"), "{}").unwrap();
1487 std::fs::write(root.join(".setup_done"), "1").unwrap();
1488 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1489 assert_eq!(moved, 3);
1490 assert!(agent.join("auth.json").exists());
1491 assert!(agent.join("models.json").exists());
1492 assert!(agent.join(".setup_done").exists());
1493 assert!(!root.join("auth.json").exists());
1494 }
1495
1496 #[test]
1497 fn migrate_legacy_layout_noop_when_agent_exists() {
1498 let tmp = tempfile::TempDir::new().unwrap();
1499 let root = tmp.path().to_path_buf();
1500 let agent = root.join("agent");
1501 std::fs::write(root.join("auth.json"), "{}").unwrap();
1502 std::fs::create_dir_all(&agent).unwrap();
1503 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1504 assert_eq!(moved, 0); }
1506
1507 #[test]
1508 fn migrate_legacy_layout_noop_when_no_flat_files() {
1509 let tmp = tempfile::TempDir::new().unwrap();
1510 let root = tmp.path().to_path_buf();
1511 let agent = root.join("agent");
1512 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1513 assert_eq!(moved, 0);
1514 }
1515
1516 #[test]
1517 fn migrate_legacy_layout_public_skips_env_override() {
1518 let _cfg = TempConfig::new();
1521 let moved = migrate_legacy_layout().unwrap();
1522 assert_eq!(moved, 0);
1523 }
1524
1525 #[test]
1526 fn read_trust_missing_file_is_empty() {
1527 let _cfg = TempConfig::new();
1528 assert!(read_trust().unwrap().is_empty());
1529 }
1530
1531 #[test]
1532 fn read_trust_parses_decisions() {
1533 let _cfg = TempConfig::new();
1534 let path = trust_path().unwrap();
1535 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1536 std::fs::write(
1537 &path,
1538 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1539 )
1540 .unwrap();
1541 let store = read_trust().unwrap();
1542 assert_eq!(store.len(), 3);
1543 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1544 assert_eq!(
1545 store.get("/home/me/untrusted").copied().flatten(),
1546 Some(false)
1547 );
1548 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1549 }
1550
1551 #[test]
1552 fn resolve_config_value_literal_passthrough() {
1553 assert_eq!(
1554 resolve_config_value("sk-literal-key", None),
1555 Some("sk-literal-key".into())
1556 );
1557 }
1558
1559 #[test]
1560 fn resolve_config_value_env_var() {
1561 let _guard = env_lock().lock().unwrap();
1562 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1563 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1564 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1565 assert_eq!(
1566 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1567 Some("secret-from-env".into())
1568 );
1569 assert_eq!(
1570 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1571 Some("prefix-secret-from-env-suffix".into())
1572 );
1573 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1575 assert_eq!(
1576 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1577 Some("a-secret-from-env-b-two-c".into())
1578 );
1579 let mut overlay = BTreeMap::new();
1581 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1582 assert_eq!(
1583 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1584 Some("overlay-value".into())
1585 );
1586 restore_env("RPI_TEST_CFG_KEY", prev);
1587 restore_env("RPI_TEST_CFG_KEY2", prev2);
1588 }
1589
1590 #[test]
1591 fn resolve_config_value_unset_env_is_none() {
1592 let _guard = env_lock().lock().unwrap();
1593 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1594 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1595 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1597 assert_eq!(
1598 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1599 None
1600 );
1601 restore_env("RPI_TEST_CFG_ABSENT", prev);
1602 }
1603
1604 #[test]
1605 fn resolve_config_value_dollar_dollar_escapes_literal() {
1606 assert_eq!(
1607 resolve_config_value("price-$$5", None),
1608 Some("price-$5".into())
1609 );
1610 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1611 }
1612
1613 #[test]
1614 fn resolve_config_value_command_runs_shell() {
1615 assert_eq!(
1617 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1618 Some("rpi-cfg-resolved".into())
1619 );
1620 assert_eq!(resolve_config_value_uncached("!false", None), None);
1622 }
1623
1624 #[test]
1625 fn resolve_headers_drops_unresolvable() {
1626 let _guard = env_lock().lock().unwrap();
1627 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1628 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1629 let mut h = BTreeMap::new();
1630 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1631 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1632 h.insert("x-literal".into(), "literal-value".into());
1633 let resolved = resolve_headers(&h, None);
1634 assert_eq!(resolved.len(), 2);
1635 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1636 assert_eq!(
1637 resolved.get("x-literal").map(|s| s.as_str()),
1638 Some("literal-value")
1639 );
1640 assert!(!resolved.contains_key("x-unset"));
1641 restore_env("RPI_TEST_HDR_SET", prev);
1642 }
1643
1644 #[test]
1645 fn agent_dir_respects_env_override() {
1646 let _guard = env_lock().lock().unwrap();
1647 let prev = std::env::var_os(CONFIG_DIR_ENV);
1648 let tmp = tempfile::TempDir::new().unwrap();
1649 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1650 let dir = agent_dir().unwrap();
1651 restore_env(CONFIG_DIR_ENV, prev);
1652 assert_eq!(dir, tmp.path());
1653 }
1654
1655 #[test]
1656 fn relative_override_is_rejected() {
1657 let _guard = env_lock().lock().unwrap();
1658 let prev = std::env::var_os(CONFIG_DIR_ENV);
1659 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1660 let err = agent_dir().unwrap_err();
1661 restore_env(CONFIG_DIR_ENV, prev);
1662 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1663 }
1664
1665 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1667 match prev {
1668 Some(v) => std::env::set_var(name, v),
1669 None => std::env::remove_var(name),
1670 }
1671 }
1672}