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