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