Skip to main content

cli/config/
mod.rs

1use crate::shells::ShellType;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6mod discovery;
7mod env_layer;
8mod load;
9mod save;
10
11use discovery::resolve_config_presets_path;
12pub(crate) use discovery::{
13    ReadOnlyRuntimePaths, discover_runtime_paths_read_only, find_project_config,
14};
15use env_layer::deserialize_env_values;
16
17pub use crate::home::{full_expand, full_expand_with_home, tilde_expand};
18pub use env_layer::{validate_env_override_file, write_env_override_entry};
19
20const GLOBAL_CONFIG_FILE: &str = "config.toml";
21const PROJECT_CONFIG_FILE: &str = "shine.config.toml";
22const PROJECT_ENV_FILE: &str = "shine.env.toml";
23
24pub const CURRENT_RUNTIME_SCHEMA_VERSION: u32 = 2;
25
26pub const DEFAULT_ENV_VARS: &[(&str, &str)] = &[
27    ("HTTP_PROXY_PORT", "6152"),
28    ("SOCKS5_PROXY_PORT", "6153"),
29    ("PROXY_HOST", "127.0.0.1"),
30    ("PROXY_NO_PROXY", "localhost,127.0.0.1,::1"),
31    ("GHOSTTY_BG_LIGHT", ""),
32    ("GHOSTTY_BG_DARK", ""),
33];
34
35pub fn default_env_map() -> BTreeMap<String, String> {
36    DEFAULT_ENV_VARS
37        .iter()
38        .map(|(k, v)| (k.to_string(), v.to_string()))
39        .collect()
40}
41
42fn default_sync_terminal_theme() -> bool {
43    true
44}
45
46fn is_true(value: &bool) -> bool {
47    *value
48}
49
50#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
51#[serde(rename_all = "kebab-case")]
52pub enum ExternalShellMode {
53    #[default]
54    Snapshot,
55    Live,
56}
57
58/// A command whose protected environment values are injected by a shine proxy.
59#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
60pub struct EnvProxyRule {
61    pub command: String,
62    #[serde(rename = "with")]
63    pub with: Vec<String>,
64    /// Whether the proxy injects its configured values. Disabled proxies still
65    /// forward to their recorded target without resolving any secret.
66    #[serde(default = "default_env_proxy_enabled", skip_serializing_if = "is_true")]
67    pub enabled: bool,
68}
69
70fn default_env_proxy_enabled() -> bool {
71    true
72}
73
74fn is_snapshot_mode(value: &ExternalShellMode) -> bool {
75    *value == ExternalShellMode::Snapshot
76}
77
78#[derive(Serialize, Deserialize, Clone, Debug)]
79pub struct Config {
80    /// Presets directory - computed at runtime, not serialized
81    #[serde(skip)]
82    presets_dir: PathBuf,
83    /// Bin directory for symlinks - computed from home
84    #[serde(skip)]
85    bin_dir: PathBuf,
86    /// Path to the active config file.
87    #[serde(skip)]
88    config_path: PathBuf,
89    /// True when this config was loaded from a project presets config.
90    #[serde(skip)]
91    is_project_config: bool,
92    /// Original sparse project table plus the effective config at load time.
93    /// Used to avoid materializing inherited global values when saving.
94    #[serde(skip)]
95    project_save_state: Option<ProjectSaveState>,
96    /// Directory used for shine runtime state.
97    #[serde(skip)]
98    shine_dir: PathBuf,
99    #[serde(skip)]
100    pub home_dir: PathBuf,
101    #[serde(default)]
102    pub schema_version: u32,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub last_cleared_schema_version: Option<u32>,
105    #[serde(skip)]
106    pub shell_type: ShellType,
107    /// Optional persistent presets_dir override stored in the active config.
108    /// Takes effect when neither SHINE_CONFIG_DIR nor SHINE_PRESETS is set.
109    #[serde(
110        rename = "presets_dir",
111        default,
112        skip_serializing_if = "Option::is_none"
113    )]
114    pub presets_dir_override: Option<PathBuf>,
115    /// Deployment policy for shell commands sourced from an external presets directory.
116    /// Snapshot mode is the safe default; live mode is an explicit preset-development opt-in.
117    #[serde(default, skip_serializing_if = "is_snapshot_mode")]
118    pub external_shell_mode: ExternalShellMode,
119    /// Optional overlay directory merged over the active presets source.
120    #[serde(
121        rename = "presets_overlay_dir",
122        default,
123        skip_serializing_if = "Option::is_none"
124    )]
125    pub presets_overlay_dir_override: Option<PathBuf>,
126    /// Optional Git URL for a shine-managed overlay. When set (and no explicit
127    /// `presets_overlay_dir` is configured), shine owns the overlay checkout at
128    /// `<shine_dir>/overlay`, cloning it `--depth 1` on `shine preset pull` and keeping
129    /// it as an always-latest mirror of the remote tip.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub presets_overlay_git: Option<String>,
132    /// Optional branch to track for `presets_overlay_git`. When unset, the
133    /// remote's default branch is used.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub presets_overlay_git_branch: Option<String>,
136    /// Resolved `<shine_dir>/overlay` path when `presets_overlay_git` is set.
137    /// Computed at load time, never serialized. `None` when no Git overlay is
138    /// configured.
139    #[serde(skip)]
140    managed_overlay_dir: Option<PathBuf>,
141    /// Optional override for the default destination root used by `shine app install`
142    /// when a preset file carries no `shine-dest:` annotation.
143    /// Defaults to `~/.config` when not set.
144    #[serde(
145        rename = "app_default_dest_root",
146        default,
147        skip_serializing_if = "Option::is_none"
148    )]
149    pub app_default_dest_root_override: Option<PathBuf>,
150    /// `true` when the presets directory is provided by the user (via env var or config
151    /// `presets_dir` key) rather than the default `~/.shine/presets/`.
152    /// When `true`, commands resolve desired presets from disk without extracting embedded
153    /// assets. Shell deployment then follows `external_shell_mode`.
154    #[serde(skip)]
155    pub is_external_presets: bool,
156    /// Allows app presets loaded from external preset directories to run post-upgrade hooks.
157    /// Embedded presets may run hooks without this opt-in.
158    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
159    pub allow_app_hooks: bool,
160    /// Global-only opt-in allowing external sys presets and overlays to execute install scripts or
161    /// install persistent shell-profile code. Declarative package providers,
162    /// detection, PATH, env, and aliases do not require this opt-in.
163    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
164    pub allow_sys_code: bool,
165    /// Whether the managed sys `pre` profile auto-syncs the terminal theme
166    /// (`shine theme sync --auto`) on interactive shell startup. Defaults to
167    /// `true`. The `SHINE_SYNC_TERMINAL_THEME` env var overrides this at
168    /// runtime regardless of value (docs/terminal-theme-sync-prd.md §5).
169    /// Deliberately not project-overridable: this is a terminal/session-level
170    /// toggle, not something that varies per project.
171    #[serde(
172        default = "default_sync_terminal_theme",
173        skip_serializing_if = "is_true"
174    )]
175    pub sync_terminal_theme: bool,
176    /// Path where `shine self install` last copied the binary.
177    /// When set, `shine self upgrade` will try to sync the new binary there automatically.
178    #[serde(
179        rename = "self_install_dest",
180        default,
181        skip_serializing_if = "Option::is_none"
182    )]
183    pub self_install_dest: Option<PathBuf>,
184    /// Default GPG recipients used by `shine env secret encrypt`/`seal` when
185    /// neither `-r/--recipient` nor a workspace GPG recipient list is given.
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub gpg_recipients: Vec<String>,
188    #[serde(
189        rename = "gpg_key_id",
190        default,
191        skip_serializing_if = "Option::is_none"
192    )]
193    pub legacy_gpg_key_id: Option<String>,
194    /// Selects the default [`crate::secret::BackendKind`] used by `shine env
195    /// encrypt`/`seal` when neither `-r/--recipient` nor a workspace backend
196    /// override is given. Absent means GPG. Decryption never consults this
197    /// field — it is resolved purely from the ciphertext's backend tag.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub secret_backend: Option<String>,
200    /// Default age recipients (`age1...` / `age1se1...`) used by `shine env
201    /// encrypt`/`seal` when the age backend is active and no `-r/--recipient`
202    /// is given. Encrypting to every team member's recipient lets any of them
203    /// decrypt the resulting ciphertext with their own identity.
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub age_recipients: Vec<String>,
206    /// Path to the age identity file used to decrypt `age:`-tagged secrets.
207    /// May contain multiple identities (e.g. a Secure Enclave identity plus a
208    /// plain fallback), one per line. Defaults to
209    /// `<shine_dir>/age/identity.txt` when unset and that file exists.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub age_identity: Option<String>,
212    /// Environment variables substituted into template-enabled presets.
213    #[serde(
214        default = "default_env_map",
215        deserialize_with = "deserialize_env_values"
216    )]
217    pub env: BTreeMap<String, String>,
218    /// Command-specific, explicitly allow-listed secret injection rules.
219    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220    pub env_proxy: Vec<EnvProxyRule>,
221    /// Per-variable descriptions read from detailed `[env]` entries.
222    #[serde(skip)]
223    pub env_descriptions: BTreeMap<String, String>,
224    /// Which env override file (if any) currently supplies each key's effective
225    /// value. Only override files populate this — `config.toml [env]` layers
226    /// never do, since a plain write there is always effective unless shadowed
227    /// by one of these. Used to detect when `env set`/`encrypt`/`delete` would
228    /// otherwise silently write a value that an override file keeps shadowing.
229    #[serde(skip)]
230    pub env_override_sources: BTreeMap<String, EnvOverrideSource>,
231}
232
233/// Identifies the override file (global/overlay/project `shine.env.toml`) that
234/// currently supplies a given env key's effective value, if any.
235#[derive(Clone, Debug, PartialEq, Eq)]
236pub struct EnvOverrideSource {
237    pub path: PathBuf,
238    /// Which override layer `path` belongs to. Drives the source labels in
239    /// `shine env list`; `is_managed_overlay` further distinguishes the two
240    /// `Overlay` variants.
241    pub kind: EnvOverrideKind,
242    /// `true` when `path` is inside the shine-managed Git overlay checkout
243    /// (force-mirrored, read-only per ADR 0010) rather than the global/project
244    /// override file or a manual overlay directory.
245    pub is_managed_overlay: bool,
246}
247
248/// Which override-file layer supplied an env key's effective value. Ordered
249/// low-to-high by precedence (a later layer shadows an earlier one), matching
250/// the apply order in `Config::load_or_init`.
251#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252pub enum EnvOverrideKind {
253    /// Global `<shine_dir>/shine.env.toml`.
254    Global,
255    /// Overlay `<overlay_dir>/shine.env.toml` (managed-git or manual overlay).
256    Overlay,
257    /// Project `<project_root>/shine.env.toml`.
258    Project,
259}
260
261#[derive(Clone, Debug)]
262struct ProjectSaveState {
263    original: toml::Table,
264    loaded: toml::Table,
265}
266
267impl Config {
268    pub fn presets_dir(&self) -> &Path {
269        &self.presets_dir
270    }
271
272    pub fn bin_dir(&self) -> &Path {
273        &self.bin_dir
274    }
275
276    pub fn shine_dir(&self) -> &Path {
277        &self.shine_dir
278    }
279
280    pub fn config_path(&self) -> &Path {
281        &self.config_path
282    }
283
284    /// Global configuration path, even when a project configuration is active.
285    pub fn global_config_path(&self) -> PathBuf {
286        self.shine_dir.join(GLOBAL_CONFIG_FILE)
287    }
288
289    /// Whether configuration was discovered from a project `shine.config.toml`.
290    pub fn is_project_config(&self) -> bool {
291        self.is_project_config
292    }
293
294    /// Directory where template-rendered shell scripts are written.
295    /// Always inside shine_dir so it is never confused with user-owned presets.
296    pub fn rendered_dir(&self) -> PathBuf {
297        self.shine_dir().join("rendered")
298    }
299
300    /// Shine-owned snapshots of external shell categories.
301    pub fn installed_shell_dir(&self) -> PathBuf {
302        self.shine_dir().join("installed").join("shell")
303    }
304
305    pub fn app_default_dest_root(&self) -> PathBuf {
306        match &self.app_default_dest_root_override {
307            Some(p) => {
308                let s = p.to_str().unwrap_or("~/.config");
309                PathBuf::from(tilde_expand(s))
310            }
311            None => self.home_dir.join(".config"),
312        }
313    }
314
315    pub fn new_for_test(dir: &Path) -> Self {
316        Self {
317            config_path: dir.join("config.toml"),
318            is_project_config: false,
319            project_save_state: None,
320            shine_dir: dir.to_path_buf(),
321            presets_dir: dir.join("presets"),
322            bin_dir: dir.join("bin"),
323            home_dir: dir.to_path_buf(),
324            schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
325            last_cleared_schema_version: None,
326            shell_type: ShellType::default(),
327            presets_dir_override: None,
328            external_shell_mode: ExternalShellMode::Snapshot,
329            presets_overlay_dir_override: None,
330            presets_overlay_git: None,
331            presets_overlay_git_branch: None,
332            managed_overlay_dir: None,
333            app_default_dest_root_override: None,
334            is_external_presets: false,
335            allow_app_hooks: false,
336            allow_sys_code: false,
337            sync_terminal_theme: default_sync_terminal_theme(),
338            self_install_dest: None,
339            gpg_recipients: Vec::new(),
340            legacy_gpg_key_id: None,
341            secret_backend: None,
342            age_recipients: Vec::new(),
343            age_identity: None,
344            env: default_env_map(),
345            env_proxy: Vec::new(),
346            env_descriptions: BTreeMap::new(),
347            env_override_sources: BTreeMap::new(),
348        }
349    }
350
351    /// Age identity file(s) used to decrypt `age:`-tagged secrets, resolved
352    /// from `age_identity` (tilde-expanded) or, when unset, the default path
353    /// under `shine_dir` if it exists.
354    pub fn age_identities(&self) -> Vec<PathBuf> {
355        if let Some(identity) = self
356            .age_identity
357            .as_deref()
358            .map(str::trim)
359            .filter(|value| !value.is_empty())
360        {
361            return vec![PathBuf::from(tilde_expand(identity))];
362        }
363        let default_path = self.shine_dir.join("age").join("identity.txt");
364        if default_path.is_file() {
365            vec![default_path]
366        } else {
367            Vec::new()
368        }
369    }
370
371    /// Return a clone of this config with `presets_dir_override` replaced.
372    pub fn with_presets_dir_override(self, value: Option<PathBuf>) -> Self {
373        Self {
374            presets_dir_override: value,
375            ..self
376        }
377    }
378
379    pub fn with_external_shell_mode(self, value: ExternalShellMode) -> Self {
380        Self {
381            external_shell_mode: value,
382            ..self
383        }
384    }
385
386    /// Return a clone of this config with the Git-managed overlay source
387    /// replaced. Setting a URL clears any manual `presets_overlay_dir` override
388    /// so the two overlay modes never coexist; clearing the URL leaves the
389    /// managed checkout on disk untouched.
390    pub fn with_presets_overlay_git(self, url: Option<String>, branch: Option<String>) -> Self {
391        let managed_overlay_dir = url.as_ref().map(|_| self.shine_dir.join("overlay"));
392        Self {
393            presets_overlay_dir_override: if url.is_some() {
394                None
395            } else {
396                self.presets_overlay_dir_override
397            },
398            presets_overlay_git_branch: branch,
399            presets_overlay_git: url,
400            managed_overlay_dir,
401            ..self
402        }
403    }
404
405    /// Return a clone of this config with `presets_overlay_dir_override` replaced.
406    pub fn with_presets_overlay_dir_override(self, value: Option<PathBuf>) -> Self {
407        Self {
408            presets_overlay_dir_override: value,
409            ..self
410        }
411    }
412
413    /// Which override file (if any) currently supplies `key`'s effective value.
414    /// `None` means the key resolves purely from `config.toml [env]` (global or
415    /// project), so writing there via `env set`/`encrypt`/`delete` is effective.
416    pub fn env_override_source(&self, key: &str) -> Option<&EnvOverrideSource> {
417        self.env_override_sources.get(key)
418    }
419
420    pub fn active_presets_overlay_dir(&self) -> Option<&Path> {
421        if let Some(dir) = self.presets_overlay_dir_override.as_deref() {
422            return Some(dir);
423        }
424        // A Git-managed overlay only counts as active once its checkout exists
425        // on disk. Until the first `shine preset pull` clones it, resolution falls back
426        // to the base presets source.
427        self.managed_overlay_dir
428            .as_deref()
429            .filter(|dir| dir.exists())
430    }
431
432    /// Git source for a shine-managed overlay, if configured: `(url, branch,
433    /// managed_dir)`. Returned regardless of whether the checkout exists yet,
434    /// so `shine preset pull` can clone it on first use.
435    pub fn overlay_git_source(&self) -> Option<(&str, Option<&str>, &Path)> {
436        let url = self.presets_overlay_git.as_deref()?;
437        let dir = self.managed_overlay_dir.as_deref()?;
438        Some((url, self.presets_overlay_git_branch.as_deref(), dir))
439    }
440
441    /// Resolve a preset file with the overlay taking precedence over the base source.
442    pub fn preset_path(&self, relative: impl AsRef<Path>) -> PathBuf {
443        let relative = relative.as_ref();
444        if let Some(overlay) = self.active_presets_overlay_dir() {
445            let candidate = overlay.join(relative);
446            if candidate.exists() {
447                return candidate;
448            }
449        }
450        self.presets_dir().join(relative)
451    }
452
453    fn resolve_presets_overlay_dir(&mut self, config_dir: &Path) {
454        if let Some(path) = self.presets_overlay_dir_override.as_deref() {
455            self.presets_overlay_dir_override = Some(resolve_config_presets_path(path, config_dir));
456        }
457    }
458
459    /// Populate `managed_overlay_dir` from `presets_overlay_git`. Must be called
460    /// after `shine_dir` is resolved. The managed overlay always lives at
461    /// `<shine_dir>/overlay` so it follows `SHINE_CONFIG_DIR` automatically.
462    fn resolve_managed_overlay_dir(&mut self) {
463        self.managed_overlay_dir = self
464            .presets_overlay_git
465            .as_ref()
466            .map(|_| self.shine_dir.join("overlay"));
467    }
468}
469
470/// Print the effective base preset source, optional overlay, and external
471/// shell deployment mode. This makes built-in, external, and overlay-backed
472/// runs report provenance with the same vocabulary.
473pub fn print_presets_note(config: &Config) {
474    if config.is_external_presets {
475        println!(
476            "{}",
477            crate::colors::external_presets_note(config.presets_dir())
478        );
479        if let Some(dir) = config.active_presets_overlay_dir() {
480            println!("{}", crate::colors::presets_overlay_note(dir));
481        }
482        let deployment = match config.external_shell_mode {
483            ExternalShellMode::Snapshot => "snapshot · changes require `shine upgrade`",
484            ExternalShellMode::Live => "live · content applies on next invocation",
485        };
486        println!(
487            "{}",
488            crate::colors::dim(&crate::colors::shell_deployment_note(deployment))
489        );
490        println!();
491    } else {
492        println!("{}", crate::colors::presets_source_note("built-in"));
493        if let Some(dir) = config.active_presets_overlay_dir() {
494            println!("{}", crate::colors::presets_overlay_note(dir));
495        }
496        println!();
497    }
498}
499
500impl Default for Config {
501    fn default() -> Self {
502        let home_dir = crate::home::effective_home_dir();
503        let shine_dir = home_dir.join(".shine");
504
505        Self {
506            presets_dir: shine_dir.join("presets"),
507            bin_dir: shine_dir.join("bin"),
508            config_path: shine_dir.join("config.toml"),
509            is_project_config: false,
510            project_save_state: None,
511            shine_dir,
512            home_dir,
513            schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
514            last_cleared_schema_version: None,
515            shell_type: ShellType::default(),
516            presets_dir_override: None,
517            external_shell_mode: ExternalShellMode::Snapshot,
518            presets_overlay_dir_override: None,
519            presets_overlay_git: None,
520            presets_overlay_git_branch: None,
521            managed_overlay_dir: None,
522            app_default_dest_root_override: None,
523            is_external_presets: false,
524            allow_app_hooks: false,
525            allow_sys_code: false,
526            sync_terminal_theme: default_sync_terminal_theme(),
527            self_install_dest: None,
528            gpg_recipients: Vec::new(),
529            legacy_gpg_key_id: None,
530            secret_backend: None,
531            age_recipients: Vec::new(),
532            age_identity: None,
533            env: default_env_map(),
534            env_proxy: Vec::new(),
535            env_descriptions: BTreeMap::new(),
536            env_override_sources: BTreeMap::new(),
537        }
538    }
539}
540
541#[cfg(test)]
542pub(super) mod test_util {
543    use super::Config;
544    use std::path::{Path, PathBuf};
545
546    /// Config rooted in `dir` with a separate `home` subdirectory, mirroring
547    /// the layout most config tests expect. Distinct from
548    /// `crate::test_support::test_config`, which roots `home_dir` at `dir`
549    /// itself — do not merge the two.
550    pub(crate) fn config_in(dir: &Path) -> Config {
551        Config {
552            home_dir: dir.join("home"),
553            ..Config::new_for_test(dir)
554        }
555    }
556
557    pub(crate) async fn make_temp_dir() -> PathBuf {
558        crate::test_support::make_temp_dir("shine-test").await
559    }
560
561    pub(crate) fn restore_current_dir(dir: &Path) {
562        crate::test_support::restore_current_dir(dir)
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    #[test]
571    fn new_for_test_bin_dir_is_under_root() {
572        let dir = std::env::temp_dir().join("shine-test-bin-dir");
573        let config = Config::new_for_test(&dir);
574        assert_eq!(config.bin_dir(), dir.join("bin"));
575    }
576
577    #[test]
578    fn git_overlay_is_inactive_until_checkout_exists() {
579        let dir = std::env::temp_dir().join(format!("shine-overlay-git-{}", uuid::Uuid::new_v4()));
580        let config = Config::new_for_test(&dir)
581            .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None);
582
583        // The source is recorded regardless of on-disk state.
584        let (url, branch, managed) = config.overlay_git_source().unwrap();
585        assert_eq!(url, "https://example.com/o.git");
586        assert_eq!(branch, None);
587        assert_eq!(managed, dir.join("overlay"));
588
589        // But the overlay only becomes active once its checkout exists on disk.
590        assert!(config.active_presets_overlay_dir().is_none());
591        std::fs::create_dir_all(dir.join("overlay")).unwrap();
592        assert_eq!(
593            config.active_presets_overlay_dir(),
594            Some(dir.join("overlay").as_path())
595        );
596
597        std::fs::remove_dir_all(&dir).unwrap();
598    }
599
600    #[test]
601    fn manual_overlay_path_takes_precedence_over_git() {
602        let dir = std::env::temp_dir().join(format!("shine-overlay-prec-{}", uuid::Uuid::new_v4()));
603        std::fs::create_dir_all(dir.join("overlay")).unwrap();
604        let manual = dir.join("manual");
605        let config = Config::new_for_test(&dir)
606            .with_presets_overlay_git(Some("https://example.com/o.git".to_string()), None)
607            .with_presets_overlay_dir_override(Some(manual.clone()));
608
609        assert_eq!(config.active_presets_overlay_dir(), Some(manual.as_path()));
610
611        std::fs::remove_dir_all(&dir).unwrap();
612    }
613
614    #[test]
615    fn setting_git_overlay_clears_manual_path() {
616        let dir = std::env::temp_dir().join("shine-overlay-clear");
617        let config = Config::new_for_test(&dir)
618            .with_presets_overlay_dir_override(Some(dir.join("manual")))
619            .with_presets_overlay_git(
620                Some("https://example.com/o.git".to_string()),
621                Some("dev".to_string()),
622            );
623
624        assert!(config.presets_overlay_dir_override.is_none());
625        assert_eq!(
626            config.presets_overlay_git.as_deref(),
627            Some("https://example.com/o.git")
628        );
629        assert_eq!(config.overlay_git_source().unwrap().1, Some("dev"));
630    }
631
632    #[test]
633    fn age_identities_is_empty_without_configured_or_default_identity() {
634        let dir =
635            std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
636        let config = Config::new_for_test(&dir);
637
638        assert!(config.age_identities().is_empty());
639    }
640
641    #[test]
642    fn age_identities_uses_configured_path_when_set() {
643        let dir =
644            std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
645        let mut config = Config::new_for_test(&dir);
646        config.age_identity = Some("/tmp/my-identity.txt".to_string());
647
648        assert_eq!(
649            config.age_identities(),
650            vec![PathBuf::from("/tmp/my-identity.txt")]
651        );
652    }
653
654    #[test]
655    fn age_identities_treats_blank_configured_path_as_unset() {
656        let dir =
657            std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
658        let mut config = Config::new_for_test(&dir);
659        config.age_identity = Some("   ".to_string());
660
661        assert!(config.age_identities().is_empty());
662    }
663
664    #[tokio::test]
665    async fn age_identities_falls_back_to_default_path_when_it_exists() {
666        let dir =
667            std::env::temp_dir().join(format!("shine-age-identities-{}", uuid::Uuid::new_v4()));
668        let config = Config::new_for_test(&dir);
669        let default_path = dir.join("age").join("identity.txt");
670        tokio::fs::create_dir_all(default_path.parent().unwrap())
671            .await
672            .unwrap();
673        tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
674            .await
675            .unwrap();
676
677        assert_eq!(config.age_identities(), vec![default_path]);
678
679        tokio::fs::remove_dir_all(&dir).await.unwrap();
680    }
681}