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