use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use rpi_ai::{Api, InputModality, Model};
pub const CONFIG_DIR_NAME: &str = ".rpi";
pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
pub const DEFAULT_PROVIDER_ID: &str = "anthropic";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("could not resolve home directory (set {env} to override)")]
NoHomeDir { env: &'static str },
#[error("config dir override {env}={val:?} is not an absolute path")]
RelativeOverride { env: &'static str, val: String },
#[error("could not read {path}: {source}")]
Read { path: PathBuf, #[source] source: std::io::Error },
#[error("could not write {path}: {source}")]
Write { path: PathBuf, #[source] source: std::io::Error },
#[error("invalid JSON in {path}: {source}")]
Json { path: PathBuf, #[source] source: serde_json::Error },
}
pub fn agent_dir() -> Result<PathBuf, ConfigError> {
if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
let p = PathBuf::from(&val);
if !p.is_absolute() {
return Err(ConfigError::RelativeOverride {
env: CONFIG_DIR_ENV,
val: val.to_string_lossy().into_owned(),
});
}
return Ok(p);
}
let home = dirs::home_dir()
.ok_or(ConfigError::NoHomeDir { env: CONFIG_DIR_ENV })?;
Ok(home.join(CONFIG_DIR_NAME).join("agent"))
}
fn config_root_dir() -> Result<PathBuf, ConfigError> {
let agent = agent_dir()?;
agent
.parent()
.map(Path::to_path_buf)
.ok_or(ConfigError::NoHomeDir { env: CONFIG_DIR_ENV })
}
pub fn auth_path() -> Result<PathBuf, ConfigError> {
Ok(agent_dir()?.join("auth.json"))
}
pub fn models_path() -> Result<PathBuf, ConfigError> {
Ok(agent_dir()?.join("models.json"))
}
pub fn settings_path() -> Result<PathBuf, ConfigError> {
Ok(agent_dir()?.join("settings.json"))
}
pub fn trust_path() -> Result<PathBuf, ConfigError> {
Ok(agent_dir()?.join("trust.json"))
}
pub fn migrate_legacy_layout() -> Result<usize, ConfigError> {
if std::env::var_os(CONFIG_DIR_ENV).is_some() {
return Ok(0);
}
let root = match config_root_dir() {
Ok(p) => p,
Err(_) => return Ok(0),
};
let agent = agent_dir()?;
migrate_legacy_layout_in(&root, &agent)
}
fn migrate_legacy_layout_in(root: &Path, agent: &Path) -> Result<usize, ConfigError> {
if agent.exists() {
return Ok(0);
}
let flat_auth = root.join("auth.json");
let flat_models = root.join("models.json");
if !flat_auth.exists() && !flat_models.exists() {
return Ok(0);
}
std::fs::create_dir_all(agent).map_err(|e| ConfigError::Write {
path: agent.to_path_buf(),
source: e,
})?;
let mut moved = 0usize;
for leaf in ["auth.json", "models.json", ".setup_done", ".earendil_seen"] {
let from = root.join(leaf);
let to = agent.join(leaf);
if from.exists() && !to.exists() {
if let Err(_e) = std::fs::rename(&from, &to) {
if std::fs::copy(&from, &to).is_ok() {
let _ = std::fs::remove_file(&from);
}
}
moved += 1;
}
}
Ok(moved)
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum Credential {
ApiKey {
key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
env: Option<BTreeMap<String, String>>,
},
Oauth {
access: String,
refresh: String,
expires: i64,
},
}
pub type AuthStore = BTreeMap<String, Credential>;
pub fn read_auth() -> Result<AuthStore, ConfigError> {
let path = auth_path()?;
match std::fs::read_to_string(&path) {
Ok(text) => Ok(serde_json::from_str(&text).map_err(|e| ConfigError::Json {
path: path.clone(),
source: e,
})?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AuthStore::new()),
Err(e) => Err(ConfigError::Read { path, source: e }),
}
}
pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
let path = auth_path()?;
let dir = agent_dir()?;
ensure_dir(&dir)?;
let json = serde_json::to_string_pretty(store).unwrap();
atomic_write(&path, json.as_bytes())?;
set_owner_only(&path);
Ok(())
}
pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
let mut store = read_auth()?;
store.insert(provider_id.to_string(), cred);
write_auth(&store)
}
pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
let mut store = read_auth()?;
if store.remove(provider_id).is_some() {
write_auth(&store)?;
Ok(true)
} else {
Ok(false)
}
}
#[derive(serde::Deserialize, Default, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelsConfig {
#[serde(default)]
pub providers: BTreeMap<String, ProviderConfig>,
}
#[derive(serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfig {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub api: Option<String>,
#[serde(default)]
pub headers: Option<BTreeMap<String, String>>,
#[serde(default)]
pub auth_header: Option<bool>,
#[serde(default)]
pub models: Vec<ModelDefinition>,
}
#[derive(serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelDefinition {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub reasoning: Option<bool>,
#[serde(default)]
pub context_window: Option<u64>,
#[serde(default)]
pub max_tokens: Option<u64>,
#[serde(default)]
pub input: Option<Vec<String>>,
#[serde(default)]
pub headers: Option<BTreeMap<String, String>>,
}
pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
let path = models_path()?;
match std::fs::read_to_string(&path) {
Ok(text) => parse_models_json(&text).map_err(|e| ConfigError::Json {
path: path.clone(),
source: e,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsConfig::default()),
Err(e) => Err(ConfigError::Read { path, source: e }),
}
}
pub type TrustStore = BTreeMap<String, Option<bool>>;
pub fn read_trust() -> Result<TrustStore, ConfigError> {
let path = trust_path()?;
match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
path: path.clone(),
source: e,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
Err(e) => Err(ConfigError::Read { path, source: e }),
}
}
fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
match serde_json::from_str(text) {
Ok(c) => Ok(c),
Err(first) => {
let stripped = strip_line_comments(text);
serde_json::from_str(&stripped).map_err(|_| first)
}
}
}
pub(crate) fn strip_line_comments(text: &str) -> String {
text.lines()
.map(|line| match find_line_comment(line) {
Some(idx) => line[..idx].to_string(),
None => line.to_string(),
})
.collect::<Vec<_>>()
.join("\n")
}
fn find_line_comment(line: &str) -> Option<usize> {
let mut in_str = false;
let mut esc = false;
for (i, ch) in line.char_indices() {
if esc {
esc = false;
continue;
}
match ch {
'\\' if in_str => esc = true,
'"' => in_str = !in_str,
'/' if !in_str => {
if line.as_bytes().get(i + 1) == Some(&b'/') {
return Some(i);
}
}
_ => {}
}
}
None
}
fn command_cache(
) -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
static CACHE: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
> = std::sync::OnceLock::new();
CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
pub fn resolve_config_value(
config: &str,
env_overlay: Option<&BTreeMap<String, String>>,
) -> Option<String> {
if let Some(cmd) = config.strip_prefix('!') {
return resolve_command(cmd);
}
resolve_template(config, env_overlay)
}
pub fn resolve_config_value_uncached(
config: &str,
env_overlay: Option<&BTreeMap<String, String>>,
) -> Option<String> {
if let Some(cmd) = config.strip_prefix('!') {
return resolve_command_uncached(cmd);
}
resolve_template(config, env_overlay)
}
pub fn resolve_headers(
headers: &BTreeMap<String, String>,
env_overlay: Option<&BTreeMap<String, String>>,
) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for (k, v) in headers {
if let Some(resolved) = resolve_config_value_uncached(v, env_overlay) {
out.insert(k.clone(), resolved);
}
}
out
}
fn env_lookup(name: &str, env_overlay: Option<&BTreeMap<String, String>>) -> Option<String> {
if let Some(overlay) = env_overlay {
if let Some(v) = overlay.get(name) {
return Some(v.clone());
}
}
std::env::var(name).ok()
}
enum TemplatePart {
Literal(String),
Env(String),
}
fn parse_template(config: &str) -> Vec<TemplatePart> {
let mut parts: Vec<TemplatePart> = Vec::new();
let bytes = config.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match config[i..].find('$') {
None => {
push_literal(&mut parts, &config[i..]);
break;
}
Some(offset) => {
let dollar = i + offset;
push_literal(&mut parts, &config[i..dollar]);
let after = dollar + 1;
let next = bytes.get(after).copied();
if next == Some(b'$') || next == Some(b'!') {
push_literal(&mut parts, &config[after..after + 1]);
i = after + 1;
continue;
}
if next == Some(b'{') {
if let Some(end_rel) = config[after + 1..].find('}') {
let end = after + 1 + end_rel;
let name = &config[after + 1..end];
if is_env_name(name) {
parts.push(TemplatePart::Env(name.to_string()));
} else {
push_literal(&mut parts, &config[dollar..=end]);
}
i = end + 1;
continue;
}
push_literal(&mut parts, "$");
i = after;
continue;
}
if let Some(name) = env_name_prefix(&config[after..]) {
parts.push(TemplatePart::Env(name.to_string()));
i = after + name.len();
} else {
push_literal(&mut parts, "$");
i = after;
}
}
}
}
parts
}
fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
if value.is_empty() {
return;
}
if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
s.push_str(value);
} else {
parts.push(TemplatePart::Literal(value.to_string()));
}
}
fn is_env_name(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn env_name_prefix(s: &str) -> Option<&str> {
let mut chars = s.char_indices();
match chars.next() {
Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return None,
}
let end = chars
.find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
.map(|(idx, _)| idx)
.unwrap_or(s.len());
Some(&s[..end])
}
fn resolve_template(
config: &str,
env_overlay: Option<&BTreeMap<String, String>>,
) -> Option<String> {
let parts = parse_template(config);
let mut out = String::with_capacity(config.len());
for part in parts {
match part {
TemplatePart::Literal(s) => out.push_str(&s),
TemplatePart::Env(name) => match env_lookup(&name, env_overlay) {
Some(v) => out.push_str(&v),
None => return None,
},
}
}
Some(out)
}
fn resolve_command(cmd: &str) -> Option<String> {
let key = format!("!{cmd}");
if let Some(v) = command_cache().lock().ok()?.get(&key) {
return v.clone();
}
let result = resolve_command_uncached(cmd);
if let Ok(mut cache) = command_cache().lock() {
cache.insert(key, result.clone());
}
result
}
#[cfg(unix)]
fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
std::process::Command::new("sh")
.arg("-c")
.arg(cmd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()
}
#[cfg(windows)]
fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
use std::os::windows::process::CommandExt;
std::process::Command::new("cmd")
.arg("/C")
.arg(cmd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.creation_flags(0x0800_0000) .output()
.ok()
}
fn resolve_command_uncached(cmd: &str) -> Option<String> {
let output = spawn_shell_command(cmd)?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
match cfg.api.as_deref() {
None | Some("") | Some("anthropic-messages") => true,
_ => false,
}
}
pub fn provider_to_models(
provider_id: &str,
cfg: &ProviderConfig,
) -> Option<Vec<Model>> {
let _ = provider_id; if !provider_is_anthropic_compatible(cfg) {
return None;
}
let provider_base = cfg.base_url.clone().unwrap_or_else(default_anthropic_base_url);
let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
for def in &cfg.models {
let base_url = def
.base_url
.clone()
.unwrap_or_else(|| provider_base.clone());
let name = def.name.clone().unwrap_or_else(|| def.id.clone());
let mut m = Model::new(
def.id.clone(),
name,
Api::AnthropicMessages,
DEFAULT_PROVIDER_ID.to_string(),
base_url,
);
m.reasoning = def.reasoning.unwrap_or(false);
m.context_window = def.context_window.unwrap_or(0);
m.max_tokens = def.max_tokens.unwrap_or(0);
m.input = parse_input_modalities(def.input.as_deref());
let mut headers: BTreeMap<String, String> = BTreeMap::new();
if let Some(h) = def.headers.clone() {
for (k, v) in resolve_headers(&h, None) {
headers.insert(k, v);
}
}
if let Some(h) = cfg.headers.clone() {
for (k, v) in resolve_headers(&h, None) {
headers.insert(k, v);
}
}
if !headers.is_empty() {
m.headers = Some(headers);
}
merged.push(m);
}
Some(merged)
}
fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
match input {
None => vec![InputModality::Text],
Some(list) if list.is_empty() => vec![InputModality::Text],
Some(list) => list
.iter()
.filter_map(|s| match s.to_ascii_lowercase().as_str() {
"text" => Some(InputModality::Text),
"image" => Some(InputModality::Image),
_ => None,
})
.collect::<Vec<_>>()
.pipe(|v| if v.is_empty() { vec![InputModality::Text] } else { v }),
}
}
pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
pub fn default_anthropic_base_url() -> String {
ANTHROPIC_DEFAULT_BASE_URL.to_string()
}
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
if dir.exists() {
return Ok(());
}
std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
path: dir.to_path_buf(),
source: e,
})?;
#[cfg(unix)]
{
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
Ok(())
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
let dir = path
.parent()
.ok_or_else(|| ConfigError::Write {
path: path.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
})?;
let tmp = dir.join(format!(
".{}.tmp",
path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi")
));
std::fs::write(&tmp, bytes).map_err(|e| ConfigError::Write { path: tmp.clone(), source: e })?;
std::fs::rename(&tmp, path).map_err(|e| ConfigError::Write {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
fn set_owner_only(_path: &Path) {
#[cfg(unix)]
{
let _ = std::fs::set_permissions(
_path,
std::fs::Permissions::from_mode(0o600),
);
}
}
trait Pipe: Sized {
fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
f(self)
}
}
impl<T> Pipe for T {}
#[cfg(test)]
pub(crate) mod test_support {
use std::sync::{Mutex, OnceLock};
pub(crate) fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_support::env_lock;
struct TempConfig {
_guard: std::sync::MutexGuard<'static, ()>,
_tmp: tempfile::TempDir,
prev: Option<std::ffi::OsString>,
}
impl TempConfig {
fn new() -> Self {
let guard = env_lock().lock().unwrap();
let prev = std::env::var_os(CONFIG_DIR_ENV);
let tmp = tempfile::TempDir::new().unwrap();
std::env::set_var(CONFIG_DIR_ENV, tmp.path());
Self { _guard: guard, _tmp: tmp, prev }
}
}
impl Drop for TempConfig {
fn drop(&mut self) {
restore_env(CONFIG_DIR_ENV, self.prev.take());
}
}
#[test]
fn read_auth_missing_file_is_empty() {
let _cfg = TempConfig::new();
let store = read_auth().unwrap();
assert!(store.is_empty());
}
#[test]
fn upsert_then_read_roundtrip() {
let _cfg = TempConfig::new();
upsert_credential(
"anthropic",
Credential::ApiKey { key: Some("sk-test-123".into()), env: None },
)
.unwrap();
let store = read_auth().unwrap();
match store.get("anthropic") {
Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
other => panic!("unexpected cred: {other:?}"),
}
let path = auth_path().unwrap();
assert!(path.exists(), "auth.json should exist after upsert");
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("\"anthropic\""));
assert!(raw.contains("api_key"));
}
#[test]
fn delete_credential_removes_entry() {
let _cfg = TempConfig::new();
upsert_credential("anthropic", Credential::ApiKey { key: Some("k".into()), env: None })
.unwrap();
assert!(delete_credential("anthropic").unwrap());
assert!(!delete_credential("anthropic").unwrap());
assert!(read_auth().unwrap().is_empty());
}
#[test]
fn load_models_config_missing_is_empty() {
let _cfg = TempConfig::new();
let c = load_models_config().unwrap();
assert!(c.providers.is_empty());
}
#[test]
fn load_models_config_parses_with_comments() {
let _cfg = TempConfig::new();
let json = r#"{
// a one-api style gateway
"providers": {
"gateway": {
"baseUrl": "https://gw.example.com",
"authHeader": true,
"apiKey": "gw-secret",
"models": [
{ "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
]
}
}
}"#;
std::fs::write(models_path().unwrap(), json).unwrap();
let c = load_models_config().unwrap();
let gw = c.providers.get("gateway").expect("gateway provider present");
assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
assert!(gw.auth_header.unwrap_or(false));
assert_eq!(gw.models.len(), 1);
assert_eq!(gw.models[0].id, "claude-sonnet-5");
}
#[test]
fn provider_to_models_merges_headers_without_synth_bearer() {
let cfg = ProviderConfig {
name: None,
base_url: Some("https://gw.example.com".into()),
api_key: Some("gw-secret".into()),
api: None,
headers: Some({
let mut h = BTreeMap::new();
h.insert("x-portkey-key".into(), "portkey-secret".into());
h
}),
auth_header: Some(true),
models: vec![ModelDefinition {
id: "claude-sonnet-5".into(),
name: None,
base_url: None,
reasoning: None,
context_window: None,
max_tokens: None,
input: None,
headers: None,
}],
};
let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
assert_eq!(models.len(), 1);
let m = &models[0];
assert_eq!(m.id, "claude-sonnet-5");
assert_eq!(m.base_url, "https://gw.example.com");
assert_eq!(m.provider, DEFAULT_PROVIDER_ID);
let headers = m.headers.as_ref().expect("provider headers merged");
assert_eq!(
headers.get("x-portkey-key").map(|s| s.as_str()),
Some("portkey-secret")
);
assert!(
headers.get("authorization").is_none(),
"provider_to_models must not synthesize the Bearer; resolve does"
);
}
#[test]
fn provider_to_models_ignores_non_anthropic_api() {
let cfg = ProviderConfig {
name: None,
base_url: None,
api_key: None,
api: Some("openai-completions".into()),
headers: None,
auth_header: None,
models: vec![],
};
assert!(provider_to_models("oai", &cfg).is_none());
}
#[test]
fn malformed_auth_json_is_an_error_not_silent_empty() {
let _cfg = TempConfig::new();
std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
}
#[test]
fn agent_dir_nests_under_agent_by_default() {
let _guard = env_lock().lock().unwrap();
let prev = std::env::var_os(CONFIG_DIR_ENV);
std::env::remove_var(CONFIG_DIR_ENV);
let dir = agent_dir().unwrap();
restore_env(CONFIG_DIR_ENV, prev);
assert!(dir.ends_with("agent"));
assert!(dir
.parent()
.map(|p| p.ends_with(CONFIG_DIR_NAME))
.unwrap_or(false));
}
#[test]
fn migrate_legacy_layout_moves_flat_files_into_agent() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let agent = root.join("agent");
std::fs::write(root.join("auth.json"), "{}").unwrap();
std::fs::write(root.join("models.json"), "{}").unwrap();
std::fs::write(root.join(".setup_done"), "1").unwrap();
let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
assert_eq!(moved, 3);
assert!(agent.join("auth.json").exists());
assert!(agent.join("models.json").exists());
assert!(agent.join(".setup_done").exists());
assert!(!root.join("auth.json").exists());
}
#[test]
fn migrate_legacy_layout_noop_when_agent_exists() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let agent = root.join("agent");
std::fs::write(root.join("auth.json"), "{}").unwrap();
std::fs::create_dir_all(&agent).unwrap();
let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
assert_eq!(moved, 0); }
#[test]
fn migrate_legacy_layout_noop_when_no_flat_files() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let agent = root.join("agent");
let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
assert_eq!(moved, 0);
}
#[test]
fn migrate_legacy_layout_public_skips_env_override() {
let _cfg = TempConfig::new();
let moved = migrate_legacy_layout().unwrap();
assert_eq!(moved, 0);
}
#[test]
fn read_trust_missing_file_is_empty() {
let _cfg = TempConfig::new();
assert!(read_trust().unwrap().is_empty());
}
#[test]
fn read_trust_parses_decisions() {
let _cfg = TempConfig::new();
let path = trust_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
)
.unwrap();
let store = read_trust().unwrap();
assert_eq!(store.len(), 3);
assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
assert_eq!(store.get("/home/me/untrusted").copied().flatten(), Some(false));
assert_eq!(store.get("/home/me/null").copied().flatten(), None);
}
#[test]
fn resolve_config_value_literal_passthrough() {
assert_eq!(resolve_config_value("sk-literal-key", None), Some("sk-literal-key".into()));
}
#[test]
fn resolve_config_value_env_var() {
let _guard = env_lock().lock().unwrap();
let prev = std::env::var_os("RPI_TEST_CFG_KEY");
let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
assert_eq!(
resolve_config_value("$RPI_TEST_CFG_KEY", None),
Some("secret-from-env".into())
);
assert_eq!(
resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
Some("prefix-secret-from-env-suffix".into())
);
std::env::set_var("RPI_TEST_CFG_KEY2", "two");
assert_eq!(
resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
Some("a-secret-from-env-b-two-c".into())
);
let mut overlay = BTreeMap::new();
overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
assert_eq!(
resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
Some("overlay-value".into())
);
restore_env("RPI_TEST_CFG_KEY", prev);
restore_env("RPI_TEST_CFG_KEY2", prev2);
}
#[test]
fn resolve_config_value_unset_env_is_none() {
let _guard = env_lock().lock().unwrap();
let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
std::env::remove_var("RPI_TEST_CFG_ABSENT");
assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
assert_eq!(
resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
None
);
restore_env("RPI_TEST_CFG_ABSENT", prev);
}
#[test]
fn resolve_config_value_dollar_dollar_escapes_literal() {
assert_eq!(resolve_config_value("price-$$5", None), Some("price-$5".into()));
assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
}
#[test]
fn resolve_config_value_command_runs_shell() {
assert_eq!(
resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
Some("rpi-cfg-resolved".into())
);
assert_eq!(
resolve_config_value_uncached("!false", None),
None
);
}
#[test]
fn resolve_headers_drops_unresolvable() {
let _guard = env_lock().lock().unwrap();
let prev = std::env::var_os("RPI_TEST_HDR_SET");
std::env::set_var("RPI_TEST_HDR_SET", "set-value");
let mut h = BTreeMap::new();
h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
h.insert("x-literal".into(), "literal-value".into());
let resolved = resolve_headers(&h, None);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
assert_eq!(resolved.get("x-literal").map(|s| s.as_str()), Some("literal-value"));
assert!(!resolved.contains_key("x-unset"));
restore_env("RPI_TEST_HDR_SET", prev);
}
#[test]
fn agent_dir_respects_env_override() {
let _guard = env_lock().lock().unwrap();
let prev = std::env::var_os(CONFIG_DIR_ENV);
let tmp = tempfile::TempDir::new().unwrap();
std::env::set_var(CONFIG_DIR_ENV, tmp.path());
let dir = agent_dir().unwrap();
restore_env(CONFIG_DIR_ENV, prev);
assert_eq!(dir, tmp.path());
}
#[test]
fn relative_override_is_rejected() {
let _guard = env_lock().lock().unwrap();
let prev = std::env::var_os(CONFIG_DIR_ENV);
std::env::set_var(CONFIG_DIR_ENV, "relative/path");
let err = agent_dir().unwrap_err();
restore_env(CONFIG_DIR_ENV, prev);
assert!(matches!(err, ConfigError::RelativeOverride { .. }));
}
fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
match prev {
Some(v) => std::env::set_var(name, v),
None => std::env::remove_var(name),
}
}
}