Skip to main content

rpi_cli/
config.rs

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