Skip to main content

rpi_cli/
config.rs

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