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