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