1use std::collections::BTreeMap;
39use std::path::{Path, PathBuf};
40
41use rpi_ai::{Api, InputModality, Model, StreamingProtocolCompat};
42
43pub const CONFIG_DIR_NAME: &str = ".rpi";
46
47pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
50
51pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
54
55#[derive(Debug, thiserror::Error)]
62pub enum ConfigError {
63 #[error("could not resolve home directory (set {env} to override)")]
64 NoHomeDir { env: &'static str },
65 #[error("config dir override {env}={val:?} is not an absolute path")]
66 RelativeOverride { env: &'static str, val: String },
67 #[error("could not read {path}: {source}")]
68 Read {
69 path: PathBuf,
70 #[source]
71 source: std::io::Error,
72 },
73 #[error("could not write {path}: {source}")]
74 Write {
75 path: PathBuf,
76 #[source]
77 source: std::io::Error,
78 },
79 #[error("invalid JSON in {path}: {source}")]
80 Json {
81 path: PathBuf,
82 #[source]
83 source: serde_json::Error,
84 },
85}
86
87pub fn agent_dir() -> Result<PathBuf, ConfigError> {
97 if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
98 let p = PathBuf::from(&val);
99 if !p.is_absolute() {
100 return Err(ConfigError::RelativeOverride {
101 env: CONFIG_DIR_ENV,
102 val: val.to_string_lossy().into_owned(),
103 });
104 }
105 return Ok(p);
106 }
107 let home = dirs::home_dir().ok_or(ConfigError::NoHomeDir {
108 env: CONFIG_DIR_ENV,
109 })?;
110 Ok(home.join(CONFIG_DIR_NAME).join("agent"))
111}
112
113fn config_root_dir() -> Result<PathBuf, ConfigError> {
117 let agent = agent_dir()?;
118 agent
119 .parent()
120 .map(Path::to_path_buf)
121 .ok_or(ConfigError::NoHomeDir {
122 env: CONFIG_DIR_ENV,
123 })
124}
125
126pub fn auth_path() -> Result<PathBuf, ConfigError> {
128 Ok(agent_dir()?.join("auth.json"))
129}
130
131pub fn models_path() -> Result<PathBuf, ConfigError> {
133 Ok(agent_dir()?.join("models.json"))
134}
135
136pub fn settings_path() -> Result<PathBuf, ConfigError> {
138 Ok(agent_dir()?.join("settings.json"))
139}
140
141pub fn trust_path() -> Result<PathBuf, ConfigError> {
143 Ok(agent_dir()?.join("trust.json"))
144}
145
146pub fn migrate_legacy_layout() -> Result<usize, ConfigError> {
154 if std::env::var_os(CONFIG_DIR_ENV).is_some() {
156 return Ok(0);
157 }
158 let root = match config_root_dir() {
159 Ok(p) => p,
160 Err(_) => return Ok(0),
161 };
162 let agent = agent_dir()?;
163 migrate_legacy_layout_in(&root, &agent)
164}
165
166fn migrate_legacy_layout_in(root: &Path, agent: &Path) -> Result<usize, ConfigError> {
173 if agent.exists() {
175 return Ok(0);
176 }
177 let flat_auth = root.join("auth.json");
179 let flat_models = root.join("models.json");
180 if !flat_auth.exists() && !flat_models.exists() {
181 return Ok(0);
182 }
183 std::fs::create_dir_all(agent).map_err(|e| ConfigError::Write {
184 path: agent.to_path_buf(),
185 source: e,
186 })?;
187 let mut moved = 0usize;
188 for leaf in ["auth.json", "models.json", ".setup_done", ".earendil_seen"] {
189 let from = root.join(leaf);
190 let to = agent.join(leaf);
191 if from.exists() && !to.exists() {
192 if let Err(_e) = std::fs::rename(&from, &to) {
195 if std::fs::copy(&from, &to).is_ok() {
196 let _ = std::fs::remove_file(&from);
197 }
198 }
199 moved += 1;
200 }
201 }
202 Ok(moved)
203}
204
205#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
214#[serde(rename_all = "snake_case", tag = "type")]
215pub enum Credential {
216 ApiKey {
219 key: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 env: Option<BTreeMap<String, String>>,
222 },
223 Oauth {
225 access: String,
226 refresh: String,
227 expires: i64,
229 },
230}
231
232pub type AuthStore = BTreeMap<String, Credential>;
235
236pub fn read_auth() -> Result<AuthStore, ConfigError> {
239 let path = auth_path()?;
240 match std::fs::read_to_string(&path) {
241 Ok(text) => Ok(serde_json::from_str(&text).map_err(|e| ConfigError::Json {
242 path: path.clone(),
243 source: e,
244 })?),
245 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()),
246 Err(e) => Err(ConfigError::Read { path, source: e }),
247 }
248}
249
250pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
253 let path = auth_path()?;
254 let dir = agent_dir()?;
255 ensure_dir(&dir)?;
256 let json = serde_json::to_string_pretty(store).unwrap();
257 atomic_write(&path, json.as_bytes())?;
258 set_owner_only(&path);
259 Ok(())
260}
261
262pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
264 let mut store = read_auth()?;
265 store.insert(provider_id.to_string(), cred);
266 write_auth(&store)
267}
268
269pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
274 let mut store = read_auth()?;
275 if store.remove(provider_id).is_some() {
276 write_auth(&store)?;
277 Ok(true)
278 } else {
279 Ok(false)
280 }
281}
282
283#[derive(serde::Deserialize, Default, Debug, Clone)]
290#[serde(rename_all = "camelCase")]
291pub struct ModelsConfig {
292 #[serde(default)]
293 pub providers: indexmap::IndexMap<String, ProviderConfig>,
296}
297
298#[derive(serde::Deserialize, Debug, Clone)]
302#[serde(rename_all = "camelCase")]
303pub struct ProviderConfig {
304 #[serde(default)]
305 pub name: Option<String>,
306 #[serde(default)]
307 pub base_url: Option<String>,
308 #[serde(default)]
309 pub api_key: Option<String>,
310 #[serde(default)]
311 pub api: Option<String>,
312 #[serde(default)]
313 pub headers: Option<BTreeMap<String, String>>,
314 #[serde(default)]
317 pub auth_header: Option<bool>,
318 #[serde(default)]
319 pub models: Vec<ModelDefinition>,
320}
321
322#[derive(serde::Deserialize, Debug, Clone)]
324#[serde(rename_all = "camelCase")]
325pub struct ModelDefinition {
326 pub id: String,
327 #[serde(default)]
328 pub name: Option<String>,
329 #[serde(default)]
330 pub base_url: Option<String>,
331 #[serde(default)]
332 pub reasoning: Option<bool>,
333 #[serde(default)]
334 pub context_window: Option<u64>,
335 #[serde(default)]
336 pub max_tokens: Option<u64>,
337 #[serde(default)]
340 pub input: Option<Vec<String>>,
341 #[serde(default)]
342 pub headers: Option<BTreeMap<String, String>>,
343 #[serde(default)]
344 pub compat: Option<serde_json::Value>,
345}
346
347pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
349 let path = models_path()?;
350 match std::fs::read_to_string(&path) {
351 Ok(text) => parse_models_json(&text).map_err(|e| ConfigError::Json {
352 path: path.clone(),
353 source: e,
354 }),
355 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsConfig::default()),
356 Err(e) => Err(ConfigError::Read { path, source: e }),
357 }
358}
359
360pub type TrustStore = BTreeMap<String, Option<bool>>;
370
371pub fn read_trust() -> Result<TrustStore, ConfigError> {
375 let path = trust_path()?;
376 match std::fs::read_to_string(&path) {
377 Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
378 path: path.clone(),
379 source: e,
380 }),
381 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
382 Err(e) => Err(ConfigError::Read { path, source: e }),
383 }
384}
385
386pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
389 let key = std::fs::canonicalize(cwd)
390 .unwrap_or_else(|_| cwd.to_path_buf())
391 .to_string_lossy()
392 .into_owned();
393 let mut store = read_trust()?;
394 if let Some(decision) = trusted {
395 store.insert(key, Some(decision));
396 } else {
397 store.remove(&key);
398 }
399 let path = trust_path()?;
400 ensure_dir(&agent_dir()?)?;
401 let json = serde_json::to_string_pretty(&store).unwrap();
402 atomic_write(&path, json.as_bytes())?;
403 set_owner_only(&path);
404 Ok(())
405}
406
407pub fn project_trust_decision(cwd: &Path) -> Result<Option<bool>, ConfigError> {
410 let key = std::fs::canonicalize(cwd)
411 .unwrap_or_else(|_| cwd.to_path_buf())
412 .to_string_lossy()
413 .into_owned();
414 Ok(read_trust()?.get(&key).copied().flatten())
415}
416
417fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
421 match serde_json::from_str(text) {
422 Ok(c) => Ok(c),
423 Err(first) => {
424 let stripped = strip_line_comments(text);
429 serde_json::from_str(&stripped).map_err(|_| first)
430 }
431 }
432}
433
434pub(crate) fn strip_line_comments(text: &str) -> String {
439 text.lines()
440 .map(|line| match find_line_comment(line) {
441 Some(idx) => line[..idx].to_string(),
442 None => line.to_string(),
443 })
444 .collect::<Vec<_>>()
445 .join("\n")
446}
447
448fn find_line_comment(line: &str) -> Option<usize> {
450 let mut in_str = false;
451 let mut esc = false;
452 for (i, ch) in line.char_indices() {
453 if esc {
454 esc = false;
455 continue;
456 }
457 match ch {
458 '\\' if in_str => esc = true,
459 '"' => in_str = !in_str,
460 '/' if !in_str => {
461 if line.as_bytes().get(i + 1) == Some(&b'/') {
462 return Some(i);
463 }
464 }
465 _ => {}
466 }
467 }
468 None
469}
470
471fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
478 static CACHE: std::sync::OnceLock<
479 std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
480 > = std::sync::OnceLock::new();
481 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
482}
483
484pub fn resolve_config_value(
499 config: &str,
500 env_overlay: Option<&BTreeMap<String, String>>,
501) -> Option<String> {
502 if let Some(cmd) = config.strip_prefix('!') {
503 return resolve_command(cmd);
504 }
505 resolve_template(config, env_overlay)
506}
507
508pub fn resolve_config_value_uncached(
512 config: &str,
513 env_overlay: Option<&BTreeMap<String, String>>,
514) -> Option<String> {
515 if let Some(cmd) = config.strip_prefix('!') {
516 return resolve_command_uncached(cmd);
517 }
518 resolve_template(config, env_overlay)
519}
520
521pub fn resolve_headers(
525 headers: &BTreeMap<String, String>,
526 env_overlay: Option<&BTreeMap<String, String>>,
527) -> BTreeMap<String, String> {
528 let mut out = BTreeMap::new();
529 for (k, v) in headers {
530 if let Some(resolved) = resolve_config_value_uncached(v, env_overlay) {
531 out.insert(k.clone(), resolved);
532 }
533 }
534 out
535}
536
537fn env_lookup(name: &str, env_overlay: Option<&BTreeMap<String, String>>) -> Option<String> {
540 if let Some(overlay) = env_overlay {
541 if let Some(v) = overlay.get(name) {
542 return Some(v.clone());
543 }
544 }
545 std::env::var(name).ok()
546}
547
548enum TemplatePart {
550 Literal(String),
551 Env(String),
552}
553
554fn parse_template(config: &str) -> Vec<TemplatePart> {
559 let mut parts: Vec<TemplatePart> = Vec::new();
560 let bytes = config.as_bytes();
561 let mut i = 0usize;
562 while i < bytes.len() {
563 match config[i..].find('$') {
565 None => {
566 push_literal(&mut parts, &config[i..]);
567 break;
568 }
569 Some(offset) => {
570 let dollar = i + offset;
571 push_literal(&mut parts, &config[i..dollar]);
572 let after = dollar + 1;
573 let next = bytes.get(after).copied();
574 if next == Some(b'$') || next == Some(b'!') {
575 push_literal(&mut parts, &config[after..after + 1]);
576 i = after + 1;
577 continue;
578 }
579 if next == Some(b'{') {
580 if let Some(end_rel) = config[after + 1..].find('}') {
582 let end = after + 1 + end_rel;
583 let name = &config[after + 1..end];
584 if is_env_name(name) {
585 parts.push(TemplatePart::Env(name.to_string()));
586 } else {
587 push_literal(&mut parts, &config[dollar..=end]);
589 }
590 i = end + 1;
591 continue;
592 }
593 push_literal(&mut parts, "$");
595 i = after;
596 continue;
597 }
598 if let Some(name) = env_name_prefix(&config[after..]) {
600 parts.push(TemplatePart::Env(name.to_string()));
601 i = after + name.len();
602 } else {
603 push_literal(&mut parts, "$");
604 i = after;
605 }
606 }
607 }
608 }
609 parts
610}
611
612fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
613 if value.is_empty() {
614 return;
615 }
616 if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
617 s.push_str(value);
618 } else {
619 parts.push(TemplatePart::Literal(value.to_string()));
620 }
621}
622
623fn is_env_name(s: &str) -> bool {
624 let mut chars = s.chars();
625 match chars.next() {
626 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
627 _ => return false,
628 }
629 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
630}
631
632fn env_name_prefix(s: &str) -> Option<&str> {
635 let mut chars = s.char_indices();
636 match chars.next() {
637 Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
638 _ => return None,
639 }
640 let end = chars
641 .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
642 .map(|(idx, _)| idx)
643 .unwrap_or(s.len());
644 Some(&s[..end])
645}
646
647fn resolve_template(
650 config: &str,
651 env_overlay: Option<&BTreeMap<String, String>>,
652) -> Option<String> {
653 let parts = parse_template(config);
654 let mut out = String::with_capacity(config.len());
655 for part in parts {
656 match part {
657 TemplatePart::Literal(s) => out.push_str(&s),
658 TemplatePart::Env(name) => match env_lookup(&name, env_overlay) {
659 Some(v) => out.push_str(&v),
660 None => return None,
661 },
662 }
663 }
664 Some(out)
665}
666
667fn resolve_command(cmd: &str) -> Option<String> {
671 let key = format!("!{cmd}");
672 if let Some(v) = command_cache().lock().ok()?.get(&key) {
673 return v.clone();
674 }
675 let result = resolve_command_uncached(cmd);
676 if let Ok(mut cache) = command_cache().lock() {
677 cache.insert(key, result.clone());
678 }
679 result
680}
681
682#[cfg(unix)]
683fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
684 std::process::Command::new("sh")
685 .arg("-c")
686 .arg(cmd)
687 .stdin(std::process::Stdio::null())
688 .stdout(std::process::Stdio::piped())
689 .stderr(std::process::Stdio::null())
690 .output()
691 .ok()
692}
693
694#[cfg(windows)]
695fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
696 use std::os::windows::process::CommandExt;
697 std::process::Command::new("cmd")
698 .arg("/C")
699 .arg(cmd)
700 .stdin(std::process::Stdio::null())
701 .stdout(std::process::Stdio::piped())
702 .stderr(std::process::Stdio::null())
703 .creation_flags(0x0800_0000) .output()
705 .ok()
706}
707
708fn resolve_command_uncached(cmd: &str) -> Option<String> {
710 let output = spawn_shell_command(cmd)?;
711 if !output.status.success() {
712 return None;
713 }
714 let stdout = String::from_utf8_lossy(&output.stdout);
715 let trimmed = stdout.trim();
716 if trimmed.is_empty() {
717 None
718 } else {
719 Some(trimmed.to_string())
720 }
721}
722
723pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
728 match cfg.api.as_deref() {
729 None | Some("") | Some("anthropic-messages") => true,
730 _ => false,
731 }
732}
733
734pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
736 matches!(cfg.api.as_deref(), Some("openai-completions"))
737}
738
739pub fn provider_is_openai_responses(cfg: &ProviderConfig) -> bool {
740 matches!(cfg.api.as_deref(), Some("openai-responses"))
741}
742
743pub fn openai_provider_api_key(provider_id: &str, cfg: &ProviderConfig) -> Option<String> {
749 if let Some(raw) = cfg.api_key.as_deref().filter(|key| !key.is_empty()) {
750 if let Some(value) = resolve_config_value(raw, None).filter(|value| !value.is_empty()) {
751 return Some(value);
752 }
753 }
754
755 let normalized: String = provider_id
756 .chars()
757 .map(|ch| {
758 if ch.is_ascii_alphanumeric() {
759 ch.to_ascii_uppercase()
760 } else {
761 '_'
762 }
763 })
764 .collect();
765 let mut names = vec![format!("{normalized}_API_KEY")];
766 match provider_id.to_ascii_lowercase().as_str() {
767 "openai" | "openai-completions" | "openai-responses" => {
768 names.push("OPENAI_API_KEY".to_string())
769 }
770 "deepseek" => names.push("DEEPSEEK_API_KEY".to_string()),
771 "groq" => names.push("GROQ_API_KEY".to_string()),
772 "mistral" => names.push("MISTRAL_API_KEY".to_string()),
773 "fireworks" => names.push("FIREWORKS_API_KEY".to_string()),
774 "together" | "togetherai" => names.push("TOGETHER_API_KEY".to_string()),
775 "openrouter" => names.push("OPENROUTER_API_KEY".to_string()),
776 "xai" => names.push("XAI_API_KEY".to_string()),
777 "cerebras" => names.push("CEREBRAS_API_KEY".to_string()),
778 "perplexity" => names.push("PERPLEXITY_API_KEY".to_string()),
779 "moonshot" | "moonshotai" | "kimi" => names.push("MOONSHOT_API_KEY".to_string()),
780 "qwen" | "qwen-token-plan" => names.push("DASHSCOPE_API_KEY".to_string()),
781 "zai" | "zhipu" => names.push("ZHIPUAI_API_KEY".to_string()),
782 _ => {}
783 }
784 names
785 .into_iter()
786 .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
787}
788
789pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
793 let api = if provider_is_anthropic_compatible(cfg) {
794 Api::AnthropicMessages
795 } else if provider_is_openai_completions(cfg) {
796 Api::OpenaiCompletions
797 } else if provider_is_openai_responses(cfg) {
798 Api::OpenaiResponses
799 } else {
800 return None;
801 };
802 let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
803 Api::OpenaiCompletions | Api::OpenaiResponses => "https://api.openai.com".to_string(),
804 _ => default_anthropic_base_url(),
805 });
806 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
807 for def in &cfg.models {
808 let base_url = def
809 .base_url
810 .clone()
811 .unwrap_or_else(|| provider_base.clone());
812 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
813 let runtime_provider = match api {
826 Api::OpenaiCompletions | Api::OpenaiResponses => provider_id,
827 _ => DEFAULT_PROVIDER_ID,
828 };
829 let mut m = Model::new(
830 def.id.clone(),
831 name,
832 api.clone(),
833 runtime_provider.to_string(),
834 base_url,
835 );
836 m.reasoning = def.reasoning.unwrap_or(false);
837 m.context_window = def.context_window.unwrap_or(0);
838 m.max_tokens = def.max_tokens.unwrap_or(0);
839 m.input = parse_input_modalities(def.input.as_deref());
840 let mut headers: BTreeMap<String, String> = BTreeMap::new();
854 if let Some(h) = def.headers.clone() {
855 for (k, v) in resolve_headers(&h, None) {
856 headers.insert(k, v);
857 }
858 }
859 if let Some(h) = cfg.headers.clone() {
860 for (k, v) in resolve_headers(&h, None) {
861 headers.insert(k, v);
862 }
863 }
864 if matches!(api, Api::OpenaiCompletions | Api::OpenaiResponses) {
865 if let Some(key) = openai_provider_api_key(provider_id, cfg) {
866 headers.retain(|name, _| !name.eq_ignore_ascii_case("authorization"));
867 headers.insert("authorization".to_string(), format!("Bearer {key}"));
868 }
869 if let Some(value) = def.compat.clone() {
870 if matches!(api, Api::OpenaiResponses) {
871 if let Ok(compat) = serde_json::from_value(value) {
872 m.compat = Some(StreamingProtocolCompat::OpenaiResponses(compat));
873 }
874 } else if let Ok(compat) = serde_json::from_value(value) {
875 m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
876 }
877 }
878 }
879 if !headers.is_empty() {
880 m.headers = Some(headers);
881 }
882 merged.push(m);
883 }
884 Some(merged)
885}
886
887fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
890 match input {
891 None => vec![InputModality::Text],
892 Some(list) if list.is_empty() => vec![InputModality::Text],
893 Some(list) => list
894 .iter()
895 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
896 "text" => Some(InputModality::Text),
897 "image" => Some(InputModality::Image),
898 _ => None,
899 })
900 .collect::<Vec<_>>()
901 .pipe(|v| {
902 if v.is_empty() {
903 vec![InputModality::Text]
904 } else {
905 v
906 }
907 }),
908 }
909}
910
911pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
917
918pub fn default_anthropic_base_url() -> String {
922 ANTHROPIC_DEFAULT_BASE_URL.to_string()
923}
924
925#[cfg(unix)]
930use std::os::unix::fs::PermissionsExt;
931
932fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
935 if dir.exists() {
936 return Ok(());
937 }
938 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
939 path: dir.to_path_buf(),
940 source: e,
941 })?;
942 #[cfg(unix)]
943 {
944 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
945 }
946 Ok(())
947}
948
949fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
952 let dir = path.parent().ok_or_else(|| ConfigError::Write {
953 path: path.to_path_buf(),
954 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
955 })?;
956 let tmp = dir.join(format!(
957 ".{}.tmp",
958 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi")
959 ));
960 std::fs::write(&tmp, bytes).map_err(|e| ConfigError::Write {
961 path: tmp.clone(),
962 source: e,
963 })?;
964 std::fs::rename(&tmp, path).map_err(|e| ConfigError::Write {
965 path: path.to_path_buf(),
966 source: e,
967 })?;
968 Ok(())
969}
970
971fn set_owner_only(_path: &Path) {
974 #[cfg(unix)]
975 {
976 let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
977 }
978}
979
980trait Pipe: Sized {
983 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
984 f(self)
985 }
986}
987impl<T> Pipe for T {}
988
989#[cfg(test)]
994pub(crate) mod test_support {
995 use std::sync::{Mutex, OnceLock};
1001 pub(crate) fn env_lock() -> &'static Mutex<()> {
1002 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1003 LOCK.get_or_init(|| Mutex::new(()))
1004 }
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009 use super::*;
1010 use crate::config::test_support::env_lock;
1011
1012 struct TempConfig {
1016 _guard: std::sync::MutexGuard<'static, ()>,
1017 _tmp: tempfile::TempDir,
1018 prev: Option<std::ffi::OsString>,
1019 }
1020 impl TempConfig {
1021 fn new() -> Self {
1022 let guard = env_lock().lock().unwrap();
1023 let prev = std::env::var_os(CONFIG_DIR_ENV);
1024 let tmp = tempfile::TempDir::new().unwrap();
1025 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1026 Self {
1027 _guard: guard,
1028 _tmp: tmp,
1029 prev,
1030 }
1031 }
1032 }
1033 impl Drop for TempConfig {
1034 fn drop(&mut self) {
1035 restore_env(CONFIG_DIR_ENV, self.prev.take());
1036 }
1037 }
1038
1039 #[test]
1040 fn read_auth_missing_file_is_empty() {
1041 let _cfg = TempConfig::new();
1042 let store = read_auth().unwrap();
1043 assert!(store.is_empty());
1044 }
1045
1046 #[test]
1047 fn upsert_then_read_roundtrip() {
1048 let _cfg = TempConfig::new();
1049 upsert_credential(
1050 "anthropic",
1051 Credential::ApiKey {
1052 key: Some("sk-test-123".into()),
1053 env: None,
1054 },
1055 )
1056 .unwrap();
1057 let store = read_auth().unwrap();
1058 match store.get("anthropic") {
1059 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
1060 other => panic!("unexpected cred: {other:?}"),
1061 }
1062 let path = auth_path().unwrap();
1064 assert!(path.exists(), "auth.json should exist after upsert");
1065 let raw = std::fs::read_to_string(&path).unwrap();
1066 assert!(raw.contains("\"anthropic\""));
1067 assert!(raw.contains("api_key"));
1068 }
1069
1070 #[test]
1071 fn delete_credential_removes_entry() {
1072 let _cfg = TempConfig::new();
1073 upsert_credential(
1074 "anthropic",
1075 Credential::ApiKey {
1076 key: Some("k".into()),
1077 env: None,
1078 },
1079 )
1080 .unwrap();
1081 assert!(delete_credential("anthropic").unwrap());
1082 assert!(!delete_credential("anthropic").unwrap());
1084 assert!(read_auth().unwrap().is_empty());
1085 }
1086
1087 #[test]
1088 fn load_models_config_missing_is_empty() {
1089 let _cfg = TempConfig::new();
1090 let c = load_models_config().unwrap();
1091 assert!(c.providers.is_empty());
1092 }
1093
1094 #[test]
1095 fn load_models_config_parses_with_comments() {
1096 let _cfg = TempConfig::new();
1097 let json = r#"{
1098 // a one-api style gateway
1099 "providers": {
1100 "gateway": {
1101 "baseUrl": "https://gw.example.com",
1102 "authHeader": true,
1103 "apiKey": "gw-secret",
1104 "models": [
1105 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
1106 ]
1107 }
1108 }
1109}"#;
1110 std::fs::write(models_path().unwrap(), json).unwrap();
1111 let c = load_models_config().unwrap();
1112 let gw = c
1113 .providers
1114 .get("gateway")
1115 .expect("gateway provider present");
1116 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
1117 assert!(gw.auth_header.unwrap_or(false));
1118 assert_eq!(gw.models.len(), 1);
1119 assert_eq!(gw.models[0].id, "claude-sonnet-5");
1120 }
1121
1122 #[test]
1123 fn provider_to_models_merges_headers_without_synth_bearer() {
1124 let cfg = ProviderConfig {
1130 name: None,
1131 base_url: Some("https://gw.example.com".into()),
1132 api_key: Some("gw-secret".into()),
1133 api: None,
1134 headers: Some({
1135 let mut h = BTreeMap::new();
1136 h.insert("x-portkey-key".into(), "portkey-secret".into());
1137 h
1138 }),
1139 auth_header: Some(true),
1140 models: vec![ModelDefinition {
1141 id: "claude-sonnet-5".into(),
1142 name: None,
1143 base_url: None,
1144 reasoning: None,
1145 context_window: None,
1146 max_tokens: None,
1147 input: None,
1148 headers: None,
1149 compat: None,
1150 }],
1151 };
1152 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1153 assert_eq!(models.len(), 1);
1154 let m = &models[0];
1155 assert_eq!(m.id, "claude-sonnet-5");
1156 assert_eq!(m.base_url, "https://gw.example.com");
1157 assert_eq!(m.provider, DEFAULT_PROVIDER_ID);
1161 let headers = m.headers.as_ref().expect("provider headers merged");
1162 assert_eq!(
1164 headers.get("x-portkey-key").map(|s| s.as_str()),
1165 Some("portkey-secret")
1166 );
1167 assert!(
1172 headers.get("authorization").is_none(),
1173 "provider_to_models must not synthesize the Bearer; resolve does"
1174 );
1175 }
1176
1177 #[test]
1178 fn provider_to_models_supports_openai_completions() {
1179 let config: ModelsConfig = serde_json::from_str(
1180 r#"{
1181 "providers": {
1182 "oai": {
1183 "api": "openai-completions",
1184 "baseUrl": "https://gateway.example.com/v1",
1185 "apiKey": "secret",
1186 "models": [{"id":"gpt-test","maxTokens":4096}]
1187 }
1188 }
1189 }"#,
1190 )
1191 .unwrap();
1192 let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
1193 assert_eq!(models.len(), 1);
1194 assert_eq!(models[0].api, Api::OpenaiCompletions);
1195 assert_eq!(models[0].provider, "oai");
1196 assert_eq!(models[0].max_tokens, 4096);
1197 assert_eq!(
1198 models[0]
1199 .headers
1200 .as_ref()
1201 .and_then(|headers| headers.get("authorization"))
1202 .map(String::as_str),
1203 Some("Bearer secret")
1204 );
1205 }
1206
1207 #[test]
1208 fn openai_provider_uses_provider_specific_api_key_environment_alias() {
1209 let _guard = env_lock().lock().unwrap();
1210 let env_name = "RPI_FAKE_PROVIDER_API_KEY";
1211 std::env::set_var(env_name, "env-secret");
1212 let cfg = ProviderConfig {
1213 name: None,
1214 base_url: Some("https://gateway.example.com/v1".into()),
1215 api_key: None,
1216 api: Some("openai-completions".into()),
1217 headers: None,
1218 auth_header: None,
1219 models: vec![ModelDefinition {
1220 id: "fake-model".into(),
1221 name: None,
1222 base_url: None,
1223 reasoning: None,
1224 context_window: None,
1225 max_tokens: None,
1226 input: None,
1227 headers: None,
1228 compat: None,
1229 }],
1230 };
1231 let models = provider_to_models("rpi-fake-provider", &cfg).unwrap();
1232 assert_eq!(
1233 models[0]
1234 .headers
1235 .as_ref()
1236 .and_then(|headers| headers.get("authorization"))
1237 .map(String::as_str),
1238 Some("Bearer env-secret")
1239 );
1240 std::env::remove_var(env_name);
1241 }
1242
1243 #[test]
1244 fn malformed_auth_json_is_an_error_not_silent_empty() {
1245 let _cfg = TempConfig::new();
1246 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1247 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1248 }
1249
1250 #[test]
1251 fn agent_dir_nests_under_agent_by_default() {
1252 let _guard = env_lock().lock().unwrap();
1256 let prev = std::env::var_os(CONFIG_DIR_ENV);
1257 std::env::remove_var(CONFIG_DIR_ENV);
1258 let dir = agent_dir().unwrap();
1259 restore_env(CONFIG_DIR_ENV, prev);
1260 assert!(dir.ends_with("agent"));
1261 assert!(dir
1262 .parent()
1263 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1264 .unwrap_or(false));
1265 }
1266
1267 #[test]
1268 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1269 let tmp = tempfile::TempDir::new().unwrap();
1273 let root = tmp.path().to_path_buf();
1274 let agent = root.join("agent");
1275 std::fs::write(root.join("auth.json"), "{}").unwrap();
1276 std::fs::write(root.join("models.json"), "{}").unwrap();
1277 std::fs::write(root.join(".setup_done"), "1").unwrap();
1278 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1279 assert_eq!(moved, 3);
1280 assert!(agent.join("auth.json").exists());
1281 assert!(agent.join("models.json").exists());
1282 assert!(agent.join(".setup_done").exists());
1283 assert!(!root.join("auth.json").exists());
1284 }
1285
1286 #[test]
1287 fn migrate_legacy_layout_noop_when_agent_exists() {
1288 let tmp = tempfile::TempDir::new().unwrap();
1289 let root = tmp.path().to_path_buf();
1290 let agent = root.join("agent");
1291 std::fs::write(root.join("auth.json"), "{}").unwrap();
1292 std::fs::create_dir_all(&agent).unwrap();
1293 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1294 assert_eq!(moved, 0); }
1296
1297 #[test]
1298 fn migrate_legacy_layout_noop_when_no_flat_files() {
1299 let tmp = tempfile::TempDir::new().unwrap();
1300 let root = tmp.path().to_path_buf();
1301 let agent = root.join("agent");
1302 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1303 assert_eq!(moved, 0);
1304 }
1305
1306 #[test]
1307 fn migrate_legacy_layout_public_skips_env_override() {
1308 let _cfg = TempConfig::new();
1311 let moved = migrate_legacy_layout().unwrap();
1312 assert_eq!(moved, 0);
1313 }
1314
1315 #[test]
1316 fn read_trust_missing_file_is_empty() {
1317 let _cfg = TempConfig::new();
1318 assert!(read_trust().unwrap().is_empty());
1319 }
1320
1321 #[test]
1322 fn read_trust_parses_decisions() {
1323 let _cfg = TempConfig::new();
1324 let path = trust_path().unwrap();
1325 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1326 std::fs::write(
1327 &path,
1328 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1329 )
1330 .unwrap();
1331 let store = read_trust().unwrap();
1332 assert_eq!(store.len(), 3);
1333 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1334 assert_eq!(
1335 store.get("/home/me/untrusted").copied().flatten(),
1336 Some(false)
1337 );
1338 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1339 }
1340
1341 #[test]
1342 fn resolve_config_value_literal_passthrough() {
1343 assert_eq!(
1344 resolve_config_value("sk-literal-key", None),
1345 Some("sk-literal-key".into())
1346 );
1347 }
1348
1349 #[test]
1350 fn resolve_config_value_env_var() {
1351 let _guard = env_lock().lock().unwrap();
1352 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1353 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1354 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1355 assert_eq!(
1356 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1357 Some("secret-from-env".into())
1358 );
1359 assert_eq!(
1360 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1361 Some("prefix-secret-from-env-suffix".into())
1362 );
1363 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1365 assert_eq!(
1366 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1367 Some("a-secret-from-env-b-two-c".into())
1368 );
1369 let mut overlay = BTreeMap::new();
1371 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1372 assert_eq!(
1373 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1374 Some("overlay-value".into())
1375 );
1376 restore_env("RPI_TEST_CFG_KEY", prev);
1377 restore_env("RPI_TEST_CFG_KEY2", prev2);
1378 }
1379
1380 #[test]
1381 fn resolve_config_value_unset_env_is_none() {
1382 let _guard = env_lock().lock().unwrap();
1383 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1384 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1385 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1387 assert_eq!(
1388 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1389 None
1390 );
1391 restore_env("RPI_TEST_CFG_ABSENT", prev);
1392 }
1393
1394 #[test]
1395 fn resolve_config_value_dollar_dollar_escapes_literal() {
1396 assert_eq!(
1397 resolve_config_value("price-$$5", None),
1398 Some("price-$5".into())
1399 );
1400 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1401 }
1402
1403 #[test]
1404 fn resolve_config_value_command_runs_shell() {
1405 assert_eq!(
1407 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1408 Some("rpi-cfg-resolved".into())
1409 );
1410 assert_eq!(resolve_config_value_uncached("!false", None), None);
1412 }
1413
1414 #[test]
1415 fn resolve_headers_drops_unresolvable() {
1416 let _guard = env_lock().lock().unwrap();
1417 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1418 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1419 let mut h = BTreeMap::new();
1420 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1421 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1422 h.insert("x-literal".into(), "literal-value".into());
1423 let resolved = resolve_headers(&h, None);
1424 assert_eq!(resolved.len(), 2);
1425 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1426 assert_eq!(
1427 resolved.get("x-literal").map(|s| s.as_str()),
1428 Some("literal-value")
1429 );
1430 assert!(!resolved.contains_key("x-unset"));
1431 restore_env("RPI_TEST_HDR_SET", prev);
1432 }
1433
1434 #[test]
1435 fn agent_dir_respects_env_override() {
1436 let _guard = env_lock().lock().unwrap();
1437 let prev = std::env::var_os(CONFIG_DIR_ENV);
1438 let tmp = tempfile::TempDir::new().unwrap();
1439 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1440 let dir = agent_dir().unwrap();
1441 restore_env(CONFIG_DIR_ENV, prev);
1442 assert_eq!(dir, tmp.path());
1443 }
1444
1445 #[test]
1446 fn relative_override_is_rejected() {
1447 let _guard = env_lock().lock().unwrap();
1448 let prev = std::env::var_os(CONFIG_DIR_ENV);
1449 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1450 let err = agent_dir().unwrap_err();
1451 restore_env(CONFIG_DIR_ENV, prev);
1452 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1453 }
1454
1455 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1457 match prev {
1458 Some(v) => std::env::set_var(name, v),
1459 None => std::env::remove_var(name),
1460 }
1461 }
1462}