Skip to main content

cli/config/
load.rs

1//! Config loading: global runtime config, project-layer discovery and merge.
2
3use anyhow::{Context, Result, bail};
4use serde::Deserialize;
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7use tokio::fs;
8
9use super::discovery::{
10    find_project_config, preliminary_shine_dir_from_env, read_minimal_config,
11    read_presets_override_from_toml, resolve_config_presets_path, resolve_runtime_config_dirs,
12};
13use super::env_layer::{deserialize_env_values, parse_env_descriptions};
14use super::{
15    Config, EnvProxyRule, ExternalShellMode, GLOBAL_CONFIG_FILE, PROJECT_CONFIG_FILE,
16    ProjectSaveState,
17};
18use crate::home::{default_config_and_presets_dir, effective_home_dir};
19
20#[derive(Default, Deserialize)]
21struct ProjectOverrides {
22    #[serde(default)]
23    presets_dir: Option<PathBuf>,
24    #[serde(default)]
25    external_shell_mode: Option<ExternalShellMode>,
26    #[serde(default)]
27    presets_overlay_dir: Option<PathBuf>,
28    #[serde(default)]
29    app_default_dest_root: Option<PathBuf>,
30    #[serde(default)]
31    self_install_dest: Option<PathBuf>,
32    #[serde(default)]
33    gpg_recipients: Vec<String>,
34    #[serde(rename = "gpg_key_id")]
35    legacy_gpg_key_id: Option<String>,
36    #[serde(default)]
37    secret_backend: Option<String>,
38    #[serde(default)]
39    age_recipients: Vec<String>,
40    #[serde(default)]
41    age_identity: Option<String>,
42    age_identities: Option<Vec<String>>,
43    #[serde(default, deserialize_with = "deserialize_env_values")]
44    env: BTreeMap<String, String>,
45    #[serde(default)]
46    env_proxy: Option<Vec<EnvProxyRule>>,
47}
48
49impl Config {
50    pub async fn init_current_dir_config() -> Result<PathBuf> {
51        let current_dir = std::env::current_dir().context("resolving current directory")?;
52        let (default_shine_dir, _) = default_config_and_presets_dir()?;
53        let preliminary_shine_dir = preliminary_shine_dir_from_env(&default_shine_dir);
54        if let Some(project_config) = find_project_config(&current_dir) {
55            bail!(
56                "{} already exists; current directory is already under a shine project at {}",
57                project_config.path.display(),
58                project_config.root.display()
59            );
60        }
61
62        let config_path = current_dir.join(PROJECT_CONFIG_FILE);
63
64        let presets_dir = tokio::fs::canonicalize(&current_dir)
65            .await
66            .unwrap_or_else(|_| current_dir.clone());
67        let shine_dir = preliminary_shine_dir;
68
69        let config = Config {
70            config_path: config_path.clone(),
71            is_project_config: true,
72            project_save_state: None,
73            shine_dir: shine_dir.clone(),
74            presets_dir: presets_dir.clone(),
75            bin_dir: shine_dir.join("bin"),
76            home_dir: effective_home_dir(),
77            presets_dir_override: Some(PathBuf::from(".")),
78            presets_overlay_dir_override: None,
79            is_external_presets: true,
80            ..Config::default()
81        };
82
83        config.save().await?;
84        Ok(config_path)
85    }
86
87    pub async fn load_or_init() -> Result<Self> {
88        let (default_shine_dir, default_presets_dir) = default_config_and_presets_dir()?;
89        let current_dir = std::env::current_dir().context("resolving current directory")?;
90        let project_config = find_project_config(&current_dir);
91        let Some(project_config) = project_config else {
92            return Self::load_global_runtime_or_init().await;
93        };
94        // Initialize the global layer before applying the sparse project layer.
95        let (mut config, global_exists) = Self::load_global_runtime_base().await?;
96        let contents = fs::read_to_string(&project_config.path)
97            .await
98            .context("Failed to read project config file")?;
99        let original: toml::Table =
100            toml::from_str(&contents).context("Failed to parse project config file")?;
101        let overrides: ProjectOverrides =
102            toml::from_str(&contents).context("Failed to parse project config file")?;
103        fs::create_dir_all(config.shine_dir()).await?;
104        fs::create_dir_all(config.presets_dir()).await?;
105        fs::create_dir_all(config.bin_dir()).await?;
106        let global_has_env = if global_exists {
107            let contents = fs::read_to_string(config.config_path()).await?;
108            config_toml_has_env_table(&contents)
109        } else {
110            false
111        };
112        config.ensure_env_defaults(global_has_env).await?;
113
114        let project_presets = overrides
115            .presets_dir
116            .map(|path| resolve_config_presets_path(&path, &project_config.root));
117        if let Some(path) = overrides.presets_overlay_dir {
118            config.presets_overlay_dir_override =
119                Some(resolve_config_presets_path(&path, &project_config.root));
120        }
121        if let Some(mode) = overrides.external_shell_mode {
122            config.external_shell_mode = mode;
123        }
124        if let Some(path) = overrides.app_default_dest_root {
125            config.app_default_dest_root_override =
126                Some(resolve_config_presets_path(&path, &project_config.root));
127        }
128        if let Some(path) = overrides.self_install_dest {
129            config.self_install_dest =
130                Some(resolve_config_presets_path(&path, &project_config.root));
131        }
132        if !overrides.gpg_recipients.is_empty() {
133            config.gpg_recipients = overrides.gpg_recipients;
134        }
135        if overrides.legacy_gpg_key_id.is_some() {
136            config.legacy_gpg_key_id = overrides.legacy_gpg_key_id;
137        }
138        if overrides.secret_backend.is_some() {
139            config.secret_backend = overrides.secret_backend;
140        }
141        if !overrides.age_recipients.is_empty() {
142            config.age_recipients = overrides.age_recipients;
143        }
144        let project_overrides_age_identities =
145            overrides.age_identity.is_some() || overrides.age_identities.is_some();
146        if project_overrides_age_identities {
147            config.age_identity = overrides.age_identity;
148            config.age_identities = overrides.age_identities.unwrap_or_default();
149        }
150        config.env.extend(overrides.env);
151        if let Some(project_rules) = overrides.env_proxy {
152            for rule in project_rules {
153                config
154                    .env_proxy
155                    .retain(|existing| existing.command != rule.command);
156                config.env_proxy.push(rule);
157            }
158        }
159        config
160            .env_descriptions
161            .extend(parse_env_descriptions(&contents));
162
163        let effective_presets = project_presets.clone().or_else(|| {
164            config
165                .is_external_presets
166                .then(|| config.presets_dir().to_path_buf())
167        });
168        if let Some(path) = &effective_presets {
169            config.presets_dir_override = Some(path.clone());
170        }
171        // SHINE_CONFIG_DIR's presets default outranks an inherited global setting,
172        // while an explicit project setting keeps the established local override behavior.
173        let runtime_presets = if project_presets.is_none()
174            && std::env::var("SHINE_CONFIG_DIR").is_ok_and(|value| !value.trim().is_empty())
175        {
176            None
177        } else {
178            effective_presets.clone()
179        };
180        let (shine_dir, presets_dir, is_external_presets) = resolve_runtime_config_dirs(
181            &default_shine_dir,
182            &default_presets_dir,
183            runtime_presets.as_deref(),
184            true,
185        );
186        config.config_path = project_config.path.clone();
187        config.is_project_config = true;
188        config.project_overrides_age_identities = project_overrides_age_identities;
189        config.shine_dir = shine_dir;
190        config.presets_dir = presets_dir;
191        config.bin_dir = config.shine_dir.join("bin");
192        config.is_external_presets = is_external_presets;
193        fs::create_dir_all(config.presets_dir()).await?;
194        fs::create_dir_all(config.bin_dir()).await?;
195
196        // Environment override files deliberately sit above both TOML layers.
197        config.apply_global_env_override().await?;
198        config.apply_overlay_env_override().await?;
199        config.apply_project_env_override(&project_config).await?;
200        crate::presets::set_overlay_dir(config.active_presets_overlay_dir());
201
202        let loaded = config.serialize_effective_table()?;
203        config.project_save_state = Some(ProjectSaveState { original, loaded });
204        Ok(config)
205    }
206
207    pub async fn load_global_runtime_or_init() -> Result<Self> {
208        let (mut config, exists) = Self::load_global_runtime_base().await?;
209
210        fs::create_dir_all(config.shine_dir())
211            .await
212            .with_context(|| "creating shine config dir")?;
213        fs::create_dir_all(config.presets_dir())
214            .await
215            .with_context(|| "creating presets dir")?;
216        fs::create_dir_all(config.bin_dir())
217            .await
218            .with_context(|| "creating bin dir")?;
219
220        let config_has_env = if exists {
221            let contents = fs::read_to_string(config.config_path())
222                .await
223                .context("Failed to read global config file")?;
224            config_toml_has_env_table(&contents)
225        } else {
226            false
227        };
228        config.ensure_env_defaults(config_has_env).await?;
229        config.apply_global_env_override().await?;
230        config.apply_overlay_env_override().await?;
231        Ok(config)
232    }
233
234    pub async fn load_global_runtime_for_dry_run() -> Result<Self> {
235        let (config, _) = Self::load_global_runtime_base().await?;
236        Ok(config)
237    }
238
239    async fn load_global_runtime_base() -> Result<(Self, bool)> {
240        let home_dir = effective_home_dir();
241        let (default_shine_dir, default_presets_dir) = default_config_and_presets_dir()?;
242        let preliminary_shine_dir = preliminary_shine_dir_from_env(&default_shine_dir);
243        let config_path = preliminary_shine_dir.join(GLOBAL_CONFIG_FILE);
244        let config_dir = config_path
245            .parent()
246            .context("Config path must have a parent directory")?
247            .to_path_buf();
248        let toml_presets = read_presets_override_from_toml(&config_path).await;
249        let toml_presets = toml_presets
250            .as_deref()
251            .map(|path| resolve_config_presets_path(path, &config_dir));
252
253        let (shine_dir, presets_dir, is_external_presets) = resolve_runtime_config_dirs(
254            &default_shine_dir,
255            &default_presets_dir,
256            toml_presets.as_deref(),
257            false,
258        );
259        let bin_dir = shine_dir.join("bin");
260
261        if config_path.exists() {
262            let contents = fs::read_to_string(&config_path)
263                .await
264                .context("Failed to read global config file")?;
265            let mut config: Config =
266                toml::from_str(&contents).context("Failed to parse global config file")?;
267            if config
268                .secret_backend
269                .as_deref()
270                .is_some_and(|b| b.trim().eq_ignore_ascii_case("hybrid"))
271            {
272                bail!("global secret_backend cannot be hybrid; use workspace access lists");
273            }
274            if let Some(value) = &config.hybrid_decrypt_backend {
275                match value.parse::<crate::secret::BackendKind>()? {
276                    crate::secret::BackendKind::Gpg | crate::secret::BackendKind::Age => {}
277                    _ => bail!("hybrid_decrypt_backend must be gpg or age"),
278                }
279            }
280            config.env_descriptions = parse_env_descriptions(&contents);
281            config.config_path = config_path.clone();
282            config.is_project_config = false;
283            config.shine_dir = shine_dir;
284            config.presets_dir = presets_dir;
285            config.bin_dir = bin_dir;
286            config.home_dir = home_dir;
287            config.is_external_presets = is_external_presets;
288            config.resolve_presets_overlay_dir(&config_dir);
289            config.resolve_managed_overlay_dir();
290            if let Some(path) = config.app_default_dest_root_override.as_deref() {
291                config.app_default_dest_root_override =
292                    Some(resolve_config_presets_path(path, &config_dir));
293            }
294            if let Some(path) = config.self_install_dest.as_deref() {
295                config.self_install_dest = Some(resolve_config_presets_path(path, &config_dir));
296            }
297            crate::presets::set_overlay_dir(config.active_presets_overlay_dir());
298            Ok((config, true))
299        } else {
300            let config = Config {
301                config_path: config_path.clone(),
302                is_project_config: false,
303                project_save_state: None,
304                shine_dir,
305                presets_dir,
306                bin_dir,
307                home_dir,
308                is_external_presets,
309                ..Config::default()
310            };
311            crate::presets::set_overlay_dir(config.active_presets_overlay_dir());
312            Ok((config, false))
313        }
314    }
315
316    pub async fn read_global_runtime_schema_version() -> Result<u32> {
317        let (default_shine_dir, _) = default_config_and_presets_dir()?;
318        let config_path =
319            preliminary_shine_dir_from_env(&default_shine_dir).join(GLOBAL_CONFIG_FILE);
320        let content = match fs::read_to_string(&config_path).await {
321            Ok(content) => content,
322            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
323                return Ok(super::CURRENT_RUNTIME_SCHEMA_VERSION);
324            }
325            Err(e) => {
326                return Err(e).with_context(|| format!("Failed to read {}", config_path.display()));
327            }
328        };
329
330        read_minimal_config(&content)
331            .map(|config| config.schema_version)
332            .with_context(|| format!("Failed to parse {}", config_path.display()))
333    }
334}
335
336fn config_toml_has_env_table(contents: &str) -> bool {
337    toml::from_str::<toml::Table>(contents)
338        .map(|table| table.contains_key("env"))
339        .unwrap_or(false)
340}
341
342#[cfg(test)]
343mod tests {
344    use super::super::test_util::{make_temp_dir, restore_current_dir};
345    use super::*;
346    use crate::config::CURRENT_RUNTIME_SCHEMA_VERSION;
347    use crate::test_support::env_lock;
348
349    #[allow(clippy::await_holding_lock)]
350    #[tokio::test(flavor = "current_thread")]
351    async fn load_or_init_creates_bin_dir() {
352        let _guard = env_lock();
353        let dir = make_temp_dir().await;
354        // SAFETY: env_lock() is held for the duration of this block, preventing
355        //          concurrent env mutation from other threads in this test binary.
356        unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
357
358        let config = Config::load_or_init().await.unwrap();
359        assert!(config.bin_dir().exists(), "bin dir should be created");
360        assert_eq!(config.bin_dir(), dir.join("bin"));
361
362        // SAFETY: env_lock() is held for the duration of this block, preventing
363        //          concurrent env mutation from other threads in this test binary.
364        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
365        fs::remove_dir_all(&dir).await.unwrap();
366    }
367
368    #[allow(clippy::await_holding_lock)]
369    #[tokio::test(flavor = "current_thread")]
370    async fn load_or_init_creates_env_table_in_config() {
371        let _guard = env_lock();
372        let dir = make_temp_dir().await;
373        // SAFETY: env_lock() is held for the duration of this block, preventing
374        //          concurrent env mutation from other threads in this test binary.
375        unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
376
377        let config = Config::load_or_init().await.unwrap();
378
379        assert_eq!(
380            config.env.get("HTTP_PROXY_PORT").map(String::as_str),
381            Some("6152")
382        );
383        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
384        let parsed: toml::Table = toml::from_str(&content).unwrap();
385        assert!(
386            parsed.get("env").is_some(),
387            "config.toml should contain [env]"
388        );
389
390        // SAFETY: env_lock() is held for the duration of this block, preventing
391        //          concurrent env mutation from other threads in this test binary.
392        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
393        fs::remove_dir_all(&dir).await.unwrap();
394    }
395
396    #[allow(clippy::await_holding_lock)]
397    #[tokio::test(flavor = "current_thread")]
398    async fn load_or_init_backfills_missing_env_defaults() {
399        let _guard = env_lock();
400        let dir = make_temp_dir().await;
401        fs::write(dir.join("config.toml"), "[env]\nCUSTOM = \"kept\"\n")
402            .await
403            .unwrap();
404
405        // SAFETY: env_lock() is held for the duration of this block, preventing
406        //          concurrent env mutation from other threads in this test binary.
407        unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
408
409        let config = Config::load_or_init().await.unwrap();
410
411        assert_eq!(config.env.get("CUSTOM").map(String::as_str), Some("kept"));
412        assert_eq!(
413            config.env.get("HTTP_PROXY_PORT").map(String::as_str),
414            Some("6152")
415        );
416        assert_eq!(
417            config.env.get("SOCKS5_PROXY_PORT").map(String::as_str),
418            Some("6153")
419        );
420        assert_eq!(
421            config.env.get("IMAGE_QUALITY").map(String::as_str),
422            Some("80")
423        );
424        assert_eq!(
425            config.env.get("IMAGE_MAX_WIDTH").map(String::as_str),
426            Some("1920")
427        );
428        assert_eq!(
429            config.env.get("IMAGE_MAX_HEIGHT").map(String::as_str),
430            Some("1080")
431        );
432        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
433        assert!(content.contains("CUSTOM = \"kept\""));
434        assert!(content.contains("HTTP_PROXY_PORT = \"6152\""));
435        assert!(content.contains("SOCKS5_PROXY_PORT = \"6153\""));
436        assert!(content.contains("IMAGE_QUALITY = \"80\""));
437        assert!(content.contains("IMAGE_MAX_WIDTH = \"1920\""));
438        assert!(content.contains("IMAGE_MAX_HEIGHT = \"1080\""));
439
440        // SAFETY: env_lock() is held for the duration of this block, preventing
441        //          concurrent env mutation from other threads in this test binary.
442        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
443        fs::remove_dir_all(&dir).await.unwrap();
444    }
445
446    #[allow(clippy::await_holding_lock)]
447    #[tokio::test(flavor = "current_thread")]
448    async fn load_or_init_discovers_project_config_from_child_dir() {
449        let _guard = env_lock();
450        let original_dir = std::env::current_dir().unwrap();
451        let project_dir = make_temp_dir().await;
452        let child_dir = project_dir.join("presets/shell/proxy");
453        fs::create_dir_all(&child_dir).await.unwrap();
454        let state_dir = make_temp_dir().await;
455        fs::write(
456            project_dir.join("shine.config.toml"),
457            "presets_dir = \".\"\n[env]\nHTTP_PROXY_PORT = \"1111\"\nCONFIG_ONLY = \"config\"\n",
458        )
459        .await
460        .unwrap();
461        fs::write(
462            project_dir.join("shine.env.toml"),
463            "HTTP_PROXY_PORT = \"2222\"\nDOTENV_ONLY = \"dotenv\"\n",
464        )
465        .await
466        .unwrap();
467        fs::write(
468            project_dir.join(".env.toml"),
469            "HTTP_PROXY_PORT = \"3333\"\n",
470        )
471        .await
472        .unwrap();
473
474        // SAFETY: env_lock() is held for the duration of this block, preventing
475        //          concurrent env mutation from other threads in this test binary.
476        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
477        // SAFETY: env_lock() is held for the duration of this block, preventing
478        //          concurrent env mutation from other threads in this test binary.
479        unsafe { std::env::remove_var("SHINE_PRESETS") };
480        std::env::set_current_dir(&child_dir).unwrap();
481
482        let config = Config::load_or_init().await.unwrap();
483
484        assert_eq!(
485            fs::canonicalize(&config.config_path).await.unwrap(),
486            fs::canonicalize(project_dir.join("shine.config.toml"))
487                .await
488                .unwrap()
489        );
490        assert_eq!(config.shine_dir(), state_dir);
491        assert_eq!(config.bin_dir(), state_dir.join("bin"));
492        assert_eq!(
493            fs::canonicalize(config.presets_dir()).await.unwrap(),
494            fs::canonicalize(&project_dir).await.unwrap()
495        );
496        assert_eq!(
497            config.env.get("HTTP_PROXY_PORT").map(String::as_str),
498            Some("2222")
499        );
500        assert_eq!(
501            config.env.get("CONFIG_ONLY").map(String::as_str),
502            Some("config")
503        );
504        assert_eq!(
505            config.env.get("DOTENV_ONLY").map(String::as_str),
506            Some("dotenv")
507        );
508
509        restore_current_dir(&original_dir);
510        // SAFETY: env_lock() is held for the duration of this block, preventing
511        //          concurrent env mutation from other threads in this test binary.
512        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
513        fs::remove_dir_all(&project_dir).await.unwrap();
514        fs::remove_dir_all(&state_dir).await.unwrap();
515    }
516
517    #[allow(clippy::await_holding_lock)]
518    #[tokio::test(flavor = "current_thread")]
519    async fn load_global_runtime_ignores_project_config() {
520        let _guard = env_lock();
521        let original_dir = std::env::current_dir().unwrap();
522        let project_dir = make_temp_dir().await;
523        let child_dir = project_dir.join("subdir");
524        fs::create_dir_all(&child_dir).await.unwrap();
525        let state_dir = make_temp_dir().await;
526        fs::write(
527            project_dir.join("shine.config.toml"),
528            "schema_version = 7\npresets_dir = \".\"\n",
529        )
530        .await
531        .unwrap();
532        fs::write(
533            state_dir.join("config.toml"),
534            "schema_version = 0\nlast_cleared_schema_version = 0\n",
535        )
536        .await
537        .unwrap();
538
539        // SAFETY: env_lock() is held for the duration of this block, preventing
540        //          concurrent env mutation from other threads in this test binary.
541        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
542        std::env::set_current_dir(&child_dir).unwrap();
543
544        let config = Config::load_global_runtime_or_init().await.unwrap();
545
546        assert_eq!(
547            fs::canonicalize(config.config_path()).await.unwrap(),
548            fs::canonicalize(state_dir.join("config.toml"))
549                .await
550                .unwrap()
551        );
552        assert_eq!(config.schema_version, 0);
553        assert_eq!(config.last_cleared_schema_version, Some(0));
554        assert_eq!(config.shine_dir(), state_dir);
555
556        restore_current_dir(&original_dir);
557        // SAFETY: env_lock() is held for the duration of this block, preventing
558        //          concurrent env mutation from other threads in this test binary.
559        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
560        fs::remove_dir_all(&project_dir).await.unwrap();
561        fs::remove_dir_all(&state_dir).await.unwrap();
562    }
563
564    #[allow(clippy::await_holding_lock)]
565    #[tokio::test(flavor = "current_thread")]
566    async fn load_global_runtime_for_dry_run_does_not_create_state() {
567        let _guard = env_lock();
568        let dir = std::env::temp_dir().join(format!("shine-dry-run-{}", uuid::Uuid::new_v4()));
569        assert!(!dir.exists());
570
571        // SAFETY: env_lock() is held for the duration of this block, preventing
572        //          concurrent env mutation from other threads in this test binary.
573        unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
574
575        let config = Config::load_global_runtime_for_dry_run().await.unwrap();
576
577        assert_eq!(config.config_path(), dir.join("config.toml"));
578        assert_eq!(config.schema_version, CURRENT_RUNTIME_SCHEMA_VERSION);
579        assert!(!dir.exists(), "dry-run loader must not create state dir");
580
581        // SAFETY: env_lock() is held for the duration of this block, preventing
582        //          concurrent env mutation from other threads in this test binary.
583        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
584    }
585
586    #[allow(clippy::await_holding_lock)]
587    #[tokio::test(flavor = "current_thread")]
588    async fn load_global_runtime_for_dry_run_ignores_removed_global_env_file() {
589        let _guard = env_lock();
590        let dir = make_temp_dir().await;
591        let removed_path = dir.join("env.toml");
592        fs::write(&removed_path, "CUSTOM_TOKEN = \"abc\"\n")
593            .await
594            .unwrap();
595
596        // SAFETY: env_lock() is held for the duration of this block, preventing
597        //          concurrent env mutation from other threads in this test binary.
598        unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
599
600        let config = Config::load_global_runtime_for_dry_run().await.unwrap();
601
602        assert_eq!(config.config_path(), dir.join("config.toml"));
603        assert!(removed_path.exists());
604        assert!(!dir.join("config.toml").exists());
605
606        // SAFETY: env_lock() is held for the duration of this block, preventing
607        //          concurrent env mutation from other threads in this test binary.
608        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
609        fs::remove_dir_all(&dir).await.unwrap();
610    }
611
612    #[allow(clippy::await_holding_lock)]
613    #[tokio::test(flavor = "current_thread")]
614    async fn global_config_with_presets_dir_remains_global_config() {
615        let _guard = env_lock();
616        let original_dir = std::env::current_dir().unwrap();
617        let original_home = std::env::var("HOME").ok();
618        let home_dir = make_temp_dir().await;
619        let shine_dir = home_dir.join(".shine");
620        let child_dir = shine_dir.join("presets/shell/proxy");
621        let external_presets = make_temp_dir().await.join("presets");
622        fs::create_dir_all(&child_dir).await.unwrap();
623        fs::create_dir_all(&external_presets).await.unwrap();
624        fs::write(
625            shine_dir.join("config.toml"),
626            format!(
627                "schema_version = 1\npresets_dir = {}\n",
628                toml::Value::String(external_presets.to_string_lossy().into_owned())
629            ),
630        )
631        .await
632        .unwrap();
633
634        // SAFETY: env_lock() is held for the duration of this block, preventing
635        //          concurrent env mutation from other threads in this test binary.
636        unsafe { std::env::set_var("HOME", home_dir.to_str().unwrap()) };
637        // SAFETY: env_lock() is held for the duration of this block, preventing
638        //          concurrent env mutation from other threads in this test binary.
639        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
640        // SAFETY: env_lock() is held for the duration of this block, preventing
641        //          concurrent env mutation from other threads in this test binary.
642        unsafe { std::env::remove_var("SHINE_PRESETS") };
643        std::env::set_current_dir(&child_dir).unwrap();
644
645        let config = Config::load_or_init().await.unwrap();
646
647        assert_eq!(
648            fs::canonicalize(config.config_path()).await.unwrap(),
649            fs::canonicalize(shine_dir.join("config.toml"))
650                .await
651                .unwrap()
652        );
653        assert!(
654            !config.is_project_config,
655            "global config.toml must not be treated as a project config"
656        );
657        assert_eq!(
658            fs::canonicalize(config.presets_dir()).await.unwrap(),
659            fs::canonicalize(&external_presets).await.unwrap()
660        );
661
662        restore_current_dir(&original_dir);
663        match original_home {
664            Some(home) => {
665                // SAFETY: env_lock() is held for the duration of this block, preventing
666                //          concurrent env mutation from other threads in this test binary.
667                unsafe { std::env::set_var("HOME", home) };
668            }
669            None => {
670                // SAFETY: env_lock() is held for the duration of this block, preventing
671                //          concurrent env mutation from other threads in this test binary.
672                unsafe { std::env::remove_var("HOME") };
673            }
674        }
675        fs::remove_dir_all(&home_dir).await.unwrap();
676        fs::remove_dir_all(external_presets.parent().unwrap())
677            .await
678            .unwrap();
679    }
680
681    #[allow(clippy::await_holding_lock)]
682    #[tokio::test(flavor = "current_thread")]
683    async fn load_or_init_ignores_generic_project_config_and_dotenv() {
684        let _guard = env_lock();
685        let original_dir = std::env::current_dir().unwrap();
686        let project_dir = make_temp_dir().await;
687        let child_dir = project_dir.join("subdir");
688        fs::create_dir_all(&child_dir).await.unwrap();
689        let state_dir = make_temp_dir().await;
690        fs::write(
691            project_dir.join("config.toml"),
692            "presets_dir = \".\"\n[env]\nHTTP_PROXY_PORT = \"1111\"\n",
693        )
694        .await
695        .unwrap();
696        fs::write(
697            project_dir.join(".env.toml"),
698            "HTTP_PROXY_PORT = \"3333\"\n",
699        )
700        .await
701        .unwrap();
702
703        // SAFETY: env_lock() is held for the duration of this block, preventing
704        //          concurrent env mutation from other threads in this test binary.
705        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
706        // SAFETY: env_lock() is held for the duration of this block, preventing
707        //          concurrent env mutation from other threads in this test binary.
708        unsafe { std::env::remove_var("SHINE_PRESETS") };
709        std::env::set_current_dir(&child_dir).unwrap();
710
711        let config = Config::load_or_init().await.unwrap();
712
713        assert_eq!(
714            fs::canonicalize(&config.config_path).await.unwrap(),
715            fs::canonicalize(state_dir.join("config.toml"))
716                .await
717                .unwrap()
718        );
719        assert!(!config.is_project_config);
720        assert_eq!(
721            fs::canonicalize(config.presets_dir()).await.unwrap(),
722            fs::canonicalize(state_dir.join("presets")).await.unwrap()
723        );
724        assert_eq!(
725            config.env.get("HTTP_PROXY_PORT").map(String::as_str),
726            Some("6152")
727        );
728
729        restore_current_dir(&original_dir);
730        // SAFETY: env_lock() is held for the duration of this block, preventing
731        //          concurrent env mutation from other threads in this test binary.
732        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
733        fs::remove_dir_all(&project_dir).await.unwrap();
734        fs::remove_dir_all(&state_dir).await.unwrap();
735    }
736
737    #[allow(clippy::await_holding_lock)]
738    #[tokio::test(flavor = "current_thread")]
739    async fn init_current_dir_config_ignores_generic_config_and_refuses_existing_shine_config() {
740        let _guard = env_lock();
741        let original_dir = std::env::current_dir().unwrap();
742        let project_dir = make_temp_dir().await;
743        fs::write(
744            project_dir.join("config.toml"),
745            "presets_dir = \"other-tool\"\n",
746        )
747        .await
748        .unwrap();
749        std::env::set_current_dir(&project_dir).unwrap();
750
751        let path = Config::init_current_dir_config().await.unwrap();
752        assert_eq!(
753            path.file_name().and_then(|name| name.to_str()),
754            Some("shine.config.toml")
755        );
756        assert_eq!(
757            fs::canonicalize(path.parent().unwrap()).await.unwrap(),
758            fs::canonicalize(&project_dir).await.unwrap()
759        );
760
761        let content = fs::read_to_string(&path).await.unwrap();
762        let parsed: toml::Table = toml::from_str(&content).unwrap();
763        assert_eq!(
764            parsed.get("presets_dir").and_then(|value| value.as_str()),
765            Some(".")
766        );
767        assert!(
768            !parsed.contains_key("schema_version"),
769            "project config must not persist runtime schema_version"
770        );
771        assert!(
772            !parsed.contains_key("last_cleared_schema_version"),
773            "project config must not persist runtime clear state"
774        );
775
776        let err = Config::init_current_dir_config().await.unwrap_err();
777        assert!(
778            err.to_string().contains("already exists"),
779            "error should refuse overwrite: {err:#}"
780        );
781
782        restore_current_dir(&original_dir);
783        fs::remove_dir_all(&project_dir).await.unwrap();
784    }
785
786    #[allow(clippy::await_holding_lock)]
787    #[tokio::test(flavor = "current_thread")]
788    async fn project_config_save_removes_runtime_schema_fields() {
789        let _guard = env_lock();
790        let original_dir = std::env::current_dir().unwrap();
791        let project_dir = make_temp_dir().await;
792        let state_dir = make_temp_dir().await;
793        fs::write(
794            project_dir.join("shine.config.toml"),
795            "schema_version = 0\nlast_cleared_schema_version = 0\npresets_dir = \".\"\n",
796        )
797        .await
798        .unwrap();
799
800        // SAFETY: env_lock() is held for the duration of this block, preventing
801        //          concurrent env mutation from other threads in this test binary.
802        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
803        std::env::set_current_dir(&project_dir).unwrap();
804
805        let config = Config::load_or_init().await.unwrap();
806        assert_eq!(config.schema_version, CURRENT_RUNTIME_SCHEMA_VERSION);
807        assert_eq!(config.last_cleared_schema_version, None);
808
809        config.save().await.unwrap();
810
811        let content = fs::read_to_string(project_dir.join("shine.config.toml"))
812            .await
813            .unwrap();
814        let parsed: toml::Table = toml::from_str(&content).unwrap();
815        assert_eq!(
816            parsed.get("presets_dir").and_then(|value| value.as_str()),
817            Some(".")
818        );
819        assert!(!parsed.contains_key("schema_version"));
820        assert!(!parsed.contains_key("last_cleared_schema_version"));
821
822        restore_current_dir(&original_dir);
823        // SAFETY: env_lock() is held for the duration of this block, preventing
824        //          concurrent env mutation from other threads in this test binary.
825        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
826        fs::remove_dir_all(&project_dir).await.unwrap();
827        fs::remove_dir_all(&state_dir).await.unwrap();
828    }
829
830    #[allow(clippy::await_holding_lock)]
831    #[tokio::test(flavor = "current_thread")]
832    async fn project_config_without_presets_dir_inherits_global_config() {
833        let _guard = env_lock();
834        let original_dir = std::env::current_dir().unwrap();
835        let original_home = std::env::var("HOME").ok();
836        let project_dir = make_temp_dir().await;
837        let home_dir = make_temp_dir().await;
838        let state_dir = home_dir.join(".shine");
839        fs::create_dir_all(&state_dir).await.unwrap();
840        let global_presets = state_dir.join("shared-presets");
841        fs::create_dir_all(&global_presets).await.unwrap();
842        fs::write(
843            state_dir.join("config.toml"),
844            "presets_dir = \"shared-presets\"\ngpg_recipients = [\"global-key\"]\n",
845        )
846        .await
847        .unwrap();
848        fs::write(
849            project_dir.join("shine.config.toml"),
850            "[env]\nLOCAL = \"yes\"\n",
851        )
852        .await
853        .unwrap();
854
855        unsafe { std::env::set_var("HOME", home_dir.to_str().unwrap()) };
856        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
857        unsafe { std::env::remove_var("SHINE_PRESETS") };
858        std::env::set_current_dir(&project_dir).unwrap();
859
860        let config = Config::load_or_init().await.unwrap();
861        assert_eq!(
862            fs::canonicalize(config.presets_dir()).await.unwrap(),
863            fs::canonicalize(&global_presets).await.unwrap()
864        );
865        assert_eq!(config.gpg_recipients, ["global-key"]);
866        assert!(config.is_external_presets);
867
868        restore_current_dir(&original_dir);
869        match original_home {
870            Some(home) => unsafe { std::env::set_var("HOME", home) },
871            None => unsafe { std::env::remove_var("HOME") },
872        }
873        fs::remove_dir_all(&project_dir).await.unwrap();
874        fs::remove_dir_all(&home_dir).await.unwrap();
875    }
876
877    #[allow(clippy::await_holding_lock)]
878    #[tokio::test(flavor = "current_thread")]
879    async fn project_config_merges_layers_and_saves_only_local_changes() {
880        let _guard = env_lock();
881        let original_dir = std::env::current_dir().unwrap();
882        let project_dir = make_temp_dir().await;
883        let state_dir = make_temp_dir().await;
884        fs::create_dir_all(project_dir.join("project-presets"))
885            .await
886            .unwrap();
887        fs::write(
888            state_dir.join("config.toml"),
889            "presets_dir = \"global-presets\"\ngpg_recipients = [\"global-key\"]\n[env]\nGLOBAL = \"config\"\nSHARED = \"global-config\"\n",
890        )
891        .await
892        .unwrap();
893        fs::write(
894            state_dir.join("shine.env.toml"),
895            "SHARED = \"global-env\"\nGLOBAL_FILE = \"yes\"\n",
896        )
897        .await
898        .unwrap();
899        fs::write(
900            project_dir.join("shine.config.toml"),
901            "presets_dir = \"project-presets\"\n[env]\nPROJECT = \"config\"\nSHARED = \"project-config\"\n",
902        )
903        .await
904        .unwrap();
905        fs::write(
906            project_dir.join("shine.env.toml"),
907            "SHARED = \"project-env\"\nPROJECT_FILE = \"yes\"\n",
908        )
909        .await
910        .unwrap();
911
912        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
913        unsafe { std::env::remove_var("SHINE_PRESETS") };
914        std::env::set_current_dir(&project_dir).unwrap();
915
916        let mut config = Config::load_or_init().await.unwrap();
917        assert_eq!(
918            fs::canonicalize(config.presets_dir()).await.unwrap(),
919            fs::canonicalize(project_dir.join("project-presets"))
920                .await
921                .unwrap()
922        );
923        assert_eq!(config.env.get("GLOBAL").map(String::as_str), Some("config"));
924        assert_eq!(
925            config.env.get("PROJECT").map(String::as_str),
926            Some("config")
927        );
928        assert_eq!(
929            config.env.get("GLOBAL_FILE").map(String::as_str),
930            Some("yes")
931        );
932        assert_eq!(
933            config.env.get("PROJECT_FILE").map(String::as_str),
934            Some("yes")
935        );
936        assert_eq!(
937            config.env.get("SHARED").map(String::as_str),
938            Some("project-env")
939        );
940
941        config.env.insert("ADDED".into(), "new".into());
942        config.save().await.unwrap();
943        let saved = fs::read_to_string(project_dir.join("shine.config.toml"))
944            .await
945            .unwrap();
946        let table: toml::Table = toml::from_str(&saved).unwrap();
947        assert_eq!(
948            table.get("presets_dir").and_then(toml::Value::as_str),
949            Some("project-presets")
950        );
951        assert!(!table.contains_key("gpg_recipients"));
952        let env = table.get("env").and_then(toml::Value::as_table).unwrap();
953        assert_eq!(env.get("ADDED").and_then(toml::Value::as_str), Some("new"));
954        assert!(!env.contains_key("GLOBAL"));
955        assert!(!env.contains_key("GLOBAL_FILE"));
956        assert!(!env.contains_key("PROJECT_FILE"));
957
958        restore_current_dir(&original_dir);
959        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
960        fs::remove_dir_all(&project_dir).await.unwrap();
961        fs::remove_dir_all(&state_dir).await.unwrap();
962    }
963
964    #[allow(clippy::await_holding_lock)]
965    #[tokio::test(flavor = "current_thread")]
966    async fn project_config_overrides_age_backend_settings() {
967        let _guard = env_lock();
968        let original_dir = std::env::current_dir().unwrap();
969        let project_dir = make_temp_dir().await;
970        let state_dir = make_temp_dir().await;
971        fs::write(
972            state_dir.join("config.toml"),
973            "hybrid_decrypt_backend = \"gpg\"\nsecret_backend = \"gpg\"\nage_recipients = [\"age1global\"]\nage_identity = \"~/.shine/age/global.txt\"\n",
974        )
975        .await
976        .unwrap();
977        fs::write(
978            project_dir.join("shine.config.toml"),
979            "hybrid_decrypt_backend = \"age\"\npresets_dir = \".\"\nsecret_backend = \"age\"\nage_recipients = [\"age1project-a\", \"age1project-b\"]\n",
980        )
981        .await
982        .unwrap();
983
984        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
985        unsafe { std::env::remove_var("SHINE_PRESETS") };
986        std::env::set_current_dir(&project_dir).unwrap();
987
988        let config = Config::load_or_init().await.unwrap();
989
990        assert_eq!(config.secret_backend.as_deref(), Some("age"));
991        assert_eq!(config.hybrid_decrypt_backend.as_deref(), Some("gpg"));
992        config.save().await.unwrap();
993        let saved = fs::read_to_string(config.config_path()).await.unwrap();
994        assert!(!saved.contains("hybrid_decrypt_backend"));
995        assert_eq!(
996            config.age_recipients,
997            vec!["age1project-a".to_string(), "age1project-b".to_string()]
998        );
999        // age_identity is absent from the project override, so the global value persists.
1000        assert_eq!(
1001            config.age_identity.as_deref(),
1002            Some("~/.shine/age/global.txt")
1003        );
1004        assert!(!config.project_overrides_age_identities());
1005
1006        restore_current_dir(&original_dir);
1007        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
1008        fs::remove_dir_all(&project_dir).await.unwrap();
1009        fs::remove_dir_all(&state_dir).await.unwrap();
1010    }
1011
1012    #[allow(clippy::await_holding_lock)]
1013    #[tokio::test(flavor = "current_thread")]
1014    async fn project_age_identities_replace_the_global_identity_set() {
1015        let _guard = env_lock();
1016        let original_dir = std::env::current_dir().unwrap();
1017        let project_dir = make_temp_dir().await;
1018        let state_dir = make_temp_dir().await;
1019        fs::write(
1020            state_dir.join("config.toml"),
1021            "age_identity = \"global-primary.txt\"\nage_identities = [\"global-extra.txt\"]\n",
1022        )
1023        .await
1024        .unwrap();
1025        fs::write(
1026            project_dir.join("shine.config.toml"),
1027            "presets_dir = \".\"\nage_identities = [\"project-phone.txt\", \"project-recovery.txt\"]\n",
1028        )
1029        .await
1030        .unwrap();
1031
1032        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
1033        unsafe { std::env::remove_var("SHINE_PRESETS") };
1034        std::env::set_current_dir(&project_dir).unwrap();
1035
1036        let config = Config::load_or_init().await.unwrap();
1037        assert!(config.age_identity.is_none());
1038        assert_eq!(
1039            config.age_identities,
1040            vec![
1041                "project-phone.txt".to_string(),
1042                "project-recovery.txt".to_string()
1043            ]
1044        );
1045        assert!(config.project_overrides_age_identities());
1046
1047        restore_current_dir(&original_dir);
1048        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
1049        fs::remove_dir_all(&project_dir).await.unwrap();
1050        fs::remove_dir_all(&state_dir).await.unwrap();
1051    }
1052}