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