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
786pub fn default_anthropic_base_url() -> String {
790 ANTHROPIC_DEFAULT_BASE_URL.to_string()
791}
792
793#[cfg(unix)]
798use std::os::unix::fs::PermissionsExt;
799
800fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
803 if dir.exists() {
804 return Ok(());
805 }
806 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
807 path: dir.to_path_buf(),
808 source: e,
809 })?;
810 #[cfg(unix)]
811 {
812 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
813 }
814 Ok(())
815}
816
817fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
820 let dir = path
821 .parent()
822 .ok_or_else(|| ConfigError::Write {
823 path: path.to_path_buf(),
824 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
825 })?;
826 let tmp = dir.join(format!(
827 ".{}.tmp",
828 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi")
829 ));
830 std::fs::write(&tmp, bytes).map_err(|e| ConfigError::Write { path: tmp.clone(), source: e })?;
831 std::fs::rename(&tmp, path).map_err(|e| ConfigError::Write {
832 path: path.to_path_buf(),
833 source: e,
834 })?;
835 Ok(())
836}
837
838fn set_owner_only(_path: &Path) {
841 #[cfg(unix)]
842 {
843 let _ = std::fs::set_permissions(
844 _path,
845 std::fs::Permissions::from_mode(0o600),
846 );
847 }
848}
849
850trait Pipe: Sized {
853 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
854 f(self)
855 }
856}
857impl<T> Pipe for T {}
858
859#[cfg(test)]
864pub(crate) mod test_support {
865 use std::sync::{Mutex, OnceLock};
871 pub(crate) fn env_lock() -> &'static Mutex<()> {
872 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
873 LOCK.get_or_init(|| Mutex::new(()))
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use crate::config::test_support::env_lock;
881
882 struct TempConfig {
886 _guard: std::sync::MutexGuard<'static, ()>,
887 _tmp: tempfile::TempDir,
888 prev: Option<std::ffi::OsString>,
889 }
890 impl TempConfig {
891 fn new() -> Self {
892 let guard = env_lock().lock().unwrap();
893 let prev = std::env::var_os(CONFIG_DIR_ENV);
894 let tmp = tempfile::TempDir::new().unwrap();
895 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
896 Self { _guard: guard, _tmp: tmp, prev }
897 }
898 }
899 impl Drop for TempConfig {
900 fn drop(&mut self) {
901 restore_env(CONFIG_DIR_ENV, self.prev.take());
902 }
903 }
904
905 #[test]
906 fn read_auth_missing_file_is_empty() {
907 let _cfg = TempConfig::new();
908 let store = read_auth().unwrap();
909 assert!(store.is_empty());
910 }
911
912 #[test]
913 fn upsert_then_read_roundtrip() {
914 let _cfg = TempConfig::new();
915 upsert_credential(
916 "anthropic",
917 Credential::ApiKey { key: Some("sk-test-123".into()), env: None },
918 )
919 .unwrap();
920 let store = read_auth().unwrap();
921 match store.get("anthropic") {
922 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
923 other => panic!("unexpected cred: {other:?}"),
924 }
925 let path = auth_path().unwrap();
927 assert!(path.exists(), "auth.json should exist after upsert");
928 let raw = std::fs::read_to_string(&path).unwrap();
929 assert!(raw.contains("\"anthropic\""));
930 assert!(raw.contains("api_key"));
931 }
932
933 #[test]
934 fn delete_credential_removes_entry() {
935 let _cfg = TempConfig::new();
936 upsert_credential("anthropic", Credential::ApiKey { key: Some("k".into()), env: None })
937 .unwrap();
938 assert!(delete_credential("anthropic").unwrap());
939 assert!(!delete_credential("anthropic").unwrap());
941 assert!(read_auth().unwrap().is_empty());
942 }
943
944 #[test]
945 fn load_models_config_missing_is_empty() {
946 let _cfg = TempConfig::new();
947 let c = load_models_config().unwrap();
948 assert!(c.providers.is_empty());
949 }
950
951 #[test]
952 fn load_models_config_parses_with_comments() {
953 let _cfg = TempConfig::new();
954 let json = r#"{
955 // a one-api style gateway
956 "providers": {
957 "gateway": {
958 "baseUrl": "https://gw.example.com",
959 "authHeader": true,
960 "apiKey": "gw-secret",
961 "models": [
962 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
963 ]
964 }
965 }
966}"#;
967 std::fs::write(models_path().unwrap(), json).unwrap();
968 let c = load_models_config().unwrap();
969 let gw = c.providers.get("gateway").expect("gateway provider present");
970 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
971 assert!(gw.auth_header.unwrap_or(false));
972 assert_eq!(gw.models.len(), 1);
973 assert_eq!(gw.models[0].id, "claude-sonnet-5");
974 }
975
976 #[test]
977 fn provider_to_models_merges_headers_without_synth_bearer() {
978 let cfg = ProviderConfig {
984 name: None,
985 base_url: Some("https://gw.example.com".into()),
986 api_key: Some("gw-secret".into()),
987 api: None,
988 headers: Some({
989 let mut h = BTreeMap::new();
990 h.insert("x-portkey-key".into(), "portkey-secret".into());
991 h
992 }),
993 auth_header: Some(true),
994 models: vec![ModelDefinition {
995 id: "claude-sonnet-5".into(),
996 name: None,
997 base_url: None,
998 reasoning: None,
999 context_window: None,
1000 max_tokens: None,
1001 input: None,
1002 headers: None,
1003 }],
1004 };
1005 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1006 assert_eq!(models.len(), 1);
1007 let m = &models[0];
1008 assert_eq!(m.id, "claude-sonnet-5");
1009 assert_eq!(m.base_url, "https://gw.example.com");
1010 assert_eq!(m.provider, DEFAULT_PROVIDER_ID);
1014 let headers = m.headers.as_ref().expect("provider headers merged");
1015 assert_eq!(
1017 headers.get("x-portkey-key").map(|s| s.as_str()),
1018 Some("portkey-secret")
1019 );
1020 assert!(
1025 headers.get("authorization").is_none(),
1026 "provider_to_models must not synthesize the Bearer; resolve does"
1027 );
1028 }
1029
1030 #[test]
1031 fn provider_to_models_ignores_non_anthropic_api() {
1032 let cfg = ProviderConfig {
1033 name: None,
1034 base_url: None,
1035 api_key: None,
1036 api: Some("openai-completions".into()),
1037 headers: None,
1038 auth_header: None,
1039 models: vec![],
1040 };
1041 assert!(provider_to_models("oai", &cfg).is_none());
1042 }
1043
1044 #[test]
1045 fn malformed_auth_json_is_an_error_not_silent_empty() {
1046 let _cfg = TempConfig::new();
1047 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1048 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1049 }
1050
1051 #[test]
1052 fn agent_dir_nests_under_agent_by_default() {
1053 let _guard = env_lock().lock().unwrap();
1057 let prev = std::env::var_os(CONFIG_DIR_ENV);
1058 std::env::remove_var(CONFIG_DIR_ENV);
1059 let dir = agent_dir().unwrap();
1060 restore_env(CONFIG_DIR_ENV, prev);
1061 assert!(dir.ends_with("agent"));
1062 assert!(dir
1063 .parent()
1064 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1065 .unwrap_or(false));
1066 }
1067
1068 #[test]
1069 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1070 let tmp = tempfile::TempDir::new().unwrap();
1074 let root = tmp.path().to_path_buf();
1075 let agent = root.join("agent");
1076 std::fs::write(root.join("auth.json"), "{}").unwrap();
1077 std::fs::write(root.join("models.json"), "{}").unwrap();
1078 std::fs::write(root.join(".setup_done"), "1").unwrap();
1079 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1080 assert_eq!(moved, 3);
1081 assert!(agent.join("auth.json").exists());
1082 assert!(agent.join("models.json").exists());
1083 assert!(agent.join(".setup_done").exists());
1084 assert!(!root.join("auth.json").exists());
1085 }
1086
1087 #[test]
1088 fn migrate_legacy_layout_noop_when_agent_exists() {
1089 let tmp = tempfile::TempDir::new().unwrap();
1090 let root = tmp.path().to_path_buf();
1091 let agent = root.join("agent");
1092 std::fs::write(root.join("auth.json"), "{}").unwrap();
1093 std::fs::create_dir_all(&agent).unwrap();
1094 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1095 assert_eq!(moved, 0); }
1097
1098 #[test]
1099 fn migrate_legacy_layout_noop_when_no_flat_files() {
1100 let tmp = tempfile::TempDir::new().unwrap();
1101 let root = tmp.path().to_path_buf();
1102 let agent = root.join("agent");
1103 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1104 assert_eq!(moved, 0);
1105 }
1106
1107 #[test]
1108 fn migrate_legacy_layout_public_skips_env_override() {
1109 let _cfg = TempConfig::new();
1112 let moved = migrate_legacy_layout().unwrap();
1113 assert_eq!(moved, 0);
1114 }
1115
1116 #[test]
1117 fn read_trust_missing_file_is_empty() {
1118 let _cfg = TempConfig::new();
1119 assert!(read_trust().unwrap().is_empty());
1120 }
1121
1122 #[test]
1123 fn read_trust_parses_decisions() {
1124 let _cfg = TempConfig::new();
1125 let path = trust_path().unwrap();
1126 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1127 std::fs::write(
1128 &path,
1129 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1130 )
1131 .unwrap();
1132 let store = read_trust().unwrap();
1133 assert_eq!(store.len(), 3);
1134 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1135 assert_eq!(store.get("/home/me/untrusted").copied().flatten(), Some(false));
1136 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1137 }
1138
1139 #[test]
1140 fn resolve_config_value_literal_passthrough() {
1141 assert_eq!(resolve_config_value("sk-literal-key", None), Some("sk-literal-key".into()));
1142 }
1143
1144 #[test]
1145 fn resolve_config_value_env_var() {
1146 let _guard = env_lock().lock().unwrap();
1147 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1148 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1149 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1150 assert_eq!(
1151 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1152 Some("secret-from-env".into())
1153 );
1154 assert_eq!(
1155 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1156 Some("prefix-secret-from-env-suffix".into())
1157 );
1158 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1160 assert_eq!(
1161 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1162 Some("a-secret-from-env-b-two-c".into())
1163 );
1164 let mut overlay = BTreeMap::new();
1166 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1167 assert_eq!(
1168 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1169 Some("overlay-value".into())
1170 );
1171 restore_env("RPI_TEST_CFG_KEY", prev);
1172 restore_env("RPI_TEST_CFG_KEY2", prev2);
1173 }
1174
1175 #[test]
1176 fn resolve_config_value_unset_env_is_none() {
1177 let _guard = env_lock().lock().unwrap();
1178 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1179 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1180 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1182 assert_eq!(
1183 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1184 None
1185 );
1186 restore_env("RPI_TEST_CFG_ABSENT", prev);
1187 }
1188
1189 #[test]
1190 fn resolve_config_value_dollar_dollar_escapes_literal() {
1191 assert_eq!(resolve_config_value("price-$$5", None), Some("price-$5".into()));
1192 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1193 }
1194
1195 #[test]
1196 fn resolve_config_value_command_runs_shell() {
1197 assert_eq!(
1199 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1200 Some("rpi-cfg-resolved".into())
1201 );
1202 assert_eq!(
1204 resolve_config_value_uncached("!false", None),
1205 None
1206 );
1207 }
1208
1209 #[test]
1210 fn resolve_headers_drops_unresolvable() {
1211 let _guard = env_lock().lock().unwrap();
1212 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1213 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1214 let mut h = BTreeMap::new();
1215 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1216 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1217 h.insert("x-literal".into(), "literal-value".into());
1218 let resolved = resolve_headers(&h, None);
1219 assert_eq!(resolved.len(), 2);
1220 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1221 assert_eq!(resolved.get("x-literal").map(|s| s.as_str()), Some("literal-value"));
1222 assert!(!resolved.contains_key("x-unset"));
1223 restore_env("RPI_TEST_HDR_SET", prev);
1224 }
1225
1226 #[test]
1227 fn agent_dir_respects_env_override() {
1228 let _guard = env_lock().lock().unwrap();
1229 let prev = std::env::var_os(CONFIG_DIR_ENV);
1230 let tmp = tempfile::TempDir::new().unwrap();
1231 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1232 let dir = agent_dir().unwrap();
1233 restore_env(CONFIG_DIR_ENV, prev);
1234 assert_eq!(dir, tmp.path());
1235 }
1236
1237 #[test]
1238 fn relative_override_is_rejected() {
1239 let _guard = env_lock().lock().unwrap();
1240 let prev = std::env::var_os(CONFIG_DIR_ENV);
1241 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1242 let err = agent_dir().unwrap_err();
1243 restore_env(CONFIG_DIR_ENV, prev);
1244 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1245 }
1246
1247 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1249 match prev {
1250 Some(v) => std::env::set_var(name, v),
1251 None => std::env::remove_var(name),
1252 }
1253 }
1254}