Skip to main content

rpi_cli/
config.rs

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