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: BTreeMap<String, ProviderConfig>,
294}
295
296#[derive(serde::Deserialize, Debug, Clone)]
300#[serde(rename_all = "camelCase")]
301pub struct ProviderConfig {
302 #[serde(default)]
303 pub name: Option<String>,
304 #[serde(default)]
305 pub base_url: Option<String>,
306 #[serde(default)]
307 pub api_key: Option<String>,
308 #[serde(default)]
309 pub api: Option<String>,
310 #[serde(default)]
311 pub headers: Option<BTreeMap<String, String>>,
312 #[serde(default)]
315 pub auth_header: Option<bool>,
316 #[serde(default)]
317 pub models: Vec<ModelDefinition>,
318}
319
320#[derive(serde::Deserialize, Debug, Clone)]
322#[serde(rename_all = "camelCase")]
323pub struct ModelDefinition {
324 pub id: String,
325 #[serde(default)]
326 pub name: Option<String>,
327 #[serde(default)]
328 pub base_url: Option<String>,
329 #[serde(default)]
330 pub reasoning: Option<bool>,
331 #[serde(default)]
332 pub context_window: Option<u64>,
333 #[serde(default)]
334 pub max_tokens: Option<u64>,
335 #[serde(default)]
338 pub input: Option<Vec<String>>,
339 #[serde(default)]
340 pub headers: Option<BTreeMap<String, String>>,
341 #[serde(default)]
342 pub compat: Option<serde_json::Value>,
343}
344
345pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
347 let path = models_path()?;
348 match std::fs::read_to_string(&path) {
349 Ok(text) => parse_models_json(&text).map_err(|e| ConfigError::Json {
350 path: path.clone(),
351 source: e,
352 }),
353 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsConfig::default()),
354 Err(e) => Err(ConfigError::Read { path, source: e }),
355 }
356}
357
358pub type TrustStore = BTreeMap<String, Option<bool>>;
368
369pub fn read_trust() -> Result<TrustStore, ConfigError> {
373 let path = trust_path()?;
374 match std::fs::read_to_string(&path) {
375 Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
376 path: path.clone(),
377 source: e,
378 }),
379 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
380 Err(e) => Err(ConfigError::Read { path, source: e }),
381 }
382}
383
384pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
387 let key = std::fs::canonicalize(cwd)
388 .unwrap_or_else(|_| cwd.to_path_buf())
389 .to_string_lossy()
390 .into_owned();
391 let mut store = read_trust()?;
392 if let Some(decision) = trusted {
393 store.insert(key, Some(decision));
394 } else {
395 store.remove(&key);
396 }
397 let path = trust_path()?;
398 ensure_dir(&agent_dir()?)?;
399 let json = serde_json::to_string_pretty(&store).unwrap();
400 atomic_write(&path, json.as_bytes())?;
401 set_owner_only(&path);
402 Ok(())
403}
404
405fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
409 match serde_json::from_str(text) {
410 Ok(c) => Ok(c),
411 Err(first) => {
412 let stripped = strip_line_comments(text);
417 serde_json::from_str(&stripped).map_err(|_| first)
418 }
419 }
420}
421
422pub(crate) fn strip_line_comments(text: &str) -> String {
427 text.lines()
428 .map(|line| match find_line_comment(line) {
429 Some(idx) => line[..idx].to_string(),
430 None => line.to_string(),
431 })
432 .collect::<Vec<_>>()
433 .join("\n")
434}
435
436fn find_line_comment(line: &str) -> Option<usize> {
438 let mut in_str = false;
439 let mut esc = false;
440 for (i, ch) in line.char_indices() {
441 if esc {
442 esc = false;
443 continue;
444 }
445 match ch {
446 '\\' if in_str => esc = true,
447 '"' => in_str = !in_str,
448 '/' if !in_str => {
449 if line.as_bytes().get(i + 1) == Some(&b'/') {
450 return Some(i);
451 }
452 }
453 _ => {}
454 }
455 }
456 None
457}
458
459fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
466 static CACHE: std::sync::OnceLock<
467 std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
468 > = std::sync::OnceLock::new();
469 CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
470}
471
472pub fn resolve_config_value(
487 config: &str,
488 env_overlay: Option<&BTreeMap<String, String>>,
489) -> Option<String> {
490 if let Some(cmd) = config.strip_prefix('!') {
491 return resolve_command(cmd);
492 }
493 resolve_template(config, env_overlay)
494}
495
496pub fn resolve_config_value_uncached(
500 config: &str,
501 env_overlay: Option<&BTreeMap<String, String>>,
502) -> Option<String> {
503 if let Some(cmd) = config.strip_prefix('!') {
504 return resolve_command_uncached(cmd);
505 }
506 resolve_template(config, env_overlay)
507}
508
509pub fn resolve_headers(
513 headers: &BTreeMap<String, String>,
514 env_overlay: Option<&BTreeMap<String, String>>,
515) -> BTreeMap<String, String> {
516 let mut out = BTreeMap::new();
517 for (k, v) in headers {
518 if let Some(resolved) = resolve_config_value_uncached(v, env_overlay) {
519 out.insert(k.clone(), resolved);
520 }
521 }
522 out
523}
524
525fn env_lookup(name: &str, env_overlay: Option<&BTreeMap<String, String>>) -> Option<String> {
528 if let Some(overlay) = env_overlay {
529 if let Some(v) = overlay.get(name) {
530 return Some(v.clone());
531 }
532 }
533 std::env::var(name).ok()
534}
535
536enum TemplatePart {
538 Literal(String),
539 Env(String),
540}
541
542fn parse_template(config: &str) -> Vec<TemplatePart> {
547 let mut parts: Vec<TemplatePart> = Vec::new();
548 let bytes = config.as_bytes();
549 let mut i = 0usize;
550 while i < bytes.len() {
551 match config[i..].find('$') {
553 None => {
554 push_literal(&mut parts, &config[i..]);
555 break;
556 }
557 Some(offset) => {
558 let dollar = i + offset;
559 push_literal(&mut parts, &config[i..dollar]);
560 let after = dollar + 1;
561 let next = bytes.get(after).copied();
562 if next == Some(b'$') || next == Some(b'!') {
563 push_literal(&mut parts, &config[after..after + 1]);
564 i = after + 1;
565 continue;
566 }
567 if next == Some(b'{') {
568 if let Some(end_rel) = config[after + 1..].find('}') {
570 let end = after + 1 + end_rel;
571 let name = &config[after + 1..end];
572 if is_env_name(name) {
573 parts.push(TemplatePart::Env(name.to_string()));
574 } else {
575 push_literal(&mut parts, &config[dollar..=end]);
577 }
578 i = end + 1;
579 continue;
580 }
581 push_literal(&mut parts, "$");
583 i = after;
584 continue;
585 }
586 if let Some(name) = env_name_prefix(&config[after..]) {
588 parts.push(TemplatePart::Env(name.to_string()));
589 i = after + name.len();
590 } else {
591 push_literal(&mut parts, "$");
592 i = after;
593 }
594 }
595 }
596 }
597 parts
598}
599
600fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
601 if value.is_empty() {
602 return;
603 }
604 if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
605 s.push_str(value);
606 } else {
607 parts.push(TemplatePart::Literal(value.to_string()));
608 }
609}
610
611fn is_env_name(s: &str) -> bool {
612 let mut chars = s.chars();
613 match chars.next() {
614 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
615 _ => return false,
616 }
617 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
618}
619
620fn env_name_prefix(s: &str) -> Option<&str> {
623 let mut chars = s.char_indices();
624 match chars.next() {
625 Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
626 _ => return None,
627 }
628 let end = chars
629 .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
630 .map(|(idx, _)| idx)
631 .unwrap_or(s.len());
632 Some(&s[..end])
633}
634
635fn resolve_template(
638 config: &str,
639 env_overlay: Option<&BTreeMap<String, String>>,
640) -> Option<String> {
641 let parts = parse_template(config);
642 let mut out = String::with_capacity(config.len());
643 for part in parts {
644 match part {
645 TemplatePart::Literal(s) => out.push_str(&s),
646 TemplatePart::Env(name) => match env_lookup(&name, env_overlay) {
647 Some(v) => out.push_str(&v),
648 None => return None,
649 },
650 }
651 }
652 Some(out)
653}
654
655fn resolve_command(cmd: &str) -> Option<String> {
659 let key = format!("!{cmd}");
660 if let Some(v) = command_cache().lock().ok()?.get(&key) {
661 return v.clone();
662 }
663 let result = resolve_command_uncached(cmd);
664 if let Ok(mut cache) = command_cache().lock() {
665 cache.insert(key, result.clone());
666 }
667 result
668}
669
670#[cfg(unix)]
671fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
672 std::process::Command::new("sh")
673 .arg("-c")
674 .arg(cmd)
675 .stdin(std::process::Stdio::null())
676 .stdout(std::process::Stdio::piped())
677 .stderr(std::process::Stdio::null())
678 .output()
679 .ok()
680}
681
682#[cfg(windows)]
683fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
684 use std::os::windows::process::CommandExt;
685 std::process::Command::new("cmd")
686 .arg("/C")
687 .arg(cmd)
688 .stdin(std::process::Stdio::null())
689 .stdout(std::process::Stdio::piped())
690 .stderr(std::process::Stdio::null())
691 .creation_flags(0x0800_0000) .output()
693 .ok()
694}
695
696fn resolve_command_uncached(cmd: &str) -> Option<String> {
698 let output = spawn_shell_command(cmd)?;
699 if !output.status.success() {
700 return None;
701 }
702 let stdout = String::from_utf8_lossy(&output.stdout);
703 let trimmed = stdout.trim();
704 if trimmed.is_empty() {
705 None
706 } else {
707 Some(trimmed.to_string())
708 }
709}
710
711pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
716 match cfg.api.as_deref() {
717 None | Some("") | Some("anthropic-messages") => true,
718 _ => false,
719 }
720}
721
722pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
724 matches!(cfg.api.as_deref(), Some("openai-completions"))
725}
726
727pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
731 let api = if provider_is_anthropic_compatible(cfg) {
732 Api::AnthropicMessages
733 } else if provider_is_openai_completions(cfg) {
734 Api::OpenaiCompletions
735 } else {
736 return None;
737 };
738 let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
739 Api::OpenaiCompletions => "https://api.openai.com".to_string(),
740 _ => default_anthropic_base_url(),
741 });
742 let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
743 for def in &cfg.models {
744 let base_url = def
745 .base_url
746 .clone()
747 .unwrap_or_else(|| provider_base.clone());
748 let name = def.name.clone().unwrap_or_else(|| def.id.clone());
749 let runtime_provider = match api {
762 Api::OpenaiCompletions => provider_id,
763 _ => DEFAULT_PROVIDER_ID,
764 };
765 let mut m = Model::new(
766 def.id.clone(),
767 name,
768 api.clone(),
769 runtime_provider.to_string(),
770 base_url,
771 );
772 m.reasoning = def.reasoning.unwrap_or(false);
773 m.context_window = def.context_window.unwrap_or(0);
774 m.max_tokens = def.max_tokens.unwrap_or(0);
775 m.input = parse_input_modalities(def.input.as_deref());
776 let mut headers: BTreeMap<String, String> = BTreeMap::new();
790 if let Some(h) = def.headers.clone() {
791 for (k, v) in resolve_headers(&h, None) {
792 headers.insert(k, v);
793 }
794 }
795 if let Some(h) = cfg.headers.clone() {
796 for (k, v) in resolve_headers(&h, None) {
797 headers.insert(k, v);
798 }
799 }
800 if matches!(api, Api::OpenaiCompletions) {
801 if let Some(key) = cfg
802 .api_key
803 .as_deref()
804 .filter(|key| !key.is_empty())
805 .and_then(|key| resolve_config_value(key, None))
806 {
807 headers.retain(|name, _| !name.eq_ignore_ascii_case("authorization"));
808 headers.insert("authorization".to_string(), format!("Bearer {key}"));
809 }
810 if let Some(value) = def.compat.clone() {
811 if let Ok(compat) = serde_json::from_value(value) {
812 m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
813 }
814 }
815 }
816 if !headers.is_empty() {
817 m.headers = Some(headers);
818 }
819 merged.push(m);
820 }
821 Some(merged)
822}
823
824fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
827 match input {
828 None => vec![InputModality::Text],
829 Some(list) if list.is_empty() => vec![InputModality::Text],
830 Some(list) => list
831 .iter()
832 .filter_map(|s| match s.to_ascii_lowercase().as_str() {
833 "text" => Some(InputModality::Text),
834 "image" => Some(InputModality::Image),
835 _ => None,
836 })
837 .collect::<Vec<_>>()
838 .pipe(|v| {
839 if v.is_empty() {
840 vec![InputModality::Text]
841 } else {
842 v
843 }
844 }),
845 }
846}
847
848pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
854
855pub fn default_anthropic_base_url() -> String {
859 ANTHROPIC_DEFAULT_BASE_URL.to_string()
860}
861
862#[cfg(unix)]
867use std::os::unix::fs::PermissionsExt;
868
869fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
872 if dir.exists() {
873 return Ok(());
874 }
875 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
876 path: dir.to_path_buf(),
877 source: e,
878 })?;
879 #[cfg(unix)]
880 {
881 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
882 }
883 Ok(())
884}
885
886fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
889 let dir = path.parent().ok_or_else(|| ConfigError::Write {
890 path: path.to_path_buf(),
891 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
892 })?;
893 let tmp = dir.join(format!(
894 ".{}.tmp",
895 path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi")
896 ));
897 std::fs::write(&tmp, bytes).map_err(|e| ConfigError::Write {
898 path: tmp.clone(),
899 source: e,
900 })?;
901 std::fs::rename(&tmp, path).map_err(|e| ConfigError::Write {
902 path: path.to_path_buf(),
903 source: e,
904 })?;
905 Ok(())
906}
907
908fn set_owner_only(_path: &Path) {
911 #[cfg(unix)]
912 {
913 let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
914 }
915}
916
917trait Pipe: Sized {
920 fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
921 f(self)
922 }
923}
924impl<T> Pipe for T {}
925
926#[cfg(test)]
931pub(crate) mod test_support {
932 use std::sync::{Mutex, OnceLock};
938 pub(crate) fn env_lock() -> &'static Mutex<()> {
939 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
940 LOCK.get_or_init(|| Mutex::new(()))
941 }
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947 use crate::config::test_support::env_lock;
948
949 struct TempConfig {
953 _guard: std::sync::MutexGuard<'static, ()>,
954 _tmp: tempfile::TempDir,
955 prev: Option<std::ffi::OsString>,
956 }
957 impl TempConfig {
958 fn new() -> Self {
959 let guard = env_lock().lock().unwrap();
960 let prev = std::env::var_os(CONFIG_DIR_ENV);
961 let tmp = tempfile::TempDir::new().unwrap();
962 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
963 Self {
964 _guard: guard,
965 _tmp: tmp,
966 prev,
967 }
968 }
969 }
970 impl Drop for TempConfig {
971 fn drop(&mut self) {
972 restore_env(CONFIG_DIR_ENV, self.prev.take());
973 }
974 }
975
976 #[test]
977 fn read_auth_missing_file_is_empty() {
978 let _cfg = TempConfig::new();
979 let store = read_auth().unwrap();
980 assert!(store.is_empty());
981 }
982
983 #[test]
984 fn upsert_then_read_roundtrip() {
985 let _cfg = TempConfig::new();
986 upsert_credential(
987 "anthropic",
988 Credential::ApiKey {
989 key: Some("sk-test-123".into()),
990 env: None,
991 },
992 )
993 .unwrap();
994 let store = read_auth().unwrap();
995 match store.get("anthropic") {
996 Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
997 other => panic!("unexpected cred: {other:?}"),
998 }
999 let path = auth_path().unwrap();
1001 assert!(path.exists(), "auth.json should exist after upsert");
1002 let raw = std::fs::read_to_string(&path).unwrap();
1003 assert!(raw.contains("\"anthropic\""));
1004 assert!(raw.contains("api_key"));
1005 }
1006
1007 #[test]
1008 fn delete_credential_removes_entry() {
1009 let _cfg = TempConfig::new();
1010 upsert_credential(
1011 "anthropic",
1012 Credential::ApiKey {
1013 key: Some("k".into()),
1014 env: None,
1015 },
1016 )
1017 .unwrap();
1018 assert!(delete_credential("anthropic").unwrap());
1019 assert!(!delete_credential("anthropic").unwrap());
1021 assert!(read_auth().unwrap().is_empty());
1022 }
1023
1024 #[test]
1025 fn load_models_config_missing_is_empty() {
1026 let _cfg = TempConfig::new();
1027 let c = load_models_config().unwrap();
1028 assert!(c.providers.is_empty());
1029 }
1030
1031 #[test]
1032 fn load_models_config_parses_with_comments() {
1033 let _cfg = TempConfig::new();
1034 let json = r#"{
1035 // a one-api style gateway
1036 "providers": {
1037 "gateway": {
1038 "baseUrl": "https://gw.example.com",
1039 "authHeader": true,
1040 "apiKey": "gw-secret",
1041 "models": [
1042 { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
1043 ]
1044 }
1045 }
1046}"#;
1047 std::fs::write(models_path().unwrap(), json).unwrap();
1048 let c = load_models_config().unwrap();
1049 let gw = c
1050 .providers
1051 .get("gateway")
1052 .expect("gateway provider present");
1053 assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
1054 assert!(gw.auth_header.unwrap_or(false));
1055 assert_eq!(gw.models.len(), 1);
1056 assert_eq!(gw.models[0].id, "claude-sonnet-5");
1057 }
1058
1059 #[test]
1060 fn provider_to_models_merges_headers_without_synth_bearer() {
1061 let cfg = ProviderConfig {
1067 name: None,
1068 base_url: Some("https://gw.example.com".into()),
1069 api_key: Some("gw-secret".into()),
1070 api: None,
1071 headers: Some({
1072 let mut h = BTreeMap::new();
1073 h.insert("x-portkey-key".into(), "portkey-secret".into());
1074 h
1075 }),
1076 auth_header: Some(true),
1077 models: vec![ModelDefinition {
1078 id: "claude-sonnet-5".into(),
1079 name: None,
1080 base_url: None,
1081 reasoning: None,
1082 context_window: None,
1083 max_tokens: None,
1084 input: None,
1085 headers: None,
1086 compat: None,
1087 }],
1088 };
1089 let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1090 assert_eq!(models.len(), 1);
1091 let m = &models[0];
1092 assert_eq!(m.id, "claude-sonnet-5");
1093 assert_eq!(m.base_url, "https://gw.example.com");
1094 assert_eq!(m.provider, DEFAULT_PROVIDER_ID);
1098 let headers = m.headers.as_ref().expect("provider headers merged");
1099 assert_eq!(
1101 headers.get("x-portkey-key").map(|s| s.as_str()),
1102 Some("portkey-secret")
1103 );
1104 assert!(
1109 headers.get("authorization").is_none(),
1110 "provider_to_models must not synthesize the Bearer; resolve does"
1111 );
1112 }
1113
1114 #[test]
1115 fn provider_to_models_supports_openai_completions() {
1116 let config: ModelsConfig = serde_json::from_str(
1117 r#"{
1118 "providers": {
1119 "oai": {
1120 "api": "openai-completions",
1121 "baseUrl": "https://gateway.example.com/v1",
1122 "apiKey": "secret",
1123 "models": [{"id":"gpt-test","maxTokens":4096}]
1124 }
1125 }
1126 }"#,
1127 )
1128 .unwrap();
1129 let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
1130 assert_eq!(models.len(), 1);
1131 assert_eq!(models[0].api, Api::OpenaiCompletions);
1132 assert_eq!(models[0].provider, "oai");
1133 assert_eq!(models[0].max_tokens, 4096);
1134 assert_eq!(
1135 models[0]
1136 .headers
1137 .as_ref()
1138 .and_then(|headers| headers.get("authorization"))
1139 .map(String::as_str),
1140 Some("Bearer secret")
1141 );
1142 }
1143
1144 #[test]
1145 fn malformed_auth_json_is_an_error_not_silent_empty() {
1146 let _cfg = TempConfig::new();
1147 std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1148 assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1149 }
1150
1151 #[test]
1152 fn agent_dir_nests_under_agent_by_default() {
1153 let _guard = env_lock().lock().unwrap();
1157 let prev = std::env::var_os(CONFIG_DIR_ENV);
1158 std::env::remove_var(CONFIG_DIR_ENV);
1159 let dir = agent_dir().unwrap();
1160 restore_env(CONFIG_DIR_ENV, prev);
1161 assert!(dir.ends_with("agent"));
1162 assert!(dir
1163 .parent()
1164 .map(|p| p.ends_with(CONFIG_DIR_NAME))
1165 .unwrap_or(false));
1166 }
1167
1168 #[test]
1169 fn migrate_legacy_layout_moves_flat_files_into_agent() {
1170 let tmp = tempfile::TempDir::new().unwrap();
1174 let root = tmp.path().to_path_buf();
1175 let agent = root.join("agent");
1176 std::fs::write(root.join("auth.json"), "{}").unwrap();
1177 std::fs::write(root.join("models.json"), "{}").unwrap();
1178 std::fs::write(root.join(".setup_done"), "1").unwrap();
1179 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1180 assert_eq!(moved, 3);
1181 assert!(agent.join("auth.json").exists());
1182 assert!(agent.join("models.json").exists());
1183 assert!(agent.join(".setup_done").exists());
1184 assert!(!root.join("auth.json").exists());
1185 }
1186
1187 #[test]
1188 fn migrate_legacy_layout_noop_when_agent_exists() {
1189 let tmp = tempfile::TempDir::new().unwrap();
1190 let root = tmp.path().to_path_buf();
1191 let agent = root.join("agent");
1192 std::fs::write(root.join("auth.json"), "{}").unwrap();
1193 std::fs::create_dir_all(&agent).unwrap();
1194 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1195 assert_eq!(moved, 0); }
1197
1198 #[test]
1199 fn migrate_legacy_layout_noop_when_no_flat_files() {
1200 let tmp = tempfile::TempDir::new().unwrap();
1201 let root = tmp.path().to_path_buf();
1202 let agent = root.join("agent");
1203 let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1204 assert_eq!(moved, 0);
1205 }
1206
1207 #[test]
1208 fn migrate_legacy_layout_public_skips_env_override() {
1209 let _cfg = TempConfig::new();
1212 let moved = migrate_legacy_layout().unwrap();
1213 assert_eq!(moved, 0);
1214 }
1215
1216 #[test]
1217 fn read_trust_missing_file_is_empty() {
1218 let _cfg = TempConfig::new();
1219 assert!(read_trust().unwrap().is_empty());
1220 }
1221
1222 #[test]
1223 fn read_trust_parses_decisions() {
1224 let _cfg = TempConfig::new();
1225 let path = trust_path().unwrap();
1226 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1227 std::fs::write(
1228 &path,
1229 r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1230 )
1231 .unwrap();
1232 let store = read_trust().unwrap();
1233 assert_eq!(store.len(), 3);
1234 assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1235 assert_eq!(
1236 store.get("/home/me/untrusted").copied().flatten(),
1237 Some(false)
1238 );
1239 assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1240 }
1241
1242 #[test]
1243 fn resolve_config_value_literal_passthrough() {
1244 assert_eq!(
1245 resolve_config_value("sk-literal-key", None),
1246 Some("sk-literal-key".into())
1247 );
1248 }
1249
1250 #[test]
1251 fn resolve_config_value_env_var() {
1252 let _guard = env_lock().lock().unwrap();
1253 let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1254 let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1255 std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1256 assert_eq!(
1257 resolve_config_value("$RPI_TEST_CFG_KEY", None),
1258 Some("secret-from-env".into())
1259 );
1260 assert_eq!(
1261 resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1262 Some("prefix-secret-from-env-suffix".into())
1263 );
1264 std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1266 assert_eq!(
1267 resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1268 Some("a-secret-from-env-b-two-c".into())
1269 );
1270 let mut overlay = BTreeMap::new();
1272 overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1273 assert_eq!(
1274 resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1275 Some("overlay-value".into())
1276 );
1277 restore_env("RPI_TEST_CFG_KEY", prev);
1278 restore_env("RPI_TEST_CFG_KEY2", prev2);
1279 }
1280
1281 #[test]
1282 fn resolve_config_value_unset_env_is_none() {
1283 let _guard = env_lock().lock().unwrap();
1284 let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1285 std::env::remove_var("RPI_TEST_CFG_ABSENT");
1286 assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1288 assert_eq!(
1289 resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1290 None
1291 );
1292 restore_env("RPI_TEST_CFG_ABSENT", prev);
1293 }
1294
1295 #[test]
1296 fn resolve_config_value_dollar_dollar_escapes_literal() {
1297 assert_eq!(
1298 resolve_config_value("price-$$5", None),
1299 Some("price-$5".into())
1300 );
1301 assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1302 }
1303
1304 #[test]
1305 fn resolve_config_value_command_runs_shell() {
1306 assert_eq!(
1308 resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1309 Some("rpi-cfg-resolved".into())
1310 );
1311 assert_eq!(resolve_config_value_uncached("!false", None), None);
1313 }
1314
1315 #[test]
1316 fn resolve_headers_drops_unresolvable() {
1317 let _guard = env_lock().lock().unwrap();
1318 let prev = std::env::var_os("RPI_TEST_HDR_SET");
1319 std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1320 let mut h = BTreeMap::new();
1321 h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1322 h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1323 h.insert("x-literal".into(), "literal-value".into());
1324 let resolved = resolve_headers(&h, None);
1325 assert_eq!(resolved.len(), 2);
1326 assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1327 assert_eq!(
1328 resolved.get("x-literal").map(|s| s.as_str()),
1329 Some("literal-value")
1330 );
1331 assert!(!resolved.contains_key("x-unset"));
1332 restore_env("RPI_TEST_HDR_SET", prev);
1333 }
1334
1335 #[test]
1336 fn agent_dir_respects_env_override() {
1337 let _guard = env_lock().lock().unwrap();
1338 let prev = std::env::var_os(CONFIG_DIR_ENV);
1339 let tmp = tempfile::TempDir::new().unwrap();
1340 std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1341 let dir = agent_dir().unwrap();
1342 restore_env(CONFIG_DIR_ENV, prev);
1343 assert_eq!(dir, tmp.path());
1344 }
1345
1346 #[test]
1347 fn relative_override_is_rejected() {
1348 let _guard = env_lock().lock().unwrap();
1349 let prev = std::env::var_os(CONFIG_DIR_ENV);
1350 std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1351 let err = agent_dir().unwrap_err();
1352 restore_env(CONFIG_DIR_ENV, prev);
1353 assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1354 }
1355
1356 fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1358 match prev {
1359 Some(v) => std::env::set_var(name, v),
1360 None => std::env::remove_var(name),
1361 }
1362 }
1363}