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