Skip to main content

rpi_cli/
config.rs

1//! # Layout
2//!
3//! Uses a nested layout: a `~/.rpi/agent/` directory (or one pointed at via
4//! `RPI_CODING_AGENT_DIR`) "just works":
5//!
6//! ```text
7//! ~/.rpi/                 (RPI_CODING_AGENT_DIR env overrides the agent/ dir)
8//! └── agent/
9//!     ├── auth.json       # persisted credentials (mode 0o600 on Unix)
10//!     ├── models.json     # user-defined provider/model catalog (hand-edited)
11//!     ├── settings.json   # saved default provider/model/thinking + theme
12//!     ├── trust.json      # per-cwd project trust decisions (read-only parity)
13//!     ├── .setup_done     # first-time-setup sentinel (extras.rs)
14//! ```
15//!
16//! Flat-installed `~/.rpi/{auth.json,models.json}` from older rpi releases are
17//! migrated under `agent/` on the next launch by [`migrate_legacy_layout`]
18//! (best-effort, idempotent; only when the env override is unset).
19//!
20//! # Concurrency
21//!
22//! v1 is a single-process CLI, so we use **atomic rename** instead of upstream's
23//! `proper-lockfile`: write a sibling temp file, `fs::rename` over the target,
24//! then `chmod 0o600` on Unix (Windows chmod is a no-op, matching Node).
25//! Concurrent `rpi auth login` from two shells could lose one update — that's
26//! accepted and documented; adding a file lock is deferred.
27//!
28//! # Config-value expansion
29//!
30//! [`resolve_config_value`] expands `$ENV`/`${ENV}`/`!command` inside
31//! `apiKey`/`headers` exactly like upstream's `resolve-config-value.ts` — so a
32//! copied pi `models.json`/`auth.json` that references env vars or shell
33//! commands resolves the same way. Applied where rpi consumes those values
34//! (auth.json key, models.json bearer apiKey, provider/model `headers`).
35
36use std::collections::BTreeMap;
37use std::io::Write;
38use std::path::{Path, PathBuf};
39
40use rpi_ai::Model;
41#[cfg(test)]
42use rpi_ai::{Api, ModelCost, StreamingProtocolCompat};
43pub use rpi_runtime::config::{
44    default_anthropic_base_url, model_compat_warnings, provider_is_anthropic_compatible,
45    provider_is_openai_completions, provider_is_openai_responses, AuthStore, ConfigError,
46    Credential, ModelDefinition, ModelsConfig, ProviderConfig, ANTHROPIC_DEFAULT_BASE_URL,
47};
48
49/// The config directory name under the home dir.
50pub const CONFIG_DIR_NAME: &str = ".rpi";
51
52/// Env var that overrides the whole config dir. Absolute path; relative
53/// values are rejected.
54pub const CONFIG_DIR_ENV: &str = "RPI_CODING_AGENT_DIR";
55
56/// The provider id under which `rpi auth login` stores the Anthropic key.
57/// Mirrors upstream's fixed `anthropic` provider id.
58pub use rpi_runtime::config::DEFAULT_PROVIDER_ID;
59
60// ---------------------------------------------------------------------------
61// Path resolution
62// ---------------------------------------------------------------------------
63
64/// The rpi config directory (`~/.rpi/agent` by default, `RPI_CODING_AGENT_DIR`
65/// override). Creates nothing — purely a path computation. The `agent/` layer
66/// is `join(homedir(), CONFIG_DIR_NAME, "agent")`. The env override points at
67/// the agent dir itself.
68pub fn agent_dir() -> Result<PathBuf, ConfigError> {
69    if let Some(val) = std::env::var_os(CONFIG_DIR_ENV) {
70        let p = PathBuf::from(&val);
71        if !p.is_absolute() {
72            return Err(ConfigError::RelativeOverride {
73                env: CONFIG_DIR_ENV,
74                val: val.to_string_lossy().into_owned(),
75            });
76        }
77        return Ok(p);
78    }
79    let home = dirs::home_dir().ok_or(ConfigError::NoHomeDir {
80        env: CONFIG_DIR_ENV,
81    })?;
82    Ok(home.join(CONFIG_DIR_NAME).join("agent"))
83}
84
85/// The config dir one level above the agent dir (`~/.rpi`, or the parent of an
86/// env override). Used by [`migrate_legacy_layout`] to locate the old flat
87/// layout. Returns `None` when the env override has no parent (a root path).
88fn config_root_dir() -> Result<PathBuf, ConfigError> {
89    let agent = agent_dir()?;
90    agent
91        .parent()
92        .map(Path::to_path_buf)
93        .ok_or(ConfigError::NoHomeDir {
94            env: CONFIG_DIR_ENV,
95        })
96}
97
98/// `~/.rpi/agent/auth.json`.
99pub fn auth_path() -> Result<PathBuf, ConfigError> {
100    Ok(agent_dir()?.join("auth.json"))
101}
102
103/// `~/.rpi/agent/models.json`.
104pub fn models_path() -> Result<PathBuf, ConfigError> {
105    Ok(agent_dir()?.join("models.json"))
106}
107
108/// `~/.rpi/agent/settings.json` (saved default provider/model/thinking + theme).
109pub fn settings_path() -> Result<PathBuf, ConfigError> {
110    Ok(agent_dir()?.join("settings.json"))
111}
112
113/// `~/.rpi/agent/trust.json` (per-cwd project trust decisions).
114pub fn trust_path() -> Result<PathBuf, ConfigError> {
115    Ok(agent_dir()?.join("trust.json"))
116}
117
118fn read_text_with_fallback(
119    primary: PathBuf,
120    fallback: Option<PathBuf>,
121) -> Result<Option<(PathBuf, String)>, ConfigError> {
122    match std::fs::read_to_string(&primary) {
123        Ok(text) => Ok(Some((primary, text))),
124        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
125            let Some(fallback) = fallback.filter(|path| path != &primary) else {
126                return Ok(None);
127            };
128            match std::fs::read_to_string(&fallback) {
129                Ok(text) => Ok(Some((fallback, text))),
130                Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
131                Err(source) => Err(ConfigError::Read {
132                    path: fallback,
133                    source,
134                }),
135            }
136        }
137        Err(source) => Err(ConfigError::Read {
138            path: primary,
139            source,
140        }),
141    }
142}
143
144/// One-time best-effort migration of a pre-nesting flat layout
145/// (`~/.rpi/{auth.json,models.json,.setup_done}`) into the
146/// nested `~/.rpi/agent/` layout. **No-op when `RPI_CODING_AGENT_DIR` is set**
147/// (never touch an explicit override), when the agent dir already exists, or
148/// when no flat files are present. Idempotent: a partial move resumes. Errors
149/// are swallowed (logged via the returned `Result` only so tests can observe);
150/// `app::run` ignores them so a migration hiccup never blocks startup.
151pub fn migrate_legacy_layout() -> Result<usize, ConfigError> {
152    // Only migrate the default home-backed layout — never an env override.
153    if std::env::var_os(CONFIG_DIR_ENV).is_some() {
154        return Ok(0);
155    }
156    let root = match config_root_dir() {
157        Ok(p) => p,
158        Err(_) => return Ok(0),
159    };
160    let agent = agent_dir()?;
161    migrate_legacy_layout_in(&root, &agent)
162}
163
164/// The core migration (no env gate): if `agent/` is absent but flat files exist
165/// under `root`, move `{auth.json,models.json,.setup_done}` into
166/// `agent/`. Idempotent. Factored out so tests can drive it against a temp
167/// root/agent pair without touching the env (the public
168/// [`migrate_legacy_layout`] short-circuits on an env override, which tests
169/// can't unset portably while other tests run).
170fn migrate_legacy_layout_in(root: &Path, agent: &Path) -> Result<usize, ConfigError> {
171    // If the agent dir already exists with any content, assume already migrated.
172    if agent.exists() {
173        return Ok(0);
174    }
175    // Probe for a flat file. If none, nothing to migrate.
176    let flat_auth = root.join("auth.json");
177    let flat_models = root.join("models.json");
178    if !flat_auth.exists() && !flat_models.exists() {
179        return Ok(0);
180    }
181    std::fs::create_dir_all(agent).map_err(|e| ConfigError::Write {
182        path: agent.to_path_buf(),
183        source: e,
184    })?;
185    let mut moved = 0usize;
186    for leaf in ["auth.json", "models.json", ".setup_done"] {
187        let from = root.join(leaf);
188        let to = agent.join(leaf);
189        if from.exists() && !to.exists() {
190            // `rename` across the same filesystem is atomic; fall back to copy
191            // + remove on cross-device (rare for a home dir).
192            if let Err(_e) = std::fs::rename(&from, &to) {
193                if std::fs::copy(&from, &to).is_ok() {
194                    let _ = std::fs::remove_file(&from);
195                }
196            }
197            moved += 1;
198        }
199    }
200    Ok(moved)
201}
202
203// ---------------------------------------------------------------------------
204// auth.json — Credential store
205// ---------------------------------------------------------------------------
206
207/// Read the auth store. Missing file ⇒ empty store (not an error). Malformed
208/// JSON ⇒ `ConfigError::Json` (we do not silently swallow a corrupt auth file).
209pub fn read_auth() -> Result<AuthStore, ConfigError> {
210    let path = auth_path()?;
211    match read_text_with_fallback(path, None)? {
212        Some((path, text)) => {
213            serde_json::from_str(&text).map_err(|source| ConfigError::Json { path, source })
214        }
215        None => Ok(AuthStore::new()),
216    }
217}
218
219/// Atomically write the whole auth store (ensures the dir exists, writes a
220/// temp sibling, `rename`s over the target, then `chmod 0o600` on Unix).
221pub fn write_auth(store: &AuthStore) -> Result<(), ConfigError> {
222    let path = auth_path()?;
223    let dir = agent_dir()?;
224    ensure_dir(&dir)?;
225    let json = serde_json::to_string_pretty(store).unwrap();
226    atomic_write(&path, json.as_bytes())?;
227    set_owner_only(&path);
228    Ok(())
229}
230
231/// Read-modify-write: upsert a credential for `provider_id`.
232pub fn upsert_credential(provider_id: &str, cred: Credential) -> Result<(), ConfigError> {
233    let mut store = read_auth()?;
234    store.insert(provider_id.to_string(), cred);
235    write_auth(&store)
236}
237
238/// Store an OAuth token under exactly one provider id using the atomic auth
239/// store writer. Token values never enter the event or session layer.
240pub fn upsert_oauth_token(
241    provider_id: &str,
242    access: String,
243    refresh: String,
244    expires: i64,
245) -> Result<(), ConfigError> {
246    upsert_credential(
247        provider_id,
248        Credential::Oauth {
249            access,
250            refresh,
251            expires,
252        },
253    )
254}
255
256/// Read only the OAuth credential for one provider.
257pub fn read_oauth_token(provider_id: &str) -> Result<Option<Credential>, ConfigError> {
258    Ok(match read_auth()?.get(provider_id) {
259        Some(Credential::Oauth {
260            access,
261            refresh,
262            expires,
263        }) => Some(Credential::Oauth {
264            access: access.clone(),
265            refresh: refresh.clone(),
266            expires: *expires,
267        }),
268        _ => None,
269    })
270}
271
272/// Remove `provider_id` from the store. Returns `true` if a credential was
273/// present (and is now gone), `false` if it was already absent. Always rewrites
274/// the file when the provider existed (so `auth logout` reflects the new state
275/// on disk even if the map isn't empty).
276pub fn delete_credential(provider_id: &str) -> Result<bool, ConfigError> {
277    let mut store = read_auth()?;
278    if store.remove(provider_id).is_some() {
279        write_auth(&store)?;
280        Ok(true)
281    } else {
282        Ok(false)
283    }
284}
285
286// ---------------------------------------------------------------------------
287// models.json — provider/model catalog
288// ---------------------------------------------------------------------------
289
290/// Load `~/.rpi/models.json`. Missing file ⇒ empty config (no error).
291pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
292    let path = models_path()?;
293    match read_text_with_fallback(path, None)? {
294        Some((path, text)) => {
295            parse_models_json(&text).map_err(|source| ConfigError::Json { path, source })
296        }
297        None => Ok(ModelsConfig::default()),
298    }
299}
300
301/// Diagnose unsupported compat keys and malformed values without rejecting
302/// unrelated providers.
303/// Atomically write the whole `~/.rpi/models.json` (used by the interactive
304/// provider form). Note: unlike [`crate::settings::save_settings`], this
305/// serializes the modeled config directly, so hand-written comments and unknown
306/// fields in `models.json` are not preserved on rewrite. A timestamped
307/// `models.json.bak-*` snapshot is written first so a mistake can be reverted.
308pub fn save_models_config(cfg: &ModelsConfig) -> Result<(), ConfigError> {
309    let path = models_path()?;
310    let dir = agent_dir()?;
311    ensure_dir(&dir)?;
312    // Back up the existing file before rewriting it (best-effort; a missing
313    // file is normal on first save).
314    if let Ok(existing) = std::fs::read(&path) {
315        let stamp = std::time::SystemTime::now()
316            .duration_since(std::time::UNIX_EPOCH)
317            .map(|d| d.as_secs())
318            .unwrap_or(0);
319        let backup = dir.join(format!("models.json.bak-{stamp}"));
320        let _ = std::fs::write(&backup, existing);
321    }
322    let text = serde_json::to_string_pretty(cfg).map_err(|source| ConfigError::Json {
323        path: path.clone(),
324        source,
325    })?;
326    atomic_write(&path, text.as_bytes())
327}
328
329// ---------------------------------------------------------------------------
330// trust.json — project-trust store (read-only layout parity with pi)
331// ---------------------------------------------------------------------------
332
333/// The trust store: `canonicalCwd -> decision` (`true`/`false`/`null`). Mirrors
334/// pi's `TrustFile = Record<string, boolean | null | undefined>`
335/// (`trust-manager.ts`). rpi reads this for layout parity (a copied pi
336/// `trust.json` parses + is located correctly) but does **not** gate any
337/// project resources behind trust in v1 — there is no trust prompt. Deferred.
338pub type TrustStore = BTreeMap<String, Option<bool>>;
339
340/// Read `~/.rpi/agent/trust.json`. Missing file ⇒ empty store (not an error).
341/// Malformed JSON ⇒ `ConfigError::Json`. `null` decisions deserialize as
342/// `None`; absent entries are simply not present.
343pub fn read_trust() -> Result<TrustStore, ConfigError> {
344    let path = trust_path()?;
345    match std::fs::read_to_string(&path) {
346        Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
347            path: path.clone(),
348            source: e,
349        }),
350        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
351        Err(e) => Err(ConfigError::Read { path, source: e }),
352    }
353}
354
355/// Persist the trust decision for a project directory. The path is canonical
356/// when it exists, with an absolute fallback for a project being created.
357pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
358    let key = std::fs::canonicalize(cwd)
359        .unwrap_or_else(|_| cwd.to_path_buf())
360        .to_string_lossy()
361        .into_owned();
362    let mut store = read_trust()?;
363    if let Some(decision) = trusted {
364        store.insert(key, Some(decision));
365    } else {
366        store.remove(&key);
367    }
368    let path = trust_path()?;
369    ensure_dir(&agent_dir()?)?;
370    let json = serde_json::to_string_pretty(&store).unwrap();
371    atomic_write(&path, json.as_bytes())?;
372    set_owner_only(&path);
373    Ok(())
374}
375
376/// Return the stored trust decision for `cwd`. A missing entry (or an entry
377/// explicitly set to `null`) returns `None`; callers choose their safe default.
378pub fn project_trust_decision(cwd: &Path) -> Result<Option<bool>, ConfigError> {
379    let key = std::fs::canonicalize(cwd)
380        .unwrap_or_else(|_| cwd.to_path_buf())
381        .to_string_lossy()
382        .into_owned();
383    Ok(read_trust()?.get(&key).copied().flatten())
384}
385
386/// Parse the models JSON, tolerating `//` line comments (a minimal subset of
387/// upstream's `stripJsonComments`). Tries strict JSON first; on failure, strips
388/// `//…` to end-of-line and retries.
389fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
390    match serde_json::from_str(text) {
391        Ok(c) => Ok(c),
392        Err(first) => {
393            // Best-effort comment strip — only `//` to EOL, never inside strings
394            // (a `//` inside a JSON string would already have made the strict
395            // parse fail for a *different* reason; stripping naively is an
396            // acceptable v1 trade-off, documented as a limitation).
397            let stripped = strip_line_comments(text);
398            serde_json::from_str(&stripped).map_err(|_| first)
399        }
400    }
401}
402
403/// Strip `//` line comments (to end-of-line), skipping `//` that appears inside
404/// a double-quoted string. A minimal subset of upstream's `stripJsonComments`,
405/// shared by [`parse_models_json`] and [`crate::settings::load_settings`] so a
406/// copied pi `models.json`/`settings.json` (which pi allows comments in) parses.
407pub(crate) fn strip_line_comments(text: &str) -> String {
408    text.lines()
409        .map(|line| match find_line_comment(line) {
410            Some(idx) => line[..idx].to_string(),
411            None => line.to_string(),
412        })
413        .collect::<Vec<_>>()
414        .join("\n")
415}
416
417/// Index of a `//` line comment that is *not* inside a double-quoted string.
418fn find_line_comment(line: &str) -> Option<usize> {
419    let mut in_str = false;
420    let mut esc = false;
421    for (i, ch) in line.char_indices() {
422        if esc {
423            esc = false;
424            continue;
425        }
426        match ch {
427            '\\' if in_str => esc = true,
428            '"' => in_str = !in_str,
429            '/' if !in_str && line.as_bytes().get(i + 1) == Some(&b'/') => {
430                return Some(i);
431            }
432            _ => {}
433        }
434    }
435    None
436}
437
438// ---------------------------------------------------------------------------
439// Config-value expansion (mirrors pi `resolve-config-value.ts`)
440// ---------------------------------------------------------------------------
441
442/// A process-lifetime cache for `!command` resolutions, mirroring pi's
443/// `commandResultCache`. Keyed by the raw `!cmd` string (including the `!`).
444fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
445    static CACHE: std::sync::OnceLock<
446        std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
447    > = std::sync::OnceLock::new();
448    CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
449}
450
451/// Resolve a config value (API key, header value) that may be a shell command,
452/// an env-var template, or a literal — mirroring pi's `resolveConfigValue`.
453///
454/// - `!command` → run the rest as a shell command (`sh -c` on Unix, `cmd /C` on
455///   Windows), return trimmed stdout (cached per process). Missing shell or
456///   non-zero exit ⇒ `None`.
457/// - `$VAR` / `${VAR}` templates: interpolate from `env_overlay` (winning) then
458///   the process env. `$$`→`$`, `$!`→`!` escapes. Any referenced var that is
459///   unset ⇒ the **whole** value resolves to `None` (pi semantics).
460/// - Otherwise the literal string (returned as-is).
461///
462/// `env_overlay` is the `credential.env` map for auth.json keys (pi passes the
463/// same). `None` (or an empty overlay) means process-env only — used for
464/// models.json apiKey/headers, which have no env overlay.
465pub fn resolve_config_value(
466    config: &str,
467    env_overlay: Option<&BTreeMap<String, String>>,
468) -> Option<String> {
469    rpi_runtime::values::resolve_config_value(
470        config,
471        env_overlay,
472        &crate::provider::CliValues,
473        true,
474    )
475}
476
477/// Like [`resolve_config_value`] but **uncached** — mirrors pi's
478/// `resolveConfigValueUncached`, used when a fresh resolution is required
479/// (e.g. headers, which pi resolves uncached so a rotating token is re-read).
480pub fn resolve_config_value_uncached(
481    config: &str,
482    env_overlay: Option<&BTreeMap<String, String>>,
483) -> Option<String> {
484    rpi_runtime::values::resolve_config_value(
485        config,
486        env_overlay,
487        &crate::provider::CliValues,
488        false,
489    )
490}
491
492/// Resolve every header value via [`resolve_config_value_uncached`]; drop
493/// entries that resolve to `None` (mirrors pi `resolveHeaders`). Used on
494/// models.json `headers` maps before folding onto a model.
495pub fn resolve_headers(
496    headers: &BTreeMap<String, String>,
497    env_overlay: Option<&BTreeMap<String, String>>,
498) -> BTreeMap<String, String> {
499    rpi_runtime::values::resolve_headers(headers, env_overlay, &crate::provider::CliValues)
500}
501
502/// Run `cmd` (without the leading `!`), returning trimmed stdout. Cached per
503/// process (mirrors pi `executeCommand`). 10s timeout; non-zero exit / missing
504/// shell ⇒ `None`.
505pub(crate) fn resolve_command(cmd: &str) -> Option<String> {
506    let key = format!("!{cmd}");
507    if let Some(v) = command_cache().lock().ok()?.get(&key) {
508        return v.clone();
509    }
510    let result = resolve_command_uncached(cmd);
511    if let Ok(mut cache) = command_cache().lock() {
512        cache.insert(key, result.clone());
513    }
514    result
515}
516
517#[cfg(unix)]
518fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
519    std::process::Command::new("sh")
520        .arg("-c")
521        .arg(cmd)
522        .stdin(std::process::Stdio::null())
523        .stdout(std::process::Stdio::piped())
524        .stderr(std::process::Stdio::null())
525        .output()
526        .ok()
527}
528
529#[cfg(windows)]
530fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
531    use std::os::windows::process::CommandExt;
532    std::process::Command::new("cmd")
533        .arg("/C")
534        .arg(cmd)
535        .stdin(std::process::Stdio::null())
536        .stdout(std::process::Stdio::piped())
537        .stderr(std::process::Stdio::null())
538        .creation_flags(0x0800_0000) // CREATE_NO_WINDOW
539        .output()
540        .ok()
541}
542
543/// Uncached `!command` execution (mirrors pi `executeCommandUncached`).
544pub(crate) fn resolve_command_uncached(cmd: &str) -> Option<String> {
545    let output = spawn_shell_command(cmd)?;
546    if !output.status.success() {
547        return None;
548    }
549    let stdout = String::from_utf8_lossy(&output.stdout);
550    let trimmed = stdout.trim();
551    if trimmed.is_empty() {
552        None
553    } else {
554        Some(trimmed.to_string())
555    }
556}
557
558/// (`anthropic-messages`, or omitted/unknown). Unknown `api` is allowed through
559/// for forward-compat but flagged ignored-in-v1 in the docs. Public so
560/// [`crate::provider`] can scan models.json providers for an `authHeader:true`
561/// gateway bearer source.
562/// Resolve an OpenAI-compatible provider key from its explicit config or a
563/// known provider's canonical environment variable. Arbitrary custom provider
564/// ids must declare `apiKey: "$ENV"`: deriving an env name by normalizing the
565/// id would collapse distinct native-Pi identities such as `a-b` and `a_b`.
566pub fn openai_provider_api_key(provider_id: &str, cfg: &ProviderConfig) -> Option<String> {
567    if let Some(raw) = cfg.api_key.as_deref().filter(|key| !key.is_empty()) {
568        if let Some(value) = resolve_config_value(raw, None).filter(|value| !value.is_empty()) {
569            return Some(value);
570        }
571    }
572
573    let name = native_provider_api_key_env_var(provider_id)?;
574    std::env::var(name).ok().filter(|value| !value.is_empty())
575}
576
577/// Provider-id-to-environment-variable mapping. Provider ids are
578/// identities, so aliases and case variants must not inherit another
579/// provider's credential.
580fn native_provider_api_key_env_var(provider_id: &str) -> Option<&'static str> {
581    match provider_id {
582        "openai" => crate::credentials::builtin_api_key_env(provider_id),
583        _ => None,
584    }
585}
586
587/// Convert a `(provider_id, ProviderConfig)` pair into a list of library
588/// [`Model`]s. Provider-level `base_url`/`headers`/`auth_header` fold into each
589/// model. Returns `None` for protocols that do not have a runtime provider.
590// ---------------------------------------------------------------------------
591// Internals: dir ensure, atomic write, chmod
592// ---------------------------------------------------------------------------
593
594#[cfg(unix)]
595use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
596
597/// Create the config dir if missing. Mode 0o700 on Unix (mkdir default on
598/// Windows, where the sticky-permission concept doesn't apply).
599fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
600    if dir.exists() {
601        return Ok(());
602    }
603    std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
604        path: dir.to_path_buf(),
605        source: e,
606    })?;
607    #[cfg(unix)]
608    {
609        let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
610    }
611    Ok(())
612}
613
614/// Write `bytes` to `path` atomically: a temp sibling → `rename`. The temp
615/// file lives next to the target so the rename stays on one filesystem.
616pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
617    let dir = path.parent().ok_or_else(|| ConfigError::Write {
618        path: path.to_path_buf(),
619        source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
620    })?;
621    let tmp = dir.join(format!(
622        ".{}.{}.tmp",
623        path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi"),
624        uuid::Uuid::new_v4().simple()
625    ));
626    let mut options = std::fs::OpenOptions::new();
627    options.write(true).create_new(true);
628    #[cfg(unix)]
629    options.mode(0o600);
630    let mut file = options.open(&tmp).map_err(|source| ConfigError::Write {
631        path: tmp.clone(),
632        source,
633    })?;
634    if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
635        // A partial staging file is never useful to a later caller. Best
636        // effort cleanup also avoids leaving a misleading stale temp behind
637        // when the target itself was left untouched.
638        let _ = std::fs::remove_file(&tmp);
639        return Err(ConfigError::Write {
640            path: tmp,
641            source: error,
642        });
643    }
644    drop(file);
645    if let Err(error) = std::fs::rename(&tmp, path) {
646        // `rename` is the publish point; on failure the original target is
647        // still intact. Remove the staging file before returning the error.
648        let _ = std::fs::remove_file(&tmp);
649        return Err(ConfigError::Write {
650            path: path.to_path_buf(),
651            source: error,
652        });
653    }
654    Ok(())
655}
656
657/// Best-effort tighten to owner-only (0o600). No-op on Windows (the Node
658/// upstream applies no ACL either).
659fn set_owner_only(_path: &Path) {
660    #[cfg(unix)]
661    {
662        let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
663    }
664}
665
666// ---------------------------------------------------------------------------
667// Tests
668// ---------------------------------------------------------------------------
669
670#[cfg(test)]
671pub(crate) mod test_support {
672    /// A shared workspace lock for tests that touch process-global env vars
673    /// (`RPI_CODING_AGENT_DIR`, `ANTHROPIC_*`). All env-mutating tests across
674    /// the crate (config / provider / auth) share this ONE mutex so they can't
675    /// race on the shared environment. Hold the returned guard for the whole
676    /// test (store it in a RAII struct).
677    use std::sync::{Mutex, OnceLock};
678    pub(crate) fn env_lock() -> &'static Mutex<()> {
679        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
680        LOCK.get_or_init(|| Mutex::new(()))
681    }
682
683    /// Isolate session-directory overrides while holding the shared env lock.
684    /// Restore the inherited value before releasing the lock, even on unwind.
685    pub(crate) struct SessionDirEnv {
686        _guard: std::sync::MutexGuard<'static, ()>,
687        previous: Option<std::ffi::OsString>,
688    }
689
690    impl SessionDirEnv {
691        pub(crate) fn new(directory: Option<&std::path::Path>) -> Self {
692            let guard = env_lock().lock().unwrap();
693            let previous = std::env::var_os("RPI_CODING_AGENT_SESSION_DIR");
694            match directory {
695                Some(directory) => std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", directory),
696                None => std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR"),
697            }
698            Self {
699                _guard: guard,
700                previous,
701            }
702        }
703    }
704
705    impl Drop for SessionDirEnv {
706        fn drop(&mut self) {
707            match self.previous.take() {
708                Some(value) => std::env::set_var("RPI_CODING_AGENT_SESSION_DIR", value),
709                None => std::env::remove_var("RPI_CODING_AGENT_SESSION_DIR"),
710            }
711        }
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use crate::config::test_support::env_lock;
719
720    /// Point `RPI_CODING_AGENT_DIR` at a fresh temp dir for the duration of the
721    /// test (cleaned up on drop). Holds the prior value of the env var to
722    /// restore it. Hold the env lock for its whole lifetime.
723    struct TempConfig {
724        _guard: std::sync::MutexGuard<'static, ()>,
725        _tmp: tempfile::TempDir,
726        prev: Option<std::ffi::OsString>,
727    }
728    impl TempConfig {
729        fn new() -> Self {
730            let guard = env_lock().lock().unwrap();
731            let prev = std::env::var_os(CONFIG_DIR_ENV);
732            let tmp = tempfile::TempDir::new().unwrap();
733            std::env::set_var(CONFIG_DIR_ENV, tmp.path());
734            Self {
735                _guard: guard,
736                _tmp: tmp,
737                prev,
738            }
739        }
740    }
741    impl Drop for TempConfig {
742        fn drop(&mut self) {
743            restore_env(CONFIG_DIR_ENV, self.prev.take());
744        }
745    }
746
747    #[test]
748    fn read_auth_missing_file_is_empty() {
749        let _cfg = TempConfig::new();
750        let store = read_auth().unwrap();
751        assert!(store.is_empty());
752    }
753
754    #[test]
755    fn upsert_then_read_roundtrip() {
756        let _cfg = TempConfig::new();
757        upsert_credential(
758            "anthropic",
759            Credential::ApiKey {
760                key: Some("sk-test-123".into()),
761                env: None,
762            },
763        )
764        .unwrap();
765        let store = read_auth().unwrap();
766        match store.get("anthropic") {
767            Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
768            other => panic!("unexpected cred: {other:?}"),
769        }
770        // The file should exist and be JSON.
771        let path = auth_path().unwrap();
772        assert!(path.exists(), "auth.json should exist after upsert");
773        let raw = std::fs::read_to_string(&path).unwrap();
774        assert!(raw.contains("\"anthropic\""));
775        assert!(raw.contains("api_key"));
776    }
777
778    #[test]
779    fn oauth_token_is_scoped_and_round_trips() {
780        let _cfg = TempConfig::new();
781        upsert_oauth_token("acme", "access".into(), "refresh".into(), 123).unwrap();
782        assert!(matches!(
783            read_oauth_token("acme").unwrap(),
784            Some(Credential::Oauth { access, refresh, expires })
785                if access == "access" && refresh == "refresh" && expires == 123
786        ));
787        assert!(read_oauth_token("other").unwrap().is_none());
788    }
789
790    #[test]
791    fn delete_credential_removes_entry() {
792        let _cfg = TempConfig::new();
793        upsert_credential(
794            "anthropic",
795            Credential::ApiKey {
796                key: Some("k".into()),
797                env: None,
798            },
799        )
800        .unwrap();
801        assert!(delete_credential("anthropic").unwrap());
802        // Second delete is a no-op.
803        assert!(!delete_credential("anthropic").unwrap());
804        assert!(read_auth().unwrap().is_empty());
805    }
806
807    #[test]
808    fn load_models_config_missing_is_empty() {
809        let _cfg = TempConfig::new();
810        let c = load_models_config().unwrap();
811        assert!(c.providers.is_empty());
812    }
813
814    #[test]
815    fn fallback_reader_prefers_primary_and_only_falls_back_when_missing() {
816        let tmp = tempfile::tempdir().unwrap();
817        let primary = tmp.path().join("primary.json");
818        let fallback = tmp.path().join("fallback.json");
819        std::fs::write(&fallback, "fallback").unwrap();
820
821        let (_, text) = read_text_with_fallback(primary.clone(), Some(fallback.clone()))
822            .unwrap()
823            .expect("fallback should be read");
824        assert_eq!(text, "fallback");
825
826        std::fs::write(&primary, "primary").unwrap();
827        let (path, text) = read_text_with_fallback(primary.clone(), Some(fallback))
828            .unwrap()
829            .expect("primary should be read");
830        assert_eq!(path, primary);
831        assert_eq!(text, "primary");
832    }
833
834    #[test]
835    fn fallback_reader_does_not_mask_primary_read_errors() {
836        let tmp = tempfile::tempdir().unwrap();
837        let primary = tmp.path().join("primary.json");
838        let fallback = tmp.path().join("fallback.json");
839        std::fs::create_dir(&primary).unwrap();
840        std::fs::write(&fallback, "fallback").unwrap();
841
842        let error = read_text_with_fallback(primary.clone(), Some(fallback)).unwrap_err();
843        assert!(matches!(error, ConfigError::Read { path, .. } if path == primary));
844    }
845
846    #[test]
847    fn load_models_config_parses_with_comments() {
848        let _cfg = TempConfig::new();
849        let json = r#"{
850  // a one-api style gateway
851  "providers": {
852    "gateway": {
853      "baseUrl": "https://gw.example.com",
854      "authHeader": true,
855      "apiKey": "gw-secret",
856      "models": [
857        { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
858      ]
859    }
860  }
861}"#;
862        std::fs::write(models_path().unwrap(), json).unwrap();
863        let c = load_models_config().unwrap();
864        let gw = c
865            .providers
866            .get("gateway")
867            .expect("gateway provider present");
868        assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
869        assert!(gw.auth_header.unwrap_or(false));
870        assert_eq!(gw.models.len(), 1);
871        assert_eq!(gw.models[0].id, "claude-sonnet-5");
872    }
873
874    #[test]
875    fn provider_to_models_merges_headers_without_synth_bearer() {
876        // `provider_to_models` merges model-level then provider-level headers,
877        // but does NOT synthesize the `authHeader:true` Bearer itself — that
878        // happens centrally in `crate::provider::resolve` (via
879        // `models_json_bearer_token`) so it can be skipped on the x-api-key
880        // path. Here the model carries only what the file declared.
881        let cfg = ProviderConfig {
882            name: None,
883            base_url: Some("https://gw.example.com".into()),
884            api_key: Some("gw-secret".into()),
885            api: None,
886            headers: Some({
887                let mut h = BTreeMap::new();
888                h.insert("x-portkey-key".into(), "portkey-secret".into());
889                h
890            }),
891            auth_header: Some(true),
892            models: vec![ModelDefinition {
893                id: "claude-sonnet-5".into(),
894                name: None,
895                base_url: None,
896                reasoning: None,
897                thinking_level_map: None,
898                context_window: None,
899                max_tokens: None,
900                cost: None,
901                input: None,
902                headers: None,
903                compat: None,
904            }],
905        };
906        let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
907        assert_eq!(models.len(), 1);
908        let m = &models[0];
909        assert_eq!(m.id, "claude-sonnet-5");
910        assert_eq!(m.base_url, "https://gw.example.com");
911        assert_eq!(m.provider, "gateway");
912        let headers = m.headers.as_ref().expect("provider headers merged");
913        // Declared provider header folds in…
914        assert_eq!(
915            headers.get("x-portkey-key").map(|s| s.as_str()),
916            Some("portkey-secret")
917        );
918        // …but no Bearer is synthesized here. The bearer-from-authHeader path
919        // is exercised end-to-end by the provider.rs `resolve` tests
920        // (`models_json_auth_header_satisfies_auth_without_env`,
921        // `api_key_flag_beats_models_json_bearer`).
922        assert!(
923            headers.get("authorization").is_none(),
924            "provider_to_models must not synthesize the Bearer; resolve does"
925        );
926    }
927
928    #[test]
929    fn provider_to_models_supports_openai_completions() {
930        let config: ModelsConfig = serde_json::from_str(
931            r#"{
932                "providers": {
933                    "oai": {
934                        "api": "openai-completions",
935                        "baseUrl": "https://gateway.example.com/v1",
936                        "apiKey": "secret",
937                        "models": [{"id":"gpt-test","maxTokens":4096}]
938                    }
939                }
940            }"#,
941        )
942        .unwrap();
943        let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
944        assert_eq!(models.len(), 1);
945        assert_eq!(models[0].api, Api::OpenaiCompletions);
946        assert_eq!(models[0].provider, "oai");
947        assert_eq!(models[0].max_tokens, 4096);
948        assert_eq!(models[0].cost, ModelCost::default());
949        assert!(!models[0].has_pricing());
950        assert!(
951            models[0].headers.is_none(),
952            "credentials are bound after provider resolution"
953        );
954    }
955
956    #[test]
957    fn models_json_pricing_reaches_providers_and_survives_serialization() {
958        let mut config = parse_models_json(
959            r#"{
960                "providers": {
961                    "pricing-fixture": {
962                        "api": "anthropic-messages",
963                        "models": [{
964                            "id": "priced-model",
965                            "cost": {
966                                "input": 2, "output": 8, "cacheRead": 0.2, "cacheWrite": 2.5,
967                                "tiers": [{
968                                    "inputTokensAbove": 1000000,
969                                    "input": 4, "output": 16, "cacheRead": 0.4, "cacheWrite": 5
970                                }]
971                            }
972                        }]
973                    }
974                }
975            }"#,
976        )
977        .unwrap();
978        let expected = config.providers["pricing-fixture"].models[0]
979            .cost
980            .clone()
981            .unwrap();
982        for api in [
983            "anthropic-messages",
984            "openai-completions",
985            "openai-responses",
986        ] {
987            let cfg = config.providers.get_mut("pricing-fixture").unwrap();
988            cfg.api = Some(api.into());
989            let models = provider_to_models("pricing-fixture", cfg).unwrap();
990            assert_eq!(models[0].cost, expected);
991            assert!(models[0].has_pricing());
992            let mut usage = rpi_ai::Usage::zero();
993            usage.input = 100_000;
994            usage.output = 250_000;
995            usage.cache_read = 300_000;
996            usage.cache_write = 400_000;
997            let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
998            assert!((cost.total - 3.26).abs() < 1e-10, "base pricing for {api}");
999            usage.input = 1_000_001;
1000            usage.output = 1_000_000;
1001            usage.cache_read = 0;
1002            usage.cache_write = 0;
1003            let cost = rpi_ai::providers::anthropic::cost::calculate_cost(&models[0].cost, &usage);
1004            assert!(
1005                (cost.total - 20.000004).abs() < 1e-10,
1006                "tiered pricing for {api}"
1007            );
1008        }
1009        let saved = serde_json::to_string(&config).unwrap();
1010        let reloaded = parse_models_json(&saved).unwrap();
1011        assert_eq!(
1012            reloaded.providers["pricing-fixture"].models[0].cost,
1013            Some(expected)
1014        );
1015        let cfg = config.providers.get_mut("pricing-fixture").unwrap();
1016        cfg.models[0].cost = Some(ModelCost::default());
1017        let free = provider_to_models("pricing-fixture", cfg).unwrap();
1018        assert_eq!(free[0].cost, ModelCost::default());
1019        assert!(
1020            free[0].has_pricing(),
1021            "explicit zero prices are still configured"
1022        );
1023    }
1024
1025    #[test]
1026    fn models_json_compat_reaches_the_model_for_openai_completions() {
1027        // The `compat` block is what lets a custom openai-completions provider
1028        // describe its quirks. `requiresThinkingAsText` is the one that decides
1029        // whether the model's own reasoning is sent back or dropped — see
1030        // `docs/debugging/llm-repetition-forensics.md` §八. If this hop ever breaks, the
1031        // setting silently does nothing (the provider falls back to
1032        // `Content::text_only`, which drops every thinking block).
1033        let config: ModelsConfig = serde_json::from_str(
1034            r#"{
1035                "providers": {
1036                    "qwen": {
1037                        "api": "openai-completions",
1038                        "baseUrl": "https://coding.example.com/v1",
1039                        "apiKey": "secret",
1040                        "models": [{
1041                            "id": "qwen3.7-plus",
1042                            "maxTokens": 131072,
1043                            "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" },
1044                            "compat": { "requiresThinkingAsText": true }
1045                        }]
1046                    }
1047                }
1048            }"#,
1049        )
1050        .unwrap();
1051        let models = provider_to_models("qwen", &config.providers["qwen"]).unwrap();
1052        assert_eq!(
1053            models[0]
1054                .thinking_level_map
1055                .as_ref()
1056                .unwrap()
1057                .get(&rpi_ai::ThinkingLevel::Xhigh),
1058            Some(&Some("xhigh".into()))
1059        );
1060        match models[0].compat.as_ref().expect("compat must survive") {
1061            StreamingProtocolCompat::OpenaiCompletions(compat) => {
1062                assert_eq!(compat.requires_thinking_as_text, Some(true));
1063            }
1064            other => panic!("expected OpenaiCompletions compat, got {other:?}"),
1065        }
1066
1067        // An entry without `compat` stays on the default (thinking dropped).
1068        let plain: ModelsConfig = serde_json::from_str(
1069            r#"{
1070                "providers": {
1071                    "qwen": {
1072                        "api": "openai-completions",
1073                        "baseUrl": "https://coding.example.com/v1",
1074                        "apiKey": "secret",
1075                        "models": [{"id":"qwen3.7-plus"}]
1076                    }
1077                }
1078            }"#,
1079        )
1080        .unwrap();
1081        let plain_models = provider_to_models("qwen", &plain.providers["qwen"]).unwrap();
1082        assert!(plain_models[0].compat.is_none());
1083    }
1084
1085    #[test]
1086    fn models_json_accepts_pi_deepseek_compat_and_warns_on_unknown_or_invalid_fields() {
1087        let mut config: ModelsConfig = serde_json::from_value(serde_json::json!({
1088            "providers": { "deepseek": {
1089                "api": "openai-completions",
1090                "models": [{ "id": "reasoner", "reasoning": true, "compat": {
1091                    "thinkingFormat": "deepseek",
1092                    "requiresReasoningContentOnAssistantMessages": true
1093                }}]
1094            }}
1095        }))
1096        .unwrap();
1097        assert!(model_compat_warnings(&config).is_empty());
1098        let models = provider_to_models("deepseek", &config.providers["deepseek"]).unwrap();
1099        assert!(rpi_ai::model::prior_reasoning_is_replayed(&models[0]));
1100        let Some(StreamingProtocolCompat::OpenaiCompletions(compat)) = &models[0].compat else {
1101            panic!("missing model compatibility flags");
1102        };
1103        assert_eq!(compat.requires_reasoning_content, Some(true));
1104        assert_eq!(
1105            compat.thinking_format,
1106            Some(rpi_ai::model::OpenaiThinkingFormat::Deepseek)
1107        );
1108
1109        config.providers["deepseek"].models[0]
1110            .compat
1111            .as_mut()
1112            .unwrap()["misspelledField"] = serde_json::json!("secret-fixture-value");
1113        let warnings = model_compat_warnings(&config);
1114        assert_eq!(warnings.len(), 1);
1115        assert!(warnings[0].contains("deepseek") && warnings[0].contains("reasoner"));
1116        assert!(warnings[0].contains("unknown field \"misspelledField\""));
1117        assert!(!warnings[0].contains("secret-fixture-value"));
1118
1119        config.providers["deepseek"].models[0].compat =
1120            Some(serde_json::json!({"requiresReasoningContent": "secret-fixture-token"}));
1121        let warning = &model_compat_warnings(&config)[0];
1122        assert!(warning.contains("invalid configuration"));
1123        assert!(!warning.contains("secret-fixture-token"));
1124        config.providers["deepseek"].models[0].compat =
1125            Some(serde_json::json!({"thinkingFormat": "unsupported"}));
1126        assert!(model_compat_warnings(&config)[0].contains("invalid configuration"));
1127    }
1128
1129    #[test]
1130    fn responses_compat_diagnostics_use_the_responses_schema() {
1131        let config: ModelsConfig = serde_json::from_value(serde_json::json!({
1132            "providers": { "relay": {
1133                "api": "openai-responses", "models": [{ "id": "test", "compat": {
1134                    "supportsOpenAIGrammarTools": true, "supportsStore": false
1135                }}]
1136            }}
1137        }))
1138        .unwrap();
1139        let warnings = model_compat_warnings(&config);
1140        assert_eq!(warnings.len(), 1);
1141        assert!(warnings[0].contains("supportsStore"));
1142    }
1143
1144    #[test]
1145    fn native_reasoning_content_key_wins_over_a_conflicting_pi_alias() {
1146        let config: ModelsConfig = serde_json::from_value(serde_json::json!({
1147            "providers": { "relay": {
1148                "api": "openai-completions", "models": [{ "id": "test", "compat": {
1149                    "requiresReasoningContent": false,
1150                    "requiresReasoningContentOnAssistantMessages": true
1151                }}]
1152            }}
1153        }))
1154        .unwrap();
1155        let warnings = model_compat_warnings(&config);
1156        assert_eq!(warnings.len(), 1);
1157        assert!(warnings[0].contains("takes precedence"));
1158        let models = provider_to_models("relay", &config.providers["relay"]).unwrap();
1159        let Some(StreamingProtocolCompat::OpenaiCompletions(compat)) = &models[0].compat else {
1160            panic!("valid native flag must survive an alias conflict");
1161        };
1162        assert_eq!(compat.requires_reasoning_content, Some(false));
1163    }
1164
1165    #[test]
1166    fn custom_openai_provider_resolves_explicit_api_key_environment_reference() {
1167        let _guard = env_lock().lock().unwrap();
1168        let env_name = "RPI_FAKE_PROVIDER_API_KEY";
1169        std::env::set_var(env_name, "env-secret");
1170        let cfg = ProviderConfig {
1171            name: None,
1172            base_url: Some("https://gateway.example.com/v1".into()),
1173            api_key: Some("$RPI_FAKE_PROVIDER_API_KEY".into()),
1174            api: Some("openai-completions".into()),
1175            headers: None,
1176            auth_header: None,
1177            models: vec![ModelDefinition {
1178                id: "fake-model".into(),
1179                name: None,
1180                base_url: None,
1181                reasoning: None,
1182                thinking_level_map: None,
1183                context_window: None,
1184                max_tokens: None,
1185                cost: None,
1186                input: None,
1187                headers: None,
1188                compat: None,
1189            }],
1190        };
1191        let models = provider_to_models("rpi-fake-provider", &cfg).unwrap();
1192        assert_eq!(
1193            openai_provider_api_key("rpi-fake-provider", &cfg).as_deref(),
1194            Some("env-secret")
1195        );
1196        assert!(
1197            models[0].headers.is_none(),
1198            "discovery does not bind credentials"
1199        );
1200        std::env::remove_var(env_name);
1201    }
1202
1203    #[test]
1204    fn openai_protocol_ids_do_not_inherit_the_official_env_key() {
1205        let _guard = env_lock().lock().unwrap();
1206        let prev_openai = std::env::var_os("OPENAI_API_KEY");
1207        let prev_completions = std::env::var_os("OPENAI_COMPLETIONS_API_KEY");
1208        let prev_responses = std::env::var_os("OPENAI_RESPONSES_API_KEY");
1209        std::env::set_var("OPENAI_API_KEY", "official-key");
1210        std::env::remove_var("OPENAI_COMPLETIONS_API_KEY");
1211        std::env::remove_var("OPENAI_RESPONSES_API_KEY");
1212        let config = ProviderConfig {
1213            name: None,
1214            base_url: Some("https://gateway.example.com/v1".into()),
1215            api_key: None,
1216            api: Some("openai-responses".into()),
1217            headers: None,
1218            auth_header: None,
1219            models: Vec::new(),
1220        };
1221
1222        assert_eq!(
1223            openai_provider_api_key("openai", &config).as_deref(),
1224            Some("official-key")
1225        );
1226        assert!(openai_provider_api_key("openai-completions", &config).is_none());
1227        assert!(openai_provider_api_key("openai-responses", &config).is_none());
1228        assert!(openai_provider_api_key("OpenAI", &config).is_none());
1229
1230        restore_env("OPENAI_API_KEY", prev_openai);
1231        restore_env("OPENAI_COMPLETIONS_API_KEY", prev_completions);
1232        restore_env("OPENAI_RESPONSES_API_KEY", prev_responses);
1233    }
1234
1235    #[test]
1236    fn native_provider_api_key_env_mapping_uses_exact_provider_ids() {
1237        assert_eq!(
1238            native_provider_api_key_env_var("openai"),
1239            Some("OPENAI_API_KEY")
1240        );
1241        for provider in [
1242            "deepseek",
1243            "openrouter",
1244            "google",
1245            "groq",
1246            "qwen",
1247            "nvidia",
1248            "OpenAI",
1249            "openai-completions",
1250        ] {
1251            assert_eq!(
1252                native_provider_api_key_env_var(provider),
1253                None,
1254                "external providers must declare auth in their package or models.json"
1255            );
1256        }
1257    }
1258
1259    #[test]
1260    fn malformed_auth_json_is_an_error_not_silent_empty() {
1261        let _cfg = TempConfig::new();
1262        std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1263        assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1264    }
1265
1266    #[test]
1267    fn agent_dir_nests_under_agent_by_default() {
1268        // With no env override, agent_dir() must end in `.../.rpi/agent`
1269        // (mirrors pi's `getAgentDir`). We can't assertion the home prefix
1270        // portably, but the leaf two segments are stable.
1271        let _guard = env_lock().lock().unwrap();
1272        let prev = std::env::var_os(CONFIG_DIR_ENV);
1273        std::env::remove_var(CONFIG_DIR_ENV);
1274        let dir = agent_dir().unwrap();
1275        restore_env(CONFIG_DIR_ENV, prev);
1276        assert!(dir.ends_with("agent"));
1277        assert!(dir
1278            .parent()
1279            .map(|p| p.ends_with(CONFIG_DIR_NAME))
1280            .unwrap_or(false));
1281    }
1282
1283    #[test]
1284    fn migrate_legacy_layout_moves_flat_files_into_agent() {
1285        // Drive the core migration directly against a temp root/agent so the
1286        // result is independent of whatever RPI_CODING_AGENT_DIR the parallel
1287        // TempConfig tests happen to set.
1288        let tmp = tempfile::TempDir::new().unwrap();
1289        let root = tmp.path().to_path_buf();
1290        let agent = root.join("agent");
1291        std::fs::write(root.join("auth.json"), "{}").unwrap();
1292        std::fs::write(root.join("models.json"), "{}").unwrap();
1293        std::fs::write(root.join(".setup_done"), "1").unwrap();
1294        let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1295        assert_eq!(moved, 3);
1296        assert!(agent.join("auth.json").exists());
1297        assert!(agent.join("models.json").exists());
1298        assert!(agent.join(".setup_done").exists());
1299        assert!(!root.join("auth.json").exists());
1300    }
1301
1302    #[test]
1303    fn migrate_legacy_layout_noop_when_agent_exists() {
1304        let tmp = tempfile::TempDir::new().unwrap();
1305        let root = tmp.path().to_path_buf();
1306        let agent = root.join("agent");
1307        std::fs::write(root.join("auth.json"), "{}").unwrap();
1308        std::fs::create_dir_all(&agent).unwrap();
1309        let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1310        assert_eq!(moved, 0); // agent/ already present — leave flat file alone
1311    }
1312
1313    #[test]
1314    fn migrate_legacy_layout_noop_when_no_flat_files() {
1315        let tmp = tempfile::TempDir::new().unwrap();
1316        let root = tmp.path().to_path_buf();
1317        let agent = root.join("agent");
1318        let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1319        assert_eq!(moved, 0);
1320    }
1321
1322    #[test]
1323    fn migrate_legacy_layout_public_skips_env_override() {
1324        // When RPI_CODING_AGENT_DIR is set, the public entry point is a no-op
1325        // (it must never touch an explicit override). TempConfig sets it.
1326        let _cfg = TempConfig::new();
1327        let moved = migrate_legacy_layout().unwrap();
1328        assert_eq!(moved, 0);
1329    }
1330
1331    #[test]
1332    fn read_trust_missing_file_is_empty() {
1333        let _cfg = TempConfig::new();
1334        assert!(read_trust().unwrap().is_empty());
1335    }
1336
1337    #[test]
1338    fn read_trust_parses_decisions() {
1339        let _cfg = TempConfig::new();
1340        let path = trust_path().unwrap();
1341        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1342        std::fs::write(
1343            &path,
1344            r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1345        )
1346        .unwrap();
1347        let store = read_trust().unwrap();
1348        assert_eq!(store.len(), 3);
1349        assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1350        assert_eq!(
1351            store.get("/home/me/untrusted").copied().flatten(),
1352            Some(false)
1353        );
1354        assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1355    }
1356
1357    #[test]
1358    fn resolve_config_value_literal_passthrough() {
1359        assert_eq!(
1360            resolve_config_value("sk-literal-key", None),
1361            Some("sk-literal-key".into())
1362        );
1363    }
1364
1365    #[test]
1366    fn resolve_config_value_env_var() {
1367        let _guard = env_lock().lock().unwrap();
1368        let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1369        let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1370        std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1371        assert_eq!(
1372            resolve_config_value("$RPI_TEST_CFG_KEY", None),
1373            Some("secret-from-env".into())
1374        );
1375        assert_eq!(
1376            resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1377            Some("prefix-secret-from-env-suffix".into())
1378        );
1379        // Two vars in one template.
1380        std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1381        assert_eq!(
1382            resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1383            Some("a-secret-from-env-b-two-c".into())
1384        );
1385        // Env overlay wins over process env.
1386        let mut overlay = BTreeMap::new();
1387        overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1388        assert_eq!(
1389            resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1390            Some("overlay-value".into())
1391        );
1392        restore_env("RPI_TEST_CFG_KEY", prev);
1393        restore_env("RPI_TEST_CFG_KEY2", prev2);
1394    }
1395
1396    #[test]
1397    fn resolve_config_value_unset_env_is_none() {
1398        let _guard = env_lock().lock().unwrap();
1399        let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1400        std::env::remove_var("RPI_TEST_CFG_ABSENT");
1401        // Any referenced unset var ⇒ the whole value is None (pi semantics).
1402        assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1403        assert_eq!(
1404            resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1405            None
1406        );
1407        restore_env("RPI_TEST_CFG_ABSENT", prev);
1408    }
1409
1410    #[test]
1411    fn resolve_config_value_dollar_dollar_escapes_literal() {
1412        assert_eq!(
1413            resolve_config_value("price-$$5", None),
1414            Some("price-$5".into())
1415        );
1416        assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1417    }
1418
1419    #[test]
1420    fn resolve_config_value_command_runs_shell() {
1421        // `!echo resolved` → "resolved" (sh on Unix; `echo` works under cmd too).
1422        assert_eq!(
1423            resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1424            Some("rpi-cfg-resolved".into())
1425        );
1426        // Non-zero exit ⇒ None.
1427        assert_eq!(resolve_config_value_uncached("!false", None), None);
1428    }
1429
1430    #[test]
1431    fn resolve_headers_drops_unresolvable() {
1432        let _guard = env_lock().lock().unwrap();
1433        let prev = std::env::var_os("RPI_TEST_HDR_SET");
1434        std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1435        let mut h = BTreeMap::new();
1436        h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1437        h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1438        h.insert("x-literal".into(), "literal-value".into());
1439        let resolved = resolve_headers(&h, None);
1440        assert_eq!(resolved.len(), 2);
1441        assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1442        assert_eq!(
1443            resolved.get("x-literal").map(|s| s.as_str()),
1444            Some("literal-value")
1445        );
1446        assert!(!resolved.contains_key("x-unset"));
1447        restore_env("RPI_TEST_HDR_SET", prev);
1448    }
1449
1450    #[test]
1451    fn agent_dir_respects_env_override() {
1452        let _guard = env_lock().lock().unwrap();
1453        let prev = std::env::var_os(CONFIG_DIR_ENV);
1454        let tmp = tempfile::TempDir::new().unwrap();
1455        std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1456        let dir = agent_dir().unwrap();
1457        restore_env(CONFIG_DIR_ENV, prev);
1458        assert_eq!(dir, tmp.path());
1459    }
1460
1461    #[test]
1462    fn relative_override_is_rejected() {
1463        let _guard = env_lock().lock().unwrap();
1464        let prev = std::env::var_os(CONFIG_DIR_ENV);
1465        std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1466        let err = agent_dir().unwrap_err();
1467        restore_env(CONFIG_DIR_ENV, prev);
1468        assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1469    }
1470
1471    /// Restore/remove an env var based on its prior `OsString` value.
1472    fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1473        match prev {
1474            Some(v) => std::env::set_var(name, v),
1475            None => std::env::remove_var(name),
1476        }
1477    }
1478}
1479
1480/// Convert host configuration with the same rules as the embeddable runtime.
1481pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
1482    rpi_runtime::config::provider_to_models(provider_id, cfg, &crate::provider::CliValues)
1483}