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