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    pub providers: BTreeMap<String, ProviderConfig>,
294}
295
296/// A provider entry in `models.json`. The fields mirror the TS `ProviderConfig`
297/// one-for-one; v1 honors `base_url`/`api_key`/`headers`/`auth_header`/`models`,
298/// and **ignores** `api` values other than `anthropic-messages` (documented).
299#[derive(serde::Deserialize, Debug, Clone)]
300#[serde(rename_all = "camelCase")]
301pub struct ProviderConfig {
302    #[serde(default)]
303    pub name: Option<String>,
304    #[serde(default)]
305    pub base_url: Option<String>,
306    #[serde(default)]
307    pub api_key: Option<String>,
308    #[serde(default)]
309    pub api: Option<String>,
310    #[serde(default)]
311    pub headers: Option<BTreeMap<String, String>>,
312    /// `true` ⇒ wrap `api_key` as `Authorization: Bearer <key>` (mirrors
313    /// upstream `provider-composer.ts` `authHeader`).
314    #[serde(default)]
315    pub auth_header: Option<bool>,
316    #[serde(default)]
317    pub models: Vec<ModelDefinition>,
318}
319
320/// One model under a provider. `id` is required (mirrors TS `ModelDefinition`).
321#[derive(serde::Deserialize, Debug, Clone)]
322#[serde(rename_all = "camelCase")]
323pub struct ModelDefinition {
324    pub id: String,
325    #[serde(default)]
326    pub name: Option<String>,
327    #[serde(default)]
328    pub base_url: Option<String>,
329    #[serde(default)]
330    pub reasoning: Option<bool>,
331    #[serde(default)]
332    pub context_window: Option<u64>,
333    #[serde(default)]
334    pub max_tokens: Option<u64>,
335    /// Free-form modality strings ("text"/"image"); unknown values fall back
336    /// to text-only.
337    #[serde(default)]
338    pub input: Option<Vec<String>>,
339    #[serde(default)]
340    pub headers: Option<BTreeMap<String, String>>,
341    #[serde(default)]
342    pub compat: Option<serde_json::Value>,
343}
344
345/// Load `~/.rpi/models.json`. Missing file ⇒ empty config (no error).
346pub fn load_models_config() -> Result<ModelsConfig, ConfigError> {
347    let path = models_path()?;
348    match std::fs::read_to_string(&path) {
349        Ok(text) => parse_models_json(&text).map_err(|e| ConfigError::Json {
350            path: path.clone(),
351            source: e,
352        }),
353        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsConfig::default()),
354        Err(e) => Err(ConfigError::Read { path, source: e }),
355    }
356}
357
358// ---------------------------------------------------------------------------
359// trust.json — project-trust store (read-only layout parity with pi)
360// ---------------------------------------------------------------------------
361
362/// The trust store: `canonicalCwd -> decision` (`true`/`false`/`null`). Mirrors
363/// pi's `TrustFile = Record<string, boolean | null | undefined>`
364/// (`trust-manager.ts`). rpi reads this for layout parity (a copied pi
365/// `trust.json` parses + is located correctly) but does **not** gate any
366/// project resources behind trust in v1 — there is no trust prompt. Deferred.
367pub type TrustStore = BTreeMap<String, Option<bool>>;
368
369/// Read `~/.rpi/agent/trust.json`. Missing file ⇒ empty store (not an error).
370/// Malformed JSON ⇒ `ConfigError::Json`. `null` decisions deserialize as
371/// `None`; absent entries are simply not present.
372pub fn read_trust() -> Result<TrustStore, ConfigError> {
373    let path = trust_path()?;
374    match std::fs::read_to_string(&path) {
375        Ok(text) => serde_json::from_str(&text).map_err(|e| ConfigError::Json {
376            path: path.clone(),
377            source: e,
378        }),
379        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::new()),
380        Err(e) => Err(ConfigError::Read { path, source: e }),
381    }
382}
383
384/// Persist the trust decision for a project directory. The path is canonical
385/// when it exists, with an absolute fallback for a project being created.
386pub fn set_project_trust(cwd: &Path, trusted: Option<bool>) -> Result<(), ConfigError> {
387    let key = std::fs::canonicalize(cwd)
388        .unwrap_or_else(|_| cwd.to_path_buf())
389        .to_string_lossy()
390        .into_owned();
391    let mut store = read_trust()?;
392    if let Some(decision) = trusted {
393        store.insert(key, Some(decision));
394    } else {
395        store.remove(&key);
396    }
397    let path = trust_path()?;
398    ensure_dir(&agent_dir()?)?;
399    let json = serde_json::to_string_pretty(&store).unwrap();
400    atomic_write(&path, json.as_bytes())?;
401    set_owner_only(&path);
402    Ok(())
403}
404
405/// Parse the models JSON, tolerating `//` line comments (a minimal subset of
406/// upstream's `stripJsonComments`). Tries strict JSON first; on failure, strips
407/// `//…` to end-of-line and retries.
408fn parse_models_json(text: &str) -> Result<ModelsConfig, serde_json::Error> {
409    match serde_json::from_str(text) {
410        Ok(c) => Ok(c),
411        Err(first) => {
412            // Best-effort comment strip — only `//` to EOL, never inside strings
413            // (a `//` inside a JSON string would already have made the strict
414            // parse fail for a *different* reason; stripping naively is an
415            // acceptable v1 trade-off, documented as a limitation).
416            let stripped = strip_line_comments(text);
417            serde_json::from_str(&stripped).map_err(|_| first)
418        }
419    }
420}
421
422/// Strip `//` line comments (to end-of-line), skipping `//` that appears inside
423/// a double-quoted string. A minimal subset of upstream's `stripJsonComments`,
424/// shared by [`parse_models_json`] and [`crate::settings::load_settings`] so a
425/// copied pi `models.json`/`settings.json` (which pi allows comments in) parses.
426pub(crate) fn strip_line_comments(text: &str) -> String {
427    text.lines()
428        .map(|line| match find_line_comment(line) {
429            Some(idx) => line[..idx].to_string(),
430            None => line.to_string(),
431        })
432        .collect::<Vec<_>>()
433        .join("\n")
434}
435
436/// Index of a `//` line comment that is *not* inside a double-quoted string.
437fn find_line_comment(line: &str) -> Option<usize> {
438    let mut in_str = false;
439    let mut esc = false;
440    for (i, ch) in line.char_indices() {
441        if esc {
442            esc = false;
443            continue;
444        }
445        match ch {
446            '\\' if in_str => esc = true,
447            '"' => in_str = !in_str,
448            '/' if !in_str => {
449                if line.as_bytes().get(i + 1) == Some(&b'/') {
450                    return Some(i);
451                }
452            }
453            _ => {}
454        }
455    }
456    None
457}
458
459// ---------------------------------------------------------------------------
460// Config-value expansion (mirrors pi `resolve-config-value.ts`)
461// ---------------------------------------------------------------------------
462
463/// A process-lifetime cache for `!command` resolutions, mirroring pi's
464/// `commandResultCache`. Keyed by the raw `!cmd` string (including the `!`).
465fn command_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<String>>> {
466    static CACHE: std::sync::OnceLock<
467        std::sync::Mutex<std::collections::HashMap<String, Option<String>>>,
468    > = std::sync::OnceLock::new();
469    CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
470}
471
472/// Resolve a config value (API key, header value) that may be a shell command,
473/// an env-var template, or a literal — mirroring pi's `resolveConfigValue`.
474///
475/// - `!command` → run the rest as a shell command (`sh -c` on Unix, `cmd /C` on
476///   Windows), return trimmed stdout (cached per process). Missing shell or
477///   non-zero exit ⇒ `None`.
478/// - `$VAR` / `${VAR}` templates: interpolate from `env_overlay` (winning) then
479///   the process env. `$$`→`$`, `$!`→`!` escapes. Any referenced var that is
480///   unset ⇒ the **whole** value resolves to `None` (pi semantics).
481/// - Otherwise the literal string (returned as-is).
482///
483/// `env_overlay` is the `credential.env` map for auth.json keys (pi passes the
484/// same). `None` (or an empty overlay) means process-env only — used for
485/// models.json apiKey/headers, which have no env overlay.
486pub fn resolve_config_value(
487    config: &str,
488    env_overlay: Option<&BTreeMap<String, String>>,
489) -> Option<String> {
490    if let Some(cmd) = config.strip_prefix('!') {
491        return resolve_command(cmd);
492    }
493    resolve_template(config, env_overlay)
494}
495
496/// Like [`resolve_config_value`] but **uncached** — mirrors pi's
497/// `resolveConfigValueUncached`, used when a fresh resolution is required
498/// (e.g. headers, which pi resolves uncached so a rotating token is re-read).
499pub fn resolve_config_value_uncached(
500    config: &str,
501    env_overlay: Option<&BTreeMap<String, String>>,
502) -> Option<String> {
503    if let Some(cmd) = config.strip_prefix('!') {
504        return resolve_command_uncached(cmd);
505    }
506    resolve_template(config, env_overlay)
507}
508
509/// Resolve every header value via [`resolve_config_value_uncached`]; drop
510/// entries that resolve to `None` (mirrors pi `resolveHeaders`). Used on
511/// models.json `headers` maps before folding onto a model.
512pub fn resolve_headers(
513    headers: &BTreeMap<String, String>,
514    env_overlay: Option<&BTreeMap<String, String>>,
515) -> BTreeMap<String, String> {
516    let mut out = BTreeMap::new();
517    for (k, v) in headers {
518        if let Some(resolved) = resolve_config_value_uncached(v, env_overlay) {
519            out.insert(k.clone(), resolved);
520        }
521    }
522    out
523}
524
525/// Env lookup: `env_overlay` (if present) wins over the process env, matching
526/// pi's `resolveEnvConfigValue` (which checks `env?.[name]` before `process.env`).
527fn env_lookup(name: &str, env_overlay: Option<&BTreeMap<String, String>>) -> Option<String> {
528    if let Some(overlay) = env_overlay {
529        if let Some(v) = overlay.get(name) {
530            return Some(v.clone());
531        }
532    }
533    std::env::var(name).ok()
534}
535
536/// A parsed template part — literal text or an env-var reference.
537enum TemplatePart {
538    Literal(String),
539    Env(String),
540}
541
542/// Parse a `$VAR`/`${VAR}` template (mirrors pi `parseConfigValueTemplate`).
543/// `$$`→`$` and `$!`→`!` are escapes; `${NAME}` requires `NAME` to match
544/// `^[A-Za-z_][A-Za-z0-9_]*$` else the raw slice is kept literal; `$NAME` takes
545/// the longest `[A-Za-z_][A-Za-z0-9_]*` prefix as the name.
546fn parse_template(config: &str) -> Vec<TemplatePart> {
547    let mut parts: Vec<TemplatePart> = Vec::new();
548    let bytes = config.as_bytes();
549    let mut i = 0usize;
550    while i < bytes.len() {
551        // Find the next `$`.
552        match config[i..].find('$') {
553            None => {
554                push_literal(&mut parts, &config[i..]);
555                break;
556            }
557            Some(offset) => {
558                let dollar = i + offset;
559                push_literal(&mut parts, &config[i..dollar]);
560                let after = dollar + 1;
561                let next = bytes.get(after).copied();
562                if next == Some(b'$') || next == Some(b'!') {
563                    push_literal(&mut parts, &config[after..after + 1]);
564                    i = after + 1;
565                    continue;
566                }
567                if next == Some(b'{') {
568                    // ${NAME}
569                    if let Some(end_rel) = config[after + 1..].find('}') {
570                        let end = after + 1 + end_rel;
571                        let name = &config[after + 1..end];
572                        if is_env_name(name) {
573                            parts.push(TemplatePart::Env(name.to_string()));
574                        } else {
575                            // Not a valid name — keep the raw `${…}` literal.
576                            push_literal(&mut parts, &config[dollar..=end]);
577                        }
578                        i = end + 1;
579                        continue;
580                    }
581                    // No closing `}` — literal `$`.
582                    push_literal(&mut parts, "$");
583                    i = after;
584                    continue;
585                }
586                // $NAME (greedy prefix). Bare `$` with no name char follows.
587                if let Some(name) = env_name_prefix(&config[after..]) {
588                    parts.push(TemplatePart::Env(name.to_string()));
589                    i = after + name.len();
590                } else {
591                    push_literal(&mut parts, "$");
592                    i = after;
593                }
594            }
595        }
596    }
597    parts
598}
599
600fn push_literal(parts: &mut Vec<TemplatePart>, value: &str) {
601    if value.is_empty() {
602        return;
603    }
604    if let Some(TemplatePart::Literal(s)) = parts.last_mut() {
605        s.push_str(value);
606    } else {
607        parts.push(TemplatePart::Literal(value.to_string()));
608    }
609}
610
611fn is_env_name(s: &str) -> bool {
612    let mut chars = s.chars();
613    match chars.next() {
614        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
615        _ => return false,
616    }
617    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
618}
619
620/// The longest `[A-Za-z_][A-Za-z0-9_]*` prefix of `s` (mirrors the TS
621/// `ENV_VAR_NAME_PREFIX_RE` match), or `None` when `s` doesn't start with one.
622fn env_name_prefix(s: &str) -> Option<&str> {
623    let mut chars = s.char_indices();
624    match chars.next() {
625        Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
626        _ => return None,
627    }
628    let end = chars
629        .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_'))
630        .map(|(idx, _)| idx)
631        .unwrap_or(s.len());
632    Some(&s[..end])
633}
634
635/// Resolve a parsed template: any referenced env var that is unset ⇒ the whole
636/// value is `None` (pi semantics). Literal-only templates pass through as-is.
637fn resolve_template(
638    config: &str,
639    env_overlay: Option<&BTreeMap<String, String>>,
640) -> Option<String> {
641    let parts = parse_template(config);
642    let mut out = String::with_capacity(config.len());
643    for part in parts {
644        match part {
645            TemplatePart::Literal(s) => out.push_str(&s),
646            TemplatePart::Env(name) => match env_lookup(&name, env_overlay) {
647                Some(v) => out.push_str(&v),
648                None => return None,
649            },
650        }
651    }
652    Some(out)
653}
654
655/// Run `cmd` (without the leading `!`), returning trimmed stdout. Cached per
656/// process (mirrors pi `executeCommand`). 10s timeout; non-zero exit / missing
657/// shell ⇒ `None`.
658fn resolve_command(cmd: &str) -> Option<String> {
659    let key = format!("!{cmd}");
660    if let Some(v) = command_cache().lock().ok()?.get(&key) {
661        return v.clone();
662    }
663    let result = resolve_command_uncached(cmd);
664    if let Ok(mut cache) = command_cache().lock() {
665        cache.insert(key, result.clone());
666    }
667    result
668}
669
670#[cfg(unix)]
671fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
672    std::process::Command::new("sh")
673        .arg("-c")
674        .arg(cmd)
675        .stdin(std::process::Stdio::null())
676        .stdout(std::process::Stdio::piped())
677        .stderr(std::process::Stdio::null())
678        .output()
679        .ok()
680}
681
682#[cfg(windows)]
683fn spawn_shell_command(cmd: &str) -> Option<std::process::Output> {
684    use std::os::windows::process::CommandExt;
685    std::process::Command::new("cmd")
686        .arg("/C")
687        .arg(cmd)
688        .stdin(std::process::Stdio::null())
689        .stdout(std::process::Stdio::piped())
690        .stderr(std::process::Stdio::null())
691        .creation_flags(0x0800_0000) // CREATE_NO_WINDOW
692        .output()
693        .ok()
694}
695
696/// Uncached `!command` execution (mirrors pi `executeCommandUncached`).
697fn resolve_command_uncached(cmd: &str) -> Option<String> {
698    let output = spawn_shell_command(cmd)?;
699    if !output.status.success() {
700        return None;
701    }
702    let stdout = String::from_utf8_lossy(&output.stdout);
703    let trimmed = stdout.trim();
704    if trimmed.is_empty() {
705        None
706    } else {
707        Some(trimmed.to_string())
708    }
709}
710
711/// (`anthropic-messages`, or omitted/unknown). Unknown `api` is allowed through
712/// for forward-compat but flagged ignored-in-v1 in the docs. Public so
713/// [`crate::provider`] can scan models.json providers for an `authHeader:true`
714/// gateway bearer source.
715pub fn provider_is_anthropic_compatible(cfg: &ProviderConfig) -> bool {
716    match cfg.api.as_deref() {
717        None | Some("") | Some("anthropic-messages") => true,
718        _ => false,
719    }
720}
721
722/// Whether a configured provider uses the OpenAI Chat Completions protocol.
723pub fn provider_is_openai_completions(cfg: &ProviderConfig) -> bool {
724    matches!(cfg.api.as_deref(), Some("openai-completions"))
725}
726
727/// Convert a `(provider_id, ProviderConfig)` pair into a list of library
728/// [`Model`]s. Provider-level `base_url`/`headers`/`auth_header` fold into each
729/// model. Returns `None` for protocols that do not have a runtime provider.
730pub fn provider_to_models(provider_id: &str, cfg: &ProviderConfig) -> Option<Vec<Model>> {
731    let api = if provider_is_anthropic_compatible(cfg) {
732        Api::AnthropicMessages
733    } else if provider_is_openai_completions(cfg) {
734        Api::OpenaiCompletions
735    } else {
736        return None;
737    };
738    let provider_base = cfg.base_url.clone().unwrap_or_else(|| match api {
739        Api::OpenaiCompletions => "https://api.openai.com".to_string(),
740        _ => default_anthropic_base_url(),
741    });
742    let mut merged: Vec<Model> = Vec::with_capacity(cfg.models.len());
743    for def in &cfg.models {
744        let base_url = def
745            .base_url
746            .clone()
747            .unwrap_or_else(|| provider_base.clone());
748        let name = def.name.clone().unwrap_or_else(|| def.id.clone());
749        // v1 routes EVERY anthropic-messages model through the single
750        // `AnthropicProvider` (whose `id()` is "anthropic"). Upstream
751        // `registerProvider(providerName, …)` registers a distinct provider per
752        // models.json key and routes by that key; v1 has no multi-provider
753        // registry, so the models.json provider id is config-namespacing only
754        // — the per-model `base_url` + `headers` carry the actual endpoint/auth
755        // differentiation. Stamping `provider = "anthropic"` here lets the
756        // harness's `resolve_provider` (`provider.id() == model.provider`)
757        // match. Without this, a `gateway/custom-claude` model would carry
758        // `provider = "gateway"` and the run would fail with "No provider
759        // registered for 'gateway'". Divergence documented in
760        // `docs/m6-cli-open-questions.md`.
761        let runtime_provider = match api {
762            Api::OpenaiCompletions => provider_id,
763            _ => DEFAULT_PROVIDER_ID,
764        };
765        let mut m = Model::new(
766            def.id.clone(),
767            name,
768            api.clone(),
769            runtime_provider.to_string(),
770            base_url,
771        );
772        m.reasoning = def.reasoning.unwrap_or(false);
773        m.context_window = def.context_window.unwrap_or(0);
774        m.max_tokens = def.max_tokens.unwrap_or(0);
775        m.input = parse_input_modalities(def.input.as_deref());
776        // Merge: model-level headers, then provider-level headers (provider wins
777        // on conflict — it's the more specific-to-this-endpoint declaration).
778        // Values are resolved via `resolve_headers` (`$ENV`/`!command` expansion,
779        // mirroring pi's `resolveHeadersOrThrow`) so a copied pi models.json
780        // referencing env vars / commands resolves the same way. Models.json
781        // providers have no credential env overlay (only auth.json keys do), so
782        // the expansion is env-only here.
783        // NOTE: the `authHeader:true` Bearer synthesis is NOT done here —
784        // [`crate::provider::resolve`] applies it centrally so it can skip it
785        // when a higher-priority x-api-key source (`--api-key` / auth.json /
786        // `ANTHROPIC_API_KEY`) wins. Folding it here unconditionally would put a
787        // Bearer on the model even on the x-api-key path. See
788        // `models_json_bearer_token` + the fold loop in `resolve`.
789        let mut headers: BTreeMap<String, String> = BTreeMap::new();
790        if let Some(h) = def.headers.clone() {
791            for (k, v) in resolve_headers(&h, None) {
792                headers.insert(k, v);
793            }
794        }
795        if let Some(h) = cfg.headers.clone() {
796            for (k, v) in resolve_headers(&h, None) {
797                headers.insert(k, v);
798            }
799        }
800        if matches!(api, Api::OpenaiCompletions) {
801            if let Some(key) = cfg
802                .api_key
803                .as_deref()
804                .filter(|key| !key.is_empty())
805                .and_then(|key| resolve_config_value(key, None))
806            {
807                headers.retain(|name, _| !name.eq_ignore_ascii_case("authorization"));
808                headers.insert("authorization".to_string(), format!("Bearer {key}"));
809            }
810            if let Some(value) = def.compat.clone() {
811                if let Ok(compat) = serde_json::from_value(value) {
812                    m.compat = Some(StreamingProtocolCompat::OpenaiCompletions(compat));
813                }
814            }
815        }
816        if !headers.is_empty() {
817            m.headers = Some(headers);
818        }
819        merged.push(m);
820    }
821    Some(merged)
822}
823
824/// Parse `["text","image"]`-style modality strings into [`InputModality`]s;
825/// unknown values drop to text-only. `None` ⇒ text (the [`Model::new`] default).
826fn parse_input_modalities(input: Option<&[String]>) -> Vec<InputModality> {
827    match input {
828        None => vec![InputModality::Text],
829        Some(list) if list.is_empty() => vec![InputModality::Text],
830        Some(list) => list
831            .iter()
832            .filter_map(|s| match s.to_ascii_lowercase().as_str() {
833                "text" => Some(InputModality::Text),
834                "image" => Some(InputModality::Image),
835                _ => None,
836            })
837            .collect::<Vec<_>>()
838            .pipe(|v| {
839                if v.is_empty() {
840                    vec![InputModality::Text]
841                } else {
842                    v
843                }
844            }),
845    }
846}
847
848/// The first-party Anthropic endpoint — used as the fallback `base_url` when a
849/// models.json provider omits it. Kept here (not imported from `rpi_ai`) so the
850/// config layer never depends on the provider's private `models` module.
851/// Public so [`crate::provider::resolve`] can tell a gateway model (whose
852/// `base_url` differs from this) from a built-in Anthropic model.
853pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
854
855/// Same value as [`ANTHROPIC_DEFAULT_BASE_URL`], as an owned `String` for the
856/// `unwrap_or_else` ergonomic used by [`provider_to_models`] and
857/// [`crate::provider::models_json_provider_auth`].
858pub fn default_anthropic_base_url() -> String {
859    ANTHROPIC_DEFAULT_BASE_URL.to_string()
860}
861
862// ---------------------------------------------------------------------------
863// Internals: dir ensure, atomic write, chmod
864// ---------------------------------------------------------------------------
865
866#[cfg(unix)]
867use std::os::unix::fs::PermissionsExt;
868
869/// Create the config dir if missing. Mode 0o700 on Unix (mkdir default on
870/// Windows, where the sticky-permission concept doesn't apply).
871fn ensure_dir(dir: &Path) -> Result<(), ConfigError> {
872    if dir.exists() {
873        return Ok(());
874    }
875    std::fs::create_dir_all(dir).map_err(|e| ConfigError::Write {
876        path: dir.to_path_buf(),
877        source: e,
878    })?;
879    #[cfg(unix)]
880    {
881        let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
882    }
883    Ok(())
884}
885
886/// Write `bytes` to `path` atomically: a temp sibling → `rename`. The temp
887/// file lives next to the target so the rename stays on one filesystem.
888fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
889    let dir = path.parent().ok_or_else(|| ConfigError::Write {
890        path: path.to_path_buf(),
891        source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent"),
892    })?;
893    let tmp = dir.join(format!(
894        ".{}.tmp",
895        path.file_name().and_then(|n| n.to_str()).unwrap_or("rpi")
896    ));
897    std::fs::write(&tmp, bytes).map_err(|e| ConfigError::Write {
898        path: tmp.clone(),
899        source: e,
900    })?;
901    std::fs::rename(&tmp, path).map_err(|e| ConfigError::Write {
902        path: path.to_path_buf(),
903        source: e,
904    })?;
905    Ok(())
906}
907
908/// Best-effort tighten to owner-only (0o600). No-op on Windows (the Node
909/// upstream applies no ACL either).
910fn set_owner_only(_path: &Path) {
911    #[cfg(unix)]
912    {
913        let _ = std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600));
914    }
915}
916
917// A tiny `.pipe`-shim so the `parse_input_modalities` chain reads top-to-bottom
918// without pulling itertools. Kept private to this module.
919trait Pipe: Sized {
920    fn pipe<R>(self, f: impl FnOnce(Self) -> R) -> R {
921        f(self)
922    }
923}
924impl<T> Pipe for T {}
925
926// ---------------------------------------------------------------------------
927// Tests
928// ---------------------------------------------------------------------------
929
930#[cfg(test)]
931pub(crate) mod test_support {
932    /// A shared workspace lock for tests that touch process-global env vars
933    /// (`RPI_CODING_AGENT_DIR`, `ANTHROPIC_*`). All env-mutating tests across
934    /// the crate (config / provider / auth) share this ONE mutex so they can't
935    /// race on the shared environment. Hold the returned guard for the whole
936    /// test (store it in a RAII struct).
937    use std::sync::{Mutex, OnceLock};
938    pub(crate) fn env_lock() -> &'static Mutex<()> {
939        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
940        LOCK.get_or_init(|| Mutex::new(()))
941    }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947    use crate::config::test_support::env_lock;
948
949    /// Point `RPI_CODING_AGENT_DIR` at a fresh temp dir for the duration of the
950    /// test (cleaned up on drop). Holds the prior value of the env var to
951    /// restore it. Hold the env lock for its whole lifetime.
952    struct TempConfig {
953        _guard: std::sync::MutexGuard<'static, ()>,
954        _tmp: tempfile::TempDir,
955        prev: Option<std::ffi::OsString>,
956    }
957    impl TempConfig {
958        fn new() -> Self {
959            let guard = env_lock().lock().unwrap();
960            let prev = std::env::var_os(CONFIG_DIR_ENV);
961            let tmp = tempfile::TempDir::new().unwrap();
962            std::env::set_var(CONFIG_DIR_ENV, tmp.path());
963            Self {
964                _guard: guard,
965                _tmp: tmp,
966                prev,
967            }
968        }
969    }
970    impl Drop for TempConfig {
971        fn drop(&mut self) {
972            restore_env(CONFIG_DIR_ENV, self.prev.take());
973        }
974    }
975
976    #[test]
977    fn read_auth_missing_file_is_empty() {
978        let _cfg = TempConfig::new();
979        let store = read_auth().unwrap();
980        assert!(store.is_empty());
981    }
982
983    #[test]
984    fn upsert_then_read_roundtrip() {
985        let _cfg = TempConfig::new();
986        upsert_credential(
987            "anthropic",
988            Credential::ApiKey {
989                key: Some("sk-test-123".into()),
990                env: None,
991            },
992        )
993        .unwrap();
994        let store = read_auth().unwrap();
995        match store.get("anthropic") {
996            Some(Credential::ApiKey { key, .. }) => assert_eq!(key.as_deref(), Some("sk-test-123")),
997            other => panic!("unexpected cred: {other:?}"),
998        }
999        // The file should exist and be JSON.
1000        let path = auth_path().unwrap();
1001        assert!(path.exists(), "auth.json should exist after upsert");
1002        let raw = std::fs::read_to_string(&path).unwrap();
1003        assert!(raw.contains("\"anthropic\""));
1004        assert!(raw.contains("api_key"));
1005    }
1006
1007    #[test]
1008    fn delete_credential_removes_entry() {
1009        let _cfg = TempConfig::new();
1010        upsert_credential(
1011            "anthropic",
1012            Credential::ApiKey {
1013                key: Some("k".into()),
1014                env: None,
1015            },
1016        )
1017        .unwrap();
1018        assert!(delete_credential("anthropic").unwrap());
1019        // Second delete is a no-op.
1020        assert!(!delete_credential("anthropic").unwrap());
1021        assert!(read_auth().unwrap().is_empty());
1022    }
1023
1024    #[test]
1025    fn load_models_config_missing_is_empty() {
1026        let _cfg = TempConfig::new();
1027        let c = load_models_config().unwrap();
1028        assert!(c.providers.is_empty());
1029    }
1030
1031    #[test]
1032    fn load_models_config_parses_with_comments() {
1033        let _cfg = TempConfig::new();
1034        let json = r#"{
1035  // a one-api style gateway
1036  "providers": {
1037    "gateway": {
1038      "baseUrl": "https://gw.example.com",
1039      "authHeader": true,
1040      "apiKey": "gw-secret",
1041      "models": [
1042        { "id": "claude-sonnet-5", "name": "Sonnet via gateway" }
1043      ]
1044    }
1045  }
1046}"#;
1047        std::fs::write(models_path().unwrap(), json).unwrap();
1048        let c = load_models_config().unwrap();
1049        let gw = c
1050            .providers
1051            .get("gateway")
1052            .expect("gateway provider present");
1053        assert_eq!(gw.base_url.as_deref(), Some("https://gw.example.com"));
1054        assert!(gw.auth_header.unwrap_or(false));
1055        assert_eq!(gw.models.len(), 1);
1056        assert_eq!(gw.models[0].id, "claude-sonnet-5");
1057    }
1058
1059    #[test]
1060    fn provider_to_models_merges_headers_without_synth_bearer() {
1061        // `provider_to_models` merges model-level then provider-level headers,
1062        // but does NOT synthesize the `authHeader:true` Bearer itself — that
1063        // happens centrally in `crate::provider::resolve` (via
1064        // `models_json_bearer_token`) so it can be skipped on the x-api-key
1065        // path. Here the model carries only what the file declared.
1066        let cfg = ProviderConfig {
1067            name: None,
1068            base_url: Some("https://gw.example.com".into()),
1069            api_key: Some("gw-secret".into()),
1070            api: None,
1071            headers: Some({
1072                let mut h = BTreeMap::new();
1073                h.insert("x-portkey-key".into(), "portkey-secret".into());
1074                h
1075            }),
1076            auth_header: Some(true),
1077            models: vec![ModelDefinition {
1078                id: "claude-sonnet-5".into(),
1079                name: None,
1080                base_url: None,
1081                reasoning: None,
1082                context_window: None,
1083                max_tokens: None,
1084                input: None,
1085                headers: None,
1086                compat: None,
1087            }],
1088        };
1089        let models = provider_to_models("gateway", &cfg).expect("anthropic-compatible");
1090        assert_eq!(models.len(), 1);
1091        let m = &models[0];
1092        assert_eq!(m.id, "claude-sonnet-5");
1093        assert_eq!(m.base_url, "https://gw.example.com");
1094        // v1 stamps `provider = "anthropic"` on every models.json model so the
1095        // single AnthropicProvider routes it (the models.json provider id is
1096        // config-namespacing only).
1097        assert_eq!(m.provider, DEFAULT_PROVIDER_ID);
1098        let headers = m.headers.as_ref().expect("provider headers merged");
1099        // Declared provider header folds in…
1100        assert_eq!(
1101            headers.get("x-portkey-key").map(|s| s.as_str()),
1102            Some("portkey-secret")
1103        );
1104        // …but no Bearer is synthesized here. The bearer-from-authHeader path
1105        // is exercised end-to-end by the provider.rs `resolve` tests
1106        // (`models_json_auth_header_satisfies_auth_without_env`,
1107        // `api_key_flag_beats_models_json_bearer`).
1108        assert!(
1109            headers.get("authorization").is_none(),
1110            "provider_to_models must not synthesize the Bearer; resolve does"
1111        );
1112    }
1113
1114    #[test]
1115    fn provider_to_models_supports_openai_completions() {
1116        let config: ModelsConfig = serde_json::from_str(
1117            r#"{
1118                "providers": {
1119                    "oai": {
1120                        "api": "openai-completions",
1121                        "baseUrl": "https://gateway.example.com/v1",
1122                        "apiKey": "secret",
1123                        "models": [{"id":"gpt-test","maxTokens":4096}]
1124                    }
1125                }
1126            }"#,
1127        )
1128        .unwrap();
1129        let models = provider_to_models("oai", &config.providers["oai"]).unwrap();
1130        assert_eq!(models.len(), 1);
1131        assert_eq!(models[0].api, Api::OpenaiCompletions);
1132        assert_eq!(models[0].provider, "oai");
1133        assert_eq!(models[0].max_tokens, 4096);
1134        assert_eq!(
1135            models[0]
1136                .headers
1137                .as_ref()
1138                .and_then(|headers| headers.get("authorization"))
1139                .map(String::as_str),
1140            Some("Bearer secret")
1141        );
1142    }
1143
1144    #[test]
1145    fn malformed_auth_json_is_an_error_not_silent_empty() {
1146        let _cfg = TempConfig::new();
1147        std::fs::write(auth_path().unwrap(), "{ not json").unwrap();
1148        assert!(matches!(read_auth(), Err(ConfigError::Json { .. })));
1149    }
1150
1151    #[test]
1152    fn agent_dir_nests_under_agent_by_default() {
1153        // With no env override, agent_dir() must end in `.../.rpi/agent`
1154        // (mirrors pi's `getAgentDir`). We can't assertion the home prefix
1155        // portably, but the leaf two segments are stable.
1156        let _guard = env_lock().lock().unwrap();
1157        let prev = std::env::var_os(CONFIG_DIR_ENV);
1158        std::env::remove_var(CONFIG_DIR_ENV);
1159        let dir = agent_dir().unwrap();
1160        restore_env(CONFIG_DIR_ENV, prev);
1161        assert!(dir.ends_with("agent"));
1162        assert!(dir
1163            .parent()
1164            .map(|p| p.ends_with(CONFIG_DIR_NAME))
1165            .unwrap_or(false));
1166    }
1167
1168    #[test]
1169    fn migrate_legacy_layout_moves_flat_files_into_agent() {
1170        // Drive the core migration directly against a temp root/agent so the
1171        // result is independent of whatever RPI_CODING_AGENT_DIR the parallel
1172        // TempConfig tests happen to set.
1173        let tmp = tempfile::TempDir::new().unwrap();
1174        let root = tmp.path().to_path_buf();
1175        let agent = root.join("agent");
1176        std::fs::write(root.join("auth.json"), "{}").unwrap();
1177        std::fs::write(root.join("models.json"), "{}").unwrap();
1178        std::fs::write(root.join(".setup_done"), "1").unwrap();
1179        let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1180        assert_eq!(moved, 3);
1181        assert!(agent.join("auth.json").exists());
1182        assert!(agent.join("models.json").exists());
1183        assert!(agent.join(".setup_done").exists());
1184        assert!(!root.join("auth.json").exists());
1185    }
1186
1187    #[test]
1188    fn migrate_legacy_layout_noop_when_agent_exists() {
1189        let tmp = tempfile::TempDir::new().unwrap();
1190        let root = tmp.path().to_path_buf();
1191        let agent = root.join("agent");
1192        std::fs::write(root.join("auth.json"), "{}").unwrap();
1193        std::fs::create_dir_all(&agent).unwrap();
1194        let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1195        assert_eq!(moved, 0); // agent/ already present — leave flat file alone
1196    }
1197
1198    #[test]
1199    fn migrate_legacy_layout_noop_when_no_flat_files() {
1200        let tmp = tempfile::TempDir::new().unwrap();
1201        let root = tmp.path().to_path_buf();
1202        let agent = root.join("agent");
1203        let moved = migrate_legacy_layout_in(&root, &agent).unwrap();
1204        assert_eq!(moved, 0);
1205    }
1206
1207    #[test]
1208    fn migrate_legacy_layout_public_skips_env_override() {
1209        // When RPI_CODING_AGENT_DIR is set, the public entry point is a no-op
1210        // (it must never touch an explicit override). TempConfig sets it.
1211        let _cfg = TempConfig::new();
1212        let moved = migrate_legacy_layout().unwrap();
1213        assert_eq!(moved, 0);
1214    }
1215
1216    #[test]
1217    fn read_trust_missing_file_is_empty() {
1218        let _cfg = TempConfig::new();
1219        assert!(read_trust().unwrap().is_empty());
1220    }
1221
1222    #[test]
1223    fn read_trust_parses_decisions() {
1224        let _cfg = TempConfig::new();
1225        let path = trust_path().unwrap();
1226        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1227        std::fs::write(
1228            &path,
1229            r#"{ "/home/me/proj": true, "/home/me/untrusted": false, "/home/me/null": null }"#,
1230        )
1231        .unwrap();
1232        let store = read_trust().unwrap();
1233        assert_eq!(store.len(), 3);
1234        assert_eq!(store.get("/home/me/proj").copied().flatten(), Some(true));
1235        assert_eq!(
1236            store.get("/home/me/untrusted").copied().flatten(),
1237            Some(false)
1238        );
1239        assert_eq!(store.get("/home/me/null").copied().flatten(), None);
1240    }
1241
1242    #[test]
1243    fn resolve_config_value_literal_passthrough() {
1244        assert_eq!(
1245            resolve_config_value("sk-literal-key", None),
1246            Some("sk-literal-key".into())
1247        );
1248    }
1249
1250    #[test]
1251    fn resolve_config_value_env_var() {
1252        let _guard = env_lock().lock().unwrap();
1253        let prev = std::env::var_os("RPI_TEST_CFG_KEY");
1254        let prev2 = std::env::var_os("RPI_TEST_CFG_KEY2");
1255        std::env::set_var("RPI_TEST_CFG_KEY", "secret-from-env");
1256        assert_eq!(
1257            resolve_config_value("$RPI_TEST_CFG_KEY", None),
1258            Some("secret-from-env".into())
1259        );
1260        assert_eq!(
1261            resolve_config_value("prefix-${RPI_TEST_CFG_KEY}-suffix", None),
1262            Some("prefix-secret-from-env-suffix".into())
1263        );
1264        // Two vars in one template.
1265        std::env::set_var("RPI_TEST_CFG_KEY2", "two");
1266        assert_eq!(
1267            resolve_config_value("a-$RPI_TEST_CFG_KEY-b-$RPI_TEST_CFG_KEY2-c", None),
1268            Some("a-secret-from-env-b-two-c".into())
1269        );
1270        // Env overlay wins over process env.
1271        let mut overlay = BTreeMap::new();
1272        overlay.insert("RPI_TEST_CFG_KEY".into(), "overlay-value".into());
1273        assert_eq!(
1274            resolve_config_value("$RPI_TEST_CFG_KEY", Some(&overlay)),
1275            Some("overlay-value".into())
1276        );
1277        restore_env("RPI_TEST_CFG_KEY", prev);
1278        restore_env("RPI_TEST_CFG_KEY2", prev2);
1279    }
1280
1281    #[test]
1282    fn resolve_config_value_unset_env_is_none() {
1283        let _guard = env_lock().lock().unwrap();
1284        let prev = std::env::var_os("RPI_TEST_CFG_ABSENT");
1285        std::env::remove_var("RPI_TEST_CFG_ABSENT");
1286        // Any referenced unset var ⇒ the whole value is None (pi semantics).
1287        assert_eq!(resolve_config_value("$RPI_TEST_CFG_ABSENT", None), None);
1288        assert_eq!(
1289            resolve_config_value("prefix-$RPI_TEST_CFG_ABSENT-suffix", None),
1290            None
1291        );
1292        restore_env("RPI_TEST_CFG_ABSENT", prev);
1293    }
1294
1295    #[test]
1296    fn resolve_config_value_dollar_dollar_escapes_literal() {
1297        assert_eq!(
1298            resolve_config_value("price-$$5", None),
1299            Some("price-$5".into())
1300        );
1301        assert_eq!(resolve_config_value("$!bang", None), Some("!bang".into()));
1302    }
1303
1304    #[test]
1305    fn resolve_config_value_command_runs_shell() {
1306        // `!echo resolved` → "resolved" (sh on Unix; `echo` works under cmd too).
1307        assert_eq!(
1308            resolve_config_value_uncached("!echo rpi-cfg-resolved", None),
1309            Some("rpi-cfg-resolved".into())
1310        );
1311        // Non-zero exit ⇒ None.
1312        assert_eq!(resolve_config_value_uncached("!false", None), None);
1313    }
1314
1315    #[test]
1316    fn resolve_headers_drops_unresolvable() {
1317        let _guard = env_lock().lock().unwrap();
1318        let prev = std::env::var_os("RPI_TEST_HDR_SET");
1319        std::env::set_var("RPI_TEST_HDR_SET", "set-value");
1320        let mut h = BTreeMap::new();
1321        h.insert("x-set".into(), "$RPI_TEST_HDR_SET".into());
1322        h.insert("x-unset".into(), "$RPI_TEST_HDR_UNSET".into());
1323        h.insert("x-literal".into(), "literal-value".into());
1324        let resolved = resolve_headers(&h, None);
1325        assert_eq!(resolved.len(), 2);
1326        assert_eq!(resolved.get("x-set").map(|s| s.as_str()), Some("set-value"));
1327        assert_eq!(
1328            resolved.get("x-literal").map(|s| s.as_str()),
1329            Some("literal-value")
1330        );
1331        assert!(!resolved.contains_key("x-unset"));
1332        restore_env("RPI_TEST_HDR_SET", prev);
1333    }
1334
1335    #[test]
1336    fn agent_dir_respects_env_override() {
1337        let _guard = env_lock().lock().unwrap();
1338        let prev = std::env::var_os(CONFIG_DIR_ENV);
1339        let tmp = tempfile::TempDir::new().unwrap();
1340        std::env::set_var(CONFIG_DIR_ENV, tmp.path());
1341        let dir = agent_dir().unwrap();
1342        restore_env(CONFIG_DIR_ENV, prev);
1343        assert_eq!(dir, tmp.path());
1344    }
1345
1346    #[test]
1347    fn relative_override_is_rejected() {
1348        let _guard = env_lock().lock().unwrap();
1349        let prev = std::env::var_os(CONFIG_DIR_ENV);
1350        std::env::set_var(CONFIG_DIR_ENV, "relative/path");
1351        let err = agent_dir().unwrap_err();
1352        restore_env(CONFIG_DIR_ENV, prev);
1353        assert!(matches!(err, ConfigError::RelativeOverride { .. }));
1354    }
1355
1356    /// Restore/remove an env var based on its prior `OsString` value.
1357    fn restore_env(name: &str, prev: Option<std::ffi::OsString>) {
1358        match prev {
1359            Some(v) => std::env::set_var(name, v),
1360            None => std::env::remove_var(name),
1361        }
1362    }
1363}