1use std::collections::BTreeMap;
39use std::path::{Path, PathBuf};
40
41use rpi_ai::{Api, InputModality, Model};
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 { path: PathBuf, #[source] source: std::io::Error },
69 #[error("could not write {path}: {source}")]
70 Write { path: PathBuf, #[source] source: std::io::Error },
71 #[error("invalid JSON in {path}: {source}")]
72 Json { path: PathBuf, #[source] source: serde_json::Error },
73}
74
75pub fn agent_dir() -> Result<PathBuf, ConfigError> {
85 if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
86 let p = PathBuf::from(&val);
87 if !p.is_absolute() {
88 return Err(ConfigError::RelativeOverride {
89 env: CONFIG_DIR_ENV,
90 val: val.to_string_lossy().into_owned(),
91 });
92 }
93 return Ok(p);
94 }
95 let home = dirs::home_dir()
96 .ok_or(ConfigError::NoHomeDir { env: CONFIG_DIR_ENV })?;
97 Ok(home.join(CONFIG_DIR_NAME).join("agent"))
98}
99
100fn config_root_dir() -> Result<PathBuf, ConfigError> {
104 let agent = agent_dir()?;
105 agent
106 .parent()
107 .map(Path::to_path_buf)
108 .ok_or(ConfigError::NoHomeDir { env: CONFIG_DIR_ENV })
109}
110
111pub fn auth_path() -> Result<PathBuf, ConfigError> {
113 Ok(agent_dir()?.join("auth.json"))
114}
115
116pub fn models_path() -> Result<PathBuf, ConfigError> {
118 Ok(agent_dir()?.join("models.json"))
119}
120
121pub fn settings_path() -> Result<PathBuf, ConfigError> {
123 Ok(agent_dir()?.join("settings.json"))
124}
125
126pub fn trust_path() -> Result<PathBuf, ConfigError> {
129 Ok(agent_dir()?.join("trust.json"))
130}
131
132pub fn migrate_legacy_layout() -> Result<usize, ConfigError> {
140 if std::env::var_os(CONFIG_DIR_ENV).is_some() {
142 return Ok(0);
143 }
144 let root = match config_root_dir() {
145 Ok(p) => p,
146 Err(_) => return Ok(0),
147 };
148 let agent = agent_dir()?;
149 migrate_legacy_layout_in(&root, &agent)
150}
151
152fn migrate_legacy_layout_in(root: &Path, agent: &Path) -> Result<usize, ConfigError> {
159 if agent.exists() {
161 return Ok(0);
162 }
163 let flat_auth = root.join("auth.json");
165 let flat_models = root.join("models.json");
166 if !flat_auth.exists() && !flat_models.exists() {
167 return Ok(0);
168 }
169 std::fs::create_dir_all(agent).map_err(|e| ConfigError::Write {
170 path: agent.to_path_buf(),
171 source: e,
172 })?;
173 let mut moved = 0usize;
174 for leaf in ["auth.json", "models.json", ".setup_done", ".earendil_seen"] {
175 let from = root.join(leaf);
176 let to = agent.join(leaf);
177 if from.exists() && !to.exists() {
178 if let Err(_e) = std::fs::rename(&from, &to) {
181 if std::fs::copy(&from, &to).is_ok() {
182 let _ = std::fs::remove_file(&from);
183 }
184 }
185 moved += 1;
186 }
187 }
188 Ok(moved)
189}
190
191#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
200#[serde(rename_all = "snake_case", tag = "type")]
201pub enum Credential {
202 ApiKey {
205 key: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 env: Option<BTreeMap<String, String>>,
208 },
209 Oauth {
211 access: String,
212 refresh: String,
213 expires: i64,
215 },
216}
217
218pub type AuthStore = BTreeMap<String, Credential>;
221
222pub fn read_auth() -> Result<AuthStore, ConfigError> {
225 let path = auth_path()?;
226 match std::fs::read_to_string(&path) {
227 Ok(text) => Ok(serde_json::from_str(&text).map_err(|e| ConfigError::Json {
228 path: path.clone(),
229 source: e,
230 })?),
231 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()),
232 Err(e) => Err(ConfigError::Read { path, source: e }),
233 }
234}
235
236pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
239 let path = auth_path()?;
240 let dir = agent_dir()?;
241 ensure_dir(&dir)?;
242 let json = serde_json::to_string_pretty(store).unwrap();
243 atomic_write(&path, json.as_bytes())?;
244 set_owner_only(&path);
245 Ok(())
246}
247
248pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
250 let mut store = read_auth()?;
251 store.insert(provider_id.to_string(), cred);
252 write_auth(&store)
253}
254
255pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
260 let mut store = read_auth()?;
261 if store.remove(provider_id).is_some() {
262 write_auth(&store)?;
263 Ok(true)
264 } else {
265 Ok(false)
266 }
267}
268
269#[derive(serde::Deserialize, Default, Debug, Clone)]
276#[serde(rename_all = "camelCase")]
277pub struct ModelsConfig {
278 #[serde(default)]
279 pub providers: BTreeMap<String, ProviderConfig>,
280}
281
282#[derive(serde::Deserialize, Debug, Clone)]
286#[serde(rename_all = "camelCase")]
287pub struct ProviderConfig {
288 #[serde(default)]
289 pub name: Option<String>,
290 #[serde(default)]
291 pub base_url: Option<String>,
292 #[serde(default)]
293 pub api_key: Option<String>,
294 #[serde(default)]
295 pub api: Option<String>,
296 #[serde(default)]
297 pub headers: Option<BTreeMap<String, String>>,
298 #[serde(default)]
301 pub auth_header: Option<bool>,
302 #[serde(default)]
303 pub models: Vec<ModelDefinition>,
304}
305
306#[derive(serde::Deserialize, Debug, Clone)]
308#[serde(rename_all = "camelCase")]
309pub struct ModelDefinition {
310 pub id: String,
311 #[serde(default)]
312 pub name: Option<String>,
313 #[serde(default)]
314 pub base_url: Option<String>,
315 #[serde(default)]
316 pub reasoning: Option<bool>,
317 #[serde(default)]
318 pub context_window: Option<u64>,
319 #[serde(default)]
320 pub max_tokens: Option<u64>,
321 #[serde(default)]
324 pub input: Option<Vec<String>>,
325 #[serde(default)]
326 pub headers: Option<BTreeMap<String, String>>,
327}
328
329pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
331 let path = models_path()?;
332 match std::fs::read_to_string(&path) {
333 Ok(text) => parse_models_json(&text).map_err(|e| ConfigError::Json {
334 path: path.clone(),
335 source: e,
336 }),
337 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsConfig::default()),
338 Err(e) => Err(ConfigError::Read { path, source: e }),
339 }
340}
341
342pub type TrustStore = BTreeMap<String, Option<bool>>;
352
353pub fn read_trust() -> Result<TrustStore, ConfigError> {
357 let path = trust_path()?;
358 match std::fs::read_to_string(&path) {
359 Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
360 path: path.clone(),
361 source: e,
362 }),
363 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
364 Err(e) => Err(ConfigError::Read { path, source: e }),
365 }
366}
367
368fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
372 match serde_json::from_str(text) {
373 Ok(c) => Ok(c),
374 Err(first) => {
375 let stripped = strip_line_comments(text);
380 serde_json::from_str(&stripped).map_err(|_| first)
381 }
382 }
383}
384
385pub(crate) fn strip_line_comments(text: &str) -> String {
390 text.lines()
391 .map(|line| match find_line_comment(line) {
392 Some(idx) => line[..idx].to_string(),
393 None => line.to_string(),
394 })
395 .collect::<Vec<_>>()
396 .join("\n")
397}
398
399fn find_line_comment(line: &str) -> Option<usize> {
401 let mut in_str = false;
402 let mut esc = false;
403 for (i, ch) in line.char_indices() {
404 if esc {
405 esc = false;
406 continue;
407 }
408 match ch {
409 '\\' if in_str => esc = true,
410 '"' => in_str = !in_str,
411 '/' if !in_str => {
412 if line.as_bytes().get(i + 1) == Some(&b'/') {
413 return Some(i);
414 }
415 }
416 _ => {}
417 }
418 }
419 None
420}
421
422fn command_cache(
429) -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
430 static CACHE: std::sync::OnceLock<
431 std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
432 > = std::sync::OnceLock::new();
433 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
434}
435
436pub fn resolve_config_value(
451 config: &str,
452 env_overlay: Option<&BTreeMap<String, String>>,
453) -> Option<String> {
454 if let Some(cmd) = config.strip_prefix('!') {
455 return resolve_command(cmd);
456 }
457 resolve_template(config, env_overlay)
458}
459
460pub fn resolve_config_value_uncached(
464 config: &str,
465 env_overlay: Option<&BTreeMap<String, String>>,
466) -> Option<String> {
467 if let Some(cmd) = config.strip_prefix('!') {
468 return resolve_command_uncached(cmd);
469 }
470 resolve_template(config, env_overlay)
471}
472
473pub fn resolve_headers(
477 headers: &BTreeMap<String, String>,
478 env_overlay: Option<&BTreeMap<String, String>>,
479) -> BTreeMap<String, String> {
480 let mut out = BTreeMap::new();
481 for (k, v) in headers {
482 if let Some(resolved) = resolve_config_value_uncached(v, env_overlay) {
483 out.insert(k.clone(), resolved);
484 }
485 }
486 out
487}
488
489fn env_lookup(name: &str, env_overlay: Option<&BTreeMap<String, String>>) -> Option<String> {
492 if let Some(overlay) = env_overlay {
493 if let Some(v) = overlay.get(name) {
494 return Some(v.clone());
495 }
496 }
497 std::env::var(name).ok()
498}
499
500enum TemplatePart {
502 Literal(String),
503 Env(String),
504}
505
506fn parse_template(config: &str) -> Vec<TemplatePart> {
511 let mut parts: Vec<TemplatePart> = Vec::new();
512 let bytes = config.as_bytes();
513 let mut i = 0usize;
514 while i < bytes.len() {
515 match config[i..].find('$') {
517 None => {
518 push_literal(&mut parts, &config[i..]);
519 break;
520 }
521 Some(offset) => {
522 let dollar = i + offset;
523 push_literal(&mut parts, &config[i..dollar]);
524 let after = dollar + 1;
525 let next = bytes.get(after).copied();
526 if next == Some(b'$') || next == Some(b'!') {
527 push_literal(&mut parts, &config[after..after + 1]);
528 i = after + 1;
529 continue;
530 }
531 if next == Some(b'{') {
532 if let Some(end_rel) = config[after + 1..].find('}') {
534 let end = after + 1 + end_rel;
535 let name = &config[after + 1..end];
536 if is_env_name(name) {
537 parts.push(TemplatePart::Env(name.to_string()));
538 } else {
539 push_literal(&mut parts, &config[dollar..=end]);
541 }
542 i = end + 1;
543 continue;
544 }
545 push_literal(&mut parts, "$");
547 i = after;
548 continue;
549 }
550 if let Some(name) = env_name_prefix(&config[after..]) {
552 parts.push(TemplatePart::Env(name.to_string()));
553 i = after + name.len();
554 } else {
555 push_literal(&mut parts, "$");
556 i = after;
557 }
558 }
559 }
560 }
561 parts
562}
563
564fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
565 if value.is_empty() {
566 return;
567 }
568 if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
569 s.push_str(value);
570 } else {
571 parts.push(TemplatePart::Literal(value.to_string()));
572 }
573}
574
575fn is_env_name(s: &str) -> bool {
576 let mut chars = s.chars();
577 match chars.next() {
578 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
579 _ => return false,
580 }
581 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
582}
583
584fn env_name_prefix(s: &str) -> Option<&str> {
587 let mut chars = s.char_indices();
588 match chars.next() {
589 Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
590 _ => return None,
591 }
592 let end = chars
593 .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
594 .map(|(idx, _)| idx)
595 .unwrap_or(s.len());
596 Some(&s[..end])
597}
598
599fn resolve_template(
602 config: &str,
603 env_overlay: Option<&BTreeMap<String, String>>,
604) -> Option<String> {
605 let parts = parse_template(config);
606 let mut out = String::with_capacity(config.len());
607 for part in parts {
608 match part {
609 TemplatePart::Literal(s) => out.push_str(&s),
610 TemplatePart::Env(name) => match env_lookup(&name, env_overlay) {
611 Some(v) => out.push_str(&v),
612 None => return None,
613 },
614 }
615 }
616 Some(out)
617}
618
619fn resolve_command(cmd: &str) -> Option<String> {
623 let key = format!("!{cmd}");
624 if let Some(v) = command_cache().lock().ok()?.get(&key) {
625 return v.clone();
626 }
627 let result = resolve_command_uncached(cmd);
628 if let Ok(mut cache) = command_cache().lock() {
629 cache.insert(key, result.clone());
630 }
631 result
632}
633
634#[cfg(unix)]
635fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
636 std::process::Command::new("sh")
637 .arg("-c")
638 .arg(cmd)
639 .stdin(std::process::Stdio::null())
640 .stdout(std::process::Stdio::piped())
641 .stderr(std::process::Stdio::null())
642 .output()
643 .ok()
644}
645
646#[cfg(windows)]
647fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
648 use std::os::windows::process::CommandExt;
649 std::process::Command::new("cmd")
650 .arg("/C")
651 .arg(cmd)
652 .stdin(std::process::Stdio::null())
653 .stdout(std::process::Stdio::piped())
654 .stderr(std::process::Stdio::null())
655 .creation_flags(0x0800_0000) .output()
657 .ok()
658}
659
660fn resolve_command_uncached(cmd: &str) -> Option<String> {
662 let output = spawn_shell_command(cmd)?;
663 if !output.status.success() {
664 return None;
665 }
666 let stdout = String::from_utf8_lossy(&output.stdout);
667 let trimmed = stdout.trim();
668 if trimmed.is_empty() {
669 None
670 } else {
671 Some(trimmed.to_string())
672 }
673}
674
675
676pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
681 match cfg.api.as_deref() {
682 None | Some("") | Some("anthropic-messages") => true,
683 _ => false,
684 }
685}
686
687pub fn provider_to_models(
691 provider_id: &str,
692 cfg: &ProviderConfig,
693) -> Option<Vec<Model>> {
694 let _ = provider_id; if !provider_is_anthropic_compatible(cfg) {
696 return None;
697 }
698 let provider_base = cfg.base_url.clone().unwrap_or_else(default_anthropic_base_url);
699 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
700 for def in &cfg.models {
701 let base_url = def
702 .base_url
703 .clone()
704 .unwrap_or_else(|| provider_base.clone());
705 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
706 let mut m = Model::new(
719 def.id.clone(),
720 name,
721 Api::AnthropicMessages,
722 DEFAULT_PROVIDER_ID.to_string(),
723 base_url,
724 );
725 m.reasoning = def.reasoning.unwrap_or(false);
726 m.context_window = def.context_window.unwrap_or(0);
727 m.max_tokens = def.max_tokens.unwrap_or(0);
728 m.input = parse_input_modalities(def.input.as_deref());
729 let mut headers: BTreeMap<String, String> = BTreeMap::new();
743 if let Some(h) = def.headers.clone() {
744 for (k, v) in resolve_headers(&h, None) {
745 headers.insert(k, v);
746 }
747 }
748 if let Some(h) = cfg.headers.clone() {
749 for (k, v) in resolve_headers(&h, None) {
750 headers.insert(k, v);
751 }
752 }
753 if !headers.is_empty() {
754 m.headers = Some(headers);
755 }
756 merged.push(m);
757 }
758 Some(merged)
759}
760
761fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
764 match input {
765 None => vec![InputModality::Text],
766 Some(list) if list.is_empty() => vec![InputModality::Text],
767 Some(list) => list
768 .iter()
769 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
770 "text" => Some(InputModality::Text),
771 "image" => Some(InputModality::Image),
772 _ => None,
773 })
774 .collect::<Vec<_>>()
775 .pipe(|v| if v.is_empty() { vec![InputModality::Text] } else { v }),
776 }
777}
778
779pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
785
786fn default_anthropic_base_url() -> String {
789 ANTHROPIC_DEFAULT_BASE_URL.to_string()
790}
791
792#[cfg(unix)]
797use std::os::unix::fs::PermissionsExt;
798
799fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
802 if dir.exists() {
803 return Ok(());
804 }
805 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
806 path: dir.to_path_buf(),
807 source: e,
808 })?;
809 #[cfg(unix)]
810 {
811 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
812 }
813 Ok(())
814}
815
816fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
819 let dir = path
820 .parent()
821 .ok_or_else(|| ConfigError::Write {
822 path: path.to_path_buf(),
823 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
824 })?;
825 let tmp = dir.join(format!(
826 ".{}.tmp",
827 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi")
828 ));
829 std::fs::write(&tmp, bytes).map_err(|e| ConfigError::Write { path: tmp.clone(), source: e })?;
830 std::fs::rename(&tmp, path).map_err(|e| ConfigError::Write {
831 path: path.to_path_buf(),
832 source: e,
833 })?;
834 Ok(())
835}
836
837fn set_owner_only(_path: &Path) {
840 #[cfg(unix)]
841 {
842 let _ = std::fs::set_permissions(
843 _path,
844 std::fs::Permissions::from_mode(0o600),
845 );
846 }
847}
848
849trait Pipe: Sized {
852 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
853 f(self)
854 }
855}
856impl<T> Pipe for T {}
857
858#[cfg(test)]
863pub(crate) mod test_support {
864 use std::sync::{Mutex, OnceLock};
870 pub(crate) fn env_lock() -> &'static Mutex<()> {
871 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
872 LOCK.get_or_init(|| Mutex::new(()))
873 }
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879 use crate::config::test_support::env_lock;
880
881 struct TempConfig {
885 _guard: std::sync::MutexGuard<'static, ()>,
886 _tmp: tempfile::TempDir,
887 prev: Option<std::ffi::OsString>,
888 }
889 impl TempConfig {
890 fn new() -> Self {
891 let guard = env_lock().lock().unwrap();
892 let prev = std::env::var_os(CONFIG_DIR_ENV);
893 let tmp = tempfile::TempDir::new().unwrap();
894 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
895 Self { _guard: guard, _tmp: tmp, prev }
896 }
897 }
898 impl Drop for TempConfig {
899 fn drop(&mut self) {
900 restore_env(CONFIG_DIR_ENV, self.prev.take());
901 }
902 }
903
904 #[test]
905 fn read_auth_missing_file_is_empty() {
906 let _cfg = TempConfig::new();
907 let store = read_auth().unwrap();
908 assert!(store.is_empty());
909 }
910
911 #[test]
912 fn upsert_then_read_roundtrip() {
913 let _cfg = TempConfig::new();
914 upsert_credential(
915 "anthropic",
916 Credential::ApiKey { key: Some("sk-test-123".into()), env: None },
917 )
918 .unwrap();
919 let store = read_auth().unwrap();
920 match store.get("anthropic") {
921 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
922 other => panic!("unexpected cred: {other:?}"),
923 }
924 let path = auth_path().unwrap();
926 assert!(path.exists(), "auth.json should exist after upsert");
927 let raw = std::fs::read_to_string(&path).unwrap();
928 assert!(raw.contains("\"anthropic\""));
929 assert!(raw.contains("api_key"));
930 }
931
932 #[test]
933 fn delete_credential_removes_entry() {
934 let _cfg = TempConfig::new();
935 upsert_credential("anthropic", Credential::ApiKey { key: Some("k".into()), env: None })
936 .unwrap();
937 assert!(delete_credential("anthropic").unwrap());
938 assert!(!delete_credential("anthropic").unwrap());
940 assert!(read_auth().unwrap().is_empty());
941 }
942
943 #[test]
944 fn load_models_config_missing_is_empty() {
945 let _cfg = TempConfig::new();
946 let c = load_models_config().unwrap();
947 assert!(c.providers.is_empty());
948 }
949
950 #[test]
951 fn load_models_config_parses_with_comments() {
952 let _cfg = TempConfig::new();
953 let json = r#"{
954 // a one-api style gateway
955 "providers": {
956 "gateway": {
957 "baseUrl": "https://gw.example.com",
958 "authHeader": true,
959 "apiKey": "gw-secret",
960 "models": [
961 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
962 ]
963 }
964 }
965}"#;
966 std::fs::write(models_path().unwrap(), json).unwrap();
967 let c = load_models_config().unwrap();
968 let gw = c.providers.get("gateway").expect("gateway provider present");
969 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
970 assert!(gw.auth_header.unwrap_or(false));
971 assert_eq!(gw.models.len(), 1);
972 assert_eq!(gw.models[0].id, "claude-sonnet-5");
973 }
974
975 #[test]
976 fn provider_to_models_merges_headers_without_synth_bearer() {
977 let cfg = ProviderConfig {
983 name: None,
984 base_url: Some("https://gw.example.com".into()),
985 api_key: Some("gw-secret".into()),
986 api: None,
987 headers: Some({
988 let mut h = BTreeMap::new();
989 h.insert("x-portkey-key".into(), "portkey-secret".into());
990 h
991 }),
992 auth_header: Some(true),
993 models: vec![ModelDefinition {
994 id: "claude-sonnet-5".into(),
995 name: None,
996 base_url: None,
997 reasoning: None,
998 context_window: None,
999 max_tokens: None,
1000 input: None,
1001 headers: None,
1002 }],
1003 };
1004 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1005 assert_eq!(models.len(), 1);
1006 let m = &models[0];
1007 assert_eq!(m.id, "claude-sonnet-5");
1008 assert_eq!(m.base_url, "https://gw.example.com");
1009 assert_eq!(m.provider, DEFAULT_PROVIDER_ID);
1013 let headers = m.headers.as_ref().expect("provider headers merged");
1014 assert_eq!(
1016 headers.get("x-portkey-key").map(|s| s.as_str()),
1017 Some("portkey-secret")
1018 );
1019 assert!(
1024 headers.get("authorization").is_none(),
1025 "provider_to_models must not synthesize the Bearer; resolve does"
1026 );
1027 }
1028
1029 #[test]
1030 fn provider_to_models_ignores_non_anthropic_api() {
1031 let cfg = ProviderConfig {
1032 name: None,
1033 base_url: None,
1034 api_key: None,
1035 api: Some("openai-completions".into()),
1036 headers: None,
1037 auth_header: None,
1038 models: vec![],
1039 };
1040 assert!(provider_to_models("oai", &cfg).is_none());
1041 }
1042
1043 #[test]
1044 fn malformed_auth_json_is_an_error_not_silent_empty() {
1045 let _cfg = TempConfig::new();
1046 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1047 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1048 }
1049
1050 #[test]
1051 fn agent_dir_nests_under_agent_by_default() {
1052 let _guard = env_lock().lock().unwrap();
1056 let prev = std::env::var_os(CONFIG_DIR_ENV);
1057 std::env::remove_var(CONFIG_DIR_ENV);
1058 let dir = agent_dir().unwrap();
1059 restore_env(CONFIG_DIR_ENV, prev);
1060 assert!(dir.ends_with("agent"));
1061 assert!(dir
1062 .parent()
1063 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1064 .unwrap_or(false));
1065 }
1066
1067 #[test]
1068 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1069 let tmp = tempfile::TempDir::new().unwrap();
1073 let root = tmp.path().to_path_buf();
1074 let agent = root.join("agent");
1075 std::fs::write(root.join("auth.json"), "{}").unwrap();
1076 std::fs::write(root.join("models.json"), "{}").unwrap();
1077 std::fs::write(root.join(".setup_done"), "1").unwrap();
1078 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1079 assert_eq!(moved, 3);
1080 assert!(agent.join("auth.json").exists());
1081 assert!(agent.join("models.json").exists());
1082 assert!(agent.join(".setup_done").exists());
1083 assert!(!root.join("auth.json").exists());
1084 }
1085
1086 #[test]
1087 fn migrate_legacy_layout_noop_when_agent_exists() {
1088 let tmp = tempfile::TempDir::new().unwrap();
1089 let root = tmp.path().to_path_buf();
1090 let agent = root.join("agent");
1091 std::fs::write(root.join("auth.json"), "{}").unwrap();
1092 std::fs::create_dir_all(&agent).unwrap();
1093 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1094 assert_eq!(moved, 0); }
1096
1097 #[test]
1098 fn migrate_legacy_layout_noop_when_no_flat_files() {
1099 let tmp = tempfile::TempDir::new().unwrap();
1100 let root = tmp.path().to_path_buf();
1101 let agent = root.join("agent");
1102 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1103 assert_eq!(moved, 0);
1104 }
1105
1106 #[test]
1107 fn migrate_legacy_layout_public_skips_env_override() {
1108 let _cfg = TempConfig::new();
1111 let moved = migrate_legacy_layout().unwrap();
1112 assert_eq!(moved, 0);
1113 }
1114
1115 #[test]
1116 fn read_trust_missing_file_is_empty() {
1117 let _cfg = TempConfig::new();
1118 assert!(read_trust().unwrap().is_empty());
1119 }
1120
1121 #[test]
1122 fn read_trust_parses_decisions() {
1123 let _cfg = TempConfig::new();
1124 let path = trust_path().unwrap();
1125 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1126 std::fs::write(
1127 &path,
1128 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1129 )
1130 .unwrap();
1131 let store = read_trust().unwrap();
1132 assert_eq!(store.len(), 3);
1133 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1134 assert_eq!(store.get("/home/me/untrusted").copied().flatten(), Some(false));
1135 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1136 }
1137
1138 #[test]
1139 fn resolve_config_value_literal_passthrough() {
1140 assert_eq!(resolve_config_value("sk-literal-key", None), Some("sk-literal-key".into()));
1141 }
1142
1143 #[test]
1144 fn resolve_config_value_env_var() {
1145 let _guard = env_lock().lock().unwrap();
1146 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1147 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1148 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1149 assert_eq!(
1150 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1151 Some("secret-from-env".into())
1152 );
1153 assert_eq!(
1154 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1155 Some("prefix-secret-from-env-suffix".into())
1156 );
1157 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1159 assert_eq!(
1160 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1161 Some("a-secret-from-env-b-two-c".into())
1162 );
1163 let mut overlay = BTreeMap::new();
1165 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1166 assert_eq!(
1167 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1168 Some("overlay-value".into())
1169 );
1170 restore_env("RPI_TEST_CFG_KEY", prev);
1171 restore_env("RPI_TEST_CFG_KEY2", prev2);
1172 }
1173
1174 #[test]
1175 fn resolve_config_value_unset_env_is_none() {
1176 let _guard = env_lock().lock().unwrap();
1177 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1178 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1179 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1181 assert_eq!(
1182 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1183 None
1184 );
1185 restore_env("RPI_TEST_CFG_ABSENT", prev);
1186 }
1187
1188 #[test]
1189 fn resolve_config_value_dollar_dollar_escapes_literal() {
1190 assert_eq!(resolve_config_value("price-$$5", None), Some("price-$5".into()));
1191 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1192 }
1193
1194 #[test]
1195 fn resolve_config_value_command_runs_shell() {
1196 assert_eq!(
1198 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1199 Some("rpi-cfg-resolved".into())
1200 );
1201 assert_eq!(
1203 resolve_config_value_uncached("!false", None),
1204 None
1205 );
1206 }
1207
1208 #[test]
1209 fn resolve_headers_drops_unresolvable() {
1210 let _guard = env_lock().lock().unwrap();
1211 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1212 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1213 let mut h = BTreeMap::new();
1214 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1215 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1216 h.insert("x-literal".into(), "literal-value".into());
1217 let resolved = resolve_headers(&h, None);
1218 assert_eq!(resolved.len(), 2);
1219 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1220 assert_eq!(resolved.get("x-literal").map(|s| s.as_str()), Some("literal-value"));
1221 assert!(!resolved.contains_key("x-unset"));
1222 restore_env("RPI_TEST_HDR_SET", prev);
1223 }
1224
1225 #[test]
1226 fn agent_dir_respects_env_override() {
1227 let _guard = env_lock().lock().unwrap();
1228 let prev = std::env::var_os(CONFIG_DIR_ENV);
1229 let tmp = tempfile::TempDir::new().unwrap();
1230 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1231 let dir = agent_dir().unwrap();
1232 restore_env(CONFIG_DIR_ENV, prev);
1233 assert_eq!(dir, tmp.path());
1234 }
1235
1236 #[test]
1237 fn relative_override_is_rejected() {
1238 let _guard = env_lock().lock().unwrap();
1239 let prev = std::env::var_os(CONFIG_DIR_ENV);
1240 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1241 let err = agent_dir().unwrap_err();
1242 restore_env(CONFIG_DIR_ENV, prev);
1243 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1244 }
1245
1246 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1248 match prev {
1249 Some(v) => std::env::set_var(name, v),
1250 None => std::env::remove_var(name),
1251 }
1252 }
1253}