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            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        assert_eq!(
408            config.env.get("IMAGE_QUALITY").map(String::as_str),
409            Some("80")
410        );
411        assert_eq!(
412            config.env.get("IMAGE_MAX_WIDTH").map(String::as_str),
413            Some("1920")
414        );
415        assert_eq!(
416            config.env.get("IMAGE_MAX_HEIGHT").map(String::as_str),
417            Some("1080")
418        );
419        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
420        assert!(content.contains("CUSTOM = \"kept\""));
421        assert!(content.contains("HTTP_PROXY_PORT = \"6152\""));
422        assert!(content.contains("SOCKS5_PROXY_PORT = \"6153\""));
423        assert!(content.contains("IMAGE_QUALITY = \"80\""));
424        assert!(content.contains("IMAGE_MAX_WIDTH = \"1920\""));
425        assert!(content.contains("IMAGE_MAX_HEIGHT = \"1080\""));
426
427        // SAFETY: env_lock() is held for the duration of this block, preventing
428        //          concurrent env mutation from other threads in this test binary.
429        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
430        fs::remove_dir_all(&dir).await.unwrap();
431    }
432
433    #[allow(clippy::await_holding_lock)]
434    #[tokio::test(flavor = "current_thread")]
435    async fn load_or_init_discovers_project_config_from_child_dir() {
436        let _guard = env_lock();
437        let original_dir = std::env::current_dir().unwrap();
438        let project_dir = make_temp_dir().await;
439        let child_dir = project_dir.join("presets/shell/proxy");
440        fs::create_dir_all(&child_dir).await.unwrap();
441        let state_dir = make_temp_dir().await;
442        fs::write(
443            project_dir.join("shine.config.toml"),
444            "presets_dir = \".\"\n[env]\nHTTP_PROXY_PORT = \"1111\"\nCONFIG_ONLY = \"config\"\n",
445        )
446        .await
447        .unwrap();
448        fs::write(
449            project_dir.join("shine.env.toml"),
450            "HTTP_PROXY_PORT = \"2222\"\nDOTENV_ONLY = \"dotenv\"\n",
451        )
452        .await
453        .unwrap();
454        fs::write(
455            project_dir.join(".env.toml"),
456            "HTTP_PROXY_PORT = \"3333\"\n",
457        )
458        .await
459        .unwrap();
460
461        // SAFETY: env_lock() is held for the duration of this block, preventing
462        //          concurrent env mutation from other threads in this test binary.
463        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
464        // SAFETY: env_lock() is held for the duration of this block, preventing
465        //          concurrent env mutation from other threads in this test binary.
466        unsafe { std::env::remove_var("SHINE_PRESETS") };
467        std::env::set_current_dir(&child_dir).unwrap();
468
469        let config = Config::load_or_init().await.unwrap();
470
471        assert_eq!(
472            fs::canonicalize(&config.config_path).await.unwrap(),
473            fs::canonicalize(project_dir.join("shine.config.toml"))
474                .await
475                .unwrap()
476        );
477        assert_eq!(config.shine_dir(), state_dir);
478        assert_eq!(config.bin_dir(), state_dir.join("bin"));
479        assert_eq!(
480            fs::canonicalize(config.presets_dir()).await.unwrap(),
481            fs::canonicalize(&project_dir).await.unwrap()
482        );
483        assert_eq!(
484            config.env.get("HTTP_PROXY_PORT").map(String::as_str),
485            Some("2222")
486        );
487        assert_eq!(
488            config.env.get("CONFIG_ONLY").map(String::as_str),
489            Some("config")
490        );
491        assert_eq!(
492            config.env.get("DOTENV_ONLY").map(String::as_str),
493            Some("dotenv")
494        );
495
496        restore_current_dir(&original_dir);
497        // SAFETY: env_lock() is held for the duration of this block, preventing
498        //          concurrent env mutation from other threads in this test binary.
499        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
500        fs::remove_dir_all(&project_dir).await.unwrap();
501        fs::remove_dir_all(&state_dir).await.unwrap();
502    }
503
504    #[allow(clippy::await_holding_lock)]
505    #[tokio::test(flavor = "current_thread")]
506    async fn load_global_runtime_ignores_project_config() {
507        let _guard = env_lock();
508        let original_dir = std::env::current_dir().unwrap();
509        let project_dir = make_temp_dir().await;
510        let child_dir = project_dir.join("subdir");
511        fs::create_dir_all(&child_dir).await.unwrap();
512        let state_dir = make_temp_dir().await;
513        fs::write(
514            project_dir.join("shine.config.toml"),
515            "schema_version = 7\npresets_dir = \".\"\n",
516        )
517        .await
518        .unwrap();
519        fs::write(
520            state_dir.join("config.toml"),
521            "schema_version = 0\nlast_cleared_schema_version = 0\n",
522        )
523        .await
524        .unwrap();
525
526        // SAFETY: env_lock() is held for the duration of this block, preventing
527        //          concurrent env mutation from other threads in this test binary.
528        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
529        std::env::set_current_dir(&child_dir).unwrap();
530
531        let config = Config::load_global_runtime_or_init().await.unwrap();
532
533        assert_eq!(
534            fs::canonicalize(config.config_path()).await.unwrap(),
535            fs::canonicalize(state_dir.join("config.toml"))
536                .await
537                .unwrap()
538        );
539        assert_eq!(config.schema_version, 0);
540        assert_eq!(config.last_cleared_schema_version, Some(0));
541        assert_eq!(config.shine_dir(), state_dir);
542
543        restore_current_dir(&original_dir);
544        // SAFETY: env_lock() is held for the duration of this block, preventing
545        //          concurrent env mutation from other threads in this test binary.
546        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
547        fs::remove_dir_all(&project_dir).await.unwrap();
548        fs::remove_dir_all(&state_dir).await.unwrap();
549    }
550
551    #[allow(clippy::await_holding_lock)]
552    #[tokio::test(flavor = "current_thread")]
553    async fn load_global_runtime_for_dry_run_does_not_create_state() {
554        let _guard = env_lock();
555        let dir = std::env::temp_dir().join(format!("shine-dry-run-{}", uuid::Uuid::new_v4()));
556        assert!(!dir.exists());
557
558        // SAFETY: env_lock() is held for the duration of this block, preventing
559        //          concurrent env mutation from other threads in this test binary.
560        unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
561
562        let config = Config::load_global_runtime_for_dry_run().await.unwrap();
563
564        assert_eq!(config.config_path(), dir.join("config.toml"));
565        assert_eq!(config.schema_version, CURRENT_RUNTIME_SCHEMA_VERSION);
566        assert!(!dir.exists(), "dry-run loader must not create state dir");
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::remove_var("SHINE_CONFIG_DIR") };
571    }
572
573    #[allow(clippy::await_holding_lock)]
574    #[tokio::test(flavor = "current_thread")]
575    async fn load_global_runtime_for_dry_run_ignores_removed_global_env_file() {
576        let _guard = env_lock();
577        let dir = make_temp_dir().await;
578        let removed_path = dir.join("env.toml");
579        fs::write(&removed_path, "CUSTOM_TOKEN = \"abc\"\n")
580            .await
581            .unwrap();
582
583        // SAFETY: env_lock() is held for the duration of this block, preventing
584        //          concurrent env mutation from other threads in this test binary.
585        unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
586
587        let config = Config::load_global_runtime_for_dry_run().await.unwrap();
588
589        assert_eq!(config.config_path(), dir.join("config.toml"));
590        assert!(removed_path.exists());
591        assert!(!dir.join("config.toml").exists());
592
593        // SAFETY: env_lock() is held for the duration of this block, preventing
594        //          concurrent env mutation from other threads in this test binary.
595        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
596        fs::remove_dir_all(&dir).await.unwrap();
597    }
598
599    #[allow(clippy::await_holding_lock)]
600    #[tokio::test(flavor = "current_thread")]
601    async fn global_config_with_presets_dir_remains_global_config() {
602        let _guard = env_lock();
603        let original_dir = std::env::current_dir().unwrap();
604        let original_home = std::env::var("HOME").ok();
605        let home_dir = make_temp_dir().await;
606        let shine_dir = home_dir.join(".shine");
607        let child_dir = shine_dir.join("presets/shell/proxy");
608        let external_presets = make_temp_dir().await.join("presets");
609        fs::create_dir_all(&child_dir).await.unwrap();
610        fs::create_dir_all(&external_presets).await.unwrap();
611        fs::write(
612            shine_dir.join("config.toml"),
613            format!(
614                "schema_version = 1\npresets_dir = {}\n",
615                toml::Value::String(external_presets.to_string_lossy().into_owned())
616            ),
617        )
618        .await
619        .unwrap();
620
621        // SAFETY: env_lock() is held for the duration of this block, preventing
622        //          concurrent env mutation from other threads in this test binary.
623        unsafe { std::env::set_var("HOME", home_dir.to_str().unwrap()) };
624        // SAFETY: env_lock() is held for the duration of this block, preventing
625        //          concurrent env mutation from other threads in this test binary.
626        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
627        // SAFETY: env_lock() is held for the duration of this block, preventing
628        //          concurrent env mutation from other threads in this test binary.
629        unsafe { std::env::remove_var("SHINE_PRESETS") };
630        std::env::set_current_dir(&child_dir).unwrap();
631
632        let config = Config::load_or_init().await.unwrap();
633
634        assert_eq!(
635            fs::canonicalize(config.config_path()).await.unwrap(),
636            fs::canonicalize(shine_dir.join("config.toml"))
637                .await
638                .unwrap()
639        );
640        assert!(
641            !config.is_project_config,
642            "global config.toml must not be treated as a project config"
643        );
644        assert_eq!(
645            fs::canonicalize(config.presets_dir()).await.unwrap(),
646            fs::canonicalize(&external_presets).await.unwrap()
647        );
648
649        restore_current_dir(&original_dir);
650        match original_home {
651            Some(home) => {
652                // SAFETY: env_lock() is held for the duration of this block, preventing
653                //          concurrent env mutation from other threads in this test binary.
654                unsafe { std::env::set_var("HOME", home) };
655            }
656            None => {
657                // SAFETY: env_lock() is held for the duration of this block, preventing
658                //          concurrent env mutation from other threads in this test binary.
659                unsafe { std::env::remove_var("HOME") };
660            }
661        }
662        fs::remove_dir_all(&home_dir).await.unwrap();
663        fs::remove_dir_all(external_presets.parent().unwrap())
664            .await
665            .unwrap();
666    }
667
668    #[allow(clippy::await_holding_lock)]
669    #[tokio::test(flavor = "current_thread")]
670    async fn load_or_init_ignores_generic_project_config_and_dotenv() {
671        let _guard = env_lock();
672        let original_dir = std::env::current_dir().unwrap();
673        let project_dir = make_temp_dir().await;
674        let child_dir = project_dir.join("subdir");
675        fs::create_dir_all(&child_dir).await.unwrap();
676        let state_dir = make_temp_dir().await;
677        fs::write(
678            project_dir.join("config.toml"),
679            "presets_dir = \".\"\n[env]\nHTTP_PROXY_PORT = \"1111\"\n",
680        )
681        .await
682        .unwrap();
683        fs::write(
684            project_dir.join(".env.toml"),
685            "HTTP_PROXY_PORT = \"3333\"\n",
686        )
687        .await
688        .unwrap();
689
690        // SAFETY: env_lock() is held for the duration of this block, preventing
691        //          concurrent env mutation from other threads in this test binary.
692        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
693        // SAFETY: env_lock() is held for the duration of this block, preventing
694        //          concurrent env mutation from other threads in this test binary.
695        unsafe { std::env::remove_var("SHINE_PRESETS") };
696        std::env::set_current_dir(&child_dir).unwrap();
697
698        let config = Config::load_or_init().await.unwrap();
699
700        assert_eq!(
701            fs::canonicalize(&config.config_path).await.unwrap(),
702            fs::canonicalize(state_dir.join("config.toml"))
703                .await
704                .unwrap()
705        );
706        assert!(!config.is_project_config);
707        assert_eq!(
708            fs::canonicalize(config.presets_dir()).await.unwrap(),
709            fs::canonicalize(state_dir.join("presets")).await.unwrap()
710        );
711        assert_eq!(
712            config.env.get("HTTP_PROXY_PORT").map(String::as_str),
713            Some("6152")
714        );
715
716        restore_current_dir(&original_dir);
717        // SAFETY: env_lock() is held for the duration of this block, preventing
718        //          concurrent env mutation from other threads in this test binary.
719        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
720        fs::remove_dir_all(&project_dir).await.unwrap();
721        fs::remove_dir_all(&state_dir).await.unwrap();
722    }
723
724    #[allow(clippy::await_holding_lock)]
725    #[tokio::test(flavor = "current_thread")]
726    async fn init_current_dir_config_ignores_generic_config_and_refuses_existing_shine_config() {
727        let _guard = env_lock();
728        let original_dir = std::env::current_dir().unwrap();
729        let project_dir = make_temp_dir().await;
730        fs::write(
731            project_dir.join("config.toml"),
732            "presets_dir = \"other-tool\"\n",
733        )
734        .await
735        .unwrap();
736        std::env::set_current_dir(&project_dir).unwrap();
737
738        let path = Config::init_current_dir_config().await.unwrap();
739        assert_eq!(
740            path.file_name().and_then(|name| name.to_str()),
741            Some("shine.config.toml")
742        );
743        assert_eq!(
744            fs::canonicalize(path.parent().unwrap()).await.unwrap(),
745            fs::canonicalize(&project_dir).await.unwrap()
746        );
747
748        let content = fs::read_to_string(&path).await.unwrap();
749        let parsed: toml::Table = toml::from_str(&content).unwrap();
750        assert_eq!(
751            parsed.get("presets_dir").and_then(|value| value.as_str()),
752            Some(".")
753        );
754        assert!(
755            !parsed.contains_key("schema_version"),
756            "project config must not persist runtime schema_version"
757        );
758        assert!(
759            !parsed.contains_key("last_cleared_schema_version"),
760            "project config must not persist runtime clear state"
761        );
762
763        let err = Config::init_current_dir_config().await.unwrap_err();
764        assert!(
765            err.to_string().contains("already exists"),
766            "error should refuse overwrite: {err:#}"
767        );
768
769        restore_current_dir(&original_dir);
770        fs::remove_dir_all(&project_dir).await.unwrap();
771    }
772
773    #[allow(clippy::await_holding_lock)]
774    #[tokio::test(flavor = "current_thread")]
775    async fn project_config_save_removes_runtime_schema_fields() {
776        let _guard = env_lock();
777        let original_dir = std::env::current_dir().unwrap();
778        let project_dir = make_temp_dir().await;
779        let state_dir = make_temp_dir().await;
780        fs::write(
781            project_dir.join("shine.config.toml"),
782            "schema_version = 0\nlast_cleared_schema_version = 0\npresets_dir = \".\"\n",
783        )
784        .await
785        .unwrap();
786
787        // SAFETY: env_lock() is held for the duration of this block, preventing
788        //          concurrent env mutation from other threads in this test binary.
789        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
790        std::env::set_current_dir(&project_dir).unwrap();
791
792        let config = Config::load_or_init().await.unwrap();
793        assert_eq!(config.schema_version, CURRENT_RUNTIME_SCHEMA_VERSION);
794        assert_eq!(config.last_cleared_schema_version, None);
795
796        config.save().await.unwrap();
797
798        let content = fs::read_to_string(project_dir.join("shine.config.toml"))
799            .await
800            .unwrap();
801        let parsed: toml::Table = toml::from_str(&content).unwrap();
802        assert_eq!(
803            parsed.get("presets_dir").and_then(|value| value.as_str()),
804            Some(".")
805        );
806        assert!(!parsed.contains_key("schema_version"));
807        assert!(!parsed.contains_key("last_cleared_schema_version"));
808
809        restore_current_dir(&original_dir);
810        // SAFETY: env_lock() is held for the duration of this block, preventing
811        //          concurrent env mutation from other threads in this test binary.
812        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
813        fs::remove_dir_all(&project_dir).await.unwrap();
814        fs::remove_dir_all(&state_dir).await.unwrap();
815    }
816
817    #[allow(clippy::await_holding_lock)]
818    #[tokio::test(flavor = "current_thread")]
819    async fn project_config_without_presets_dir_inherits_global_config() {
820        let _guard = env_lock();
821        let original_dir = std::env::current_dir().unwrap();
822        let original_home = std::env::var("HOME").ok();
823        let project_dir = make_temp_dir().await;
824        let home_dir = make_temp_dir().await;
825        let state_dir = home_dir.join(".shine");
826        fs::create_dir_all(&state_dir).await.unwrap();
827        let global_presets = state_dir.join("shared-presets");
828        fs::create_dir_all(&global_presets).await.unwrap();
829        fs::write(
830            state_dir.join("config.toml"),
831            "presets_dir = \"shared-presets\"\ngpg_recipients = [\"global-key\"]\n",
832        )
833        .await
834        .unwrap();
835        fs::write(
836            project_dir.join("shine.config.toml"),
837            "[env]\nLOCAL = \"yes\"\n",
838        )
839        .await
840        .unwrap();
841
842        unsafe { std::env::set_var("HOME", home_dir.to_str().unwrap()) };
843        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
844        unsafe { std::env::remove_var("SHINE_PRESETS") };
845        std::env::set_current_dir(&project_dir).unwrap();
846
847        let config = Config::load_or_init().await.unwrap();
848        assert_eq!(
849            fs::canonicalize(config.presets_dir()).await.unwrap(),
850            fs::canonicalize(&global_presets).await.unwrap()
851        );
852        assert_eq!(config.gpg_recipients, ["global-key"]);
853        assert!(config.is_external_presets);
854
855        restore_current_dir(&original_dir);
856        match original_home {
857            Some(home) => unsafe { std::env::set_var("HOME", home) },
858            None => unsafe { std::env::remove_var("HOME") },
859        }
860        fs::remove_dir_all(&project_dir).await.unwrap();
861        fs::remove_dir_all(&home_dir).await.unwrap();
862    }
863
864    #[allow(clippy::await_holding_lock)]
865    #[tokio::test(flavor = "current_thread")]
866    async fn project_config_merges_layers_and_saves_only_local_changes() {
867        let _guard = env_lock();
868        let original_dir = std::env::current_dir().unwrap();
869        let project_dir = make_temp_dir().await;
870        let state_dir = make_temp_dir().await;
871        fs::create_dir_all(project_dir.join("project-presets"))
872            .await
873            .unwrap();
874        fs::write(
875            state_dir.join("config.toml"),
876            "presets_dir = \"global-presets\"\ngpg_recipients = [\"global-key\"]\n[env]\nGLOBAL = \"config\"\nSHARED = \"global-config\"\n",
877        )
878        .await
879        .unwrap();
880        fs::write(
881            state_dir.join("shine.env.toml"),
882            "SHARED = \"global-env\"\nGLOBAL_FILE = \"yes\"\n",
883        )
884        .await
885        .unwrap();
886        fs::write(
887            project_dir.join("shine.config.toml"),
888            "presets_dir = \"project-presets\"\n[env]\nPROJECT = \"config\"\nSHARED = \"project-config\"\n",
889        )
890        .await
891        .unwrap();
892        fs::write(
893            project_dir.join("shine.env.toml"),
894            "SHARED = \"project-env\"\nPROJECT_FILE = \"yes\"\n",
895        )
896        .await
897        .unwrap();
898
899        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
900        unsafe { std::env::remove_var("SHINE_PRESETS") };
901        std::env::set_current_dir(&project_dir).unwrap();
902
903        let mut config = Config::load_or_init().await.unwrap();
904        assert_eq!(
905            fs::canonicalize(config.presets_dir()).await.unwrap(),
906            fs::canonicalize(project_dir.join("project-presets"))
907                .await
908                .unwrap()
909        );
910        assert_eq!(config.env.get("GLOBAL").map(String::as_str), Some("config"));
911        assert_eq!(
912            config.env.get("PROJECT").map(String::as_str),
913            Some("config")
914        );
915        assert_eq!(
916            config.env.get("GLOBAL_FILE").map(String::as_str),
917            Some("yes")
918        );
919        assert_eq!(
920            config.env.get("PROJECT_FILE").map(String::as_str),
921            Some("yes")
922        );
923        assert_eq!(
924            config.env.get("SHARED").map(String::as_str),
925            Some("project-env")
926        );
927
928        config.env.insert("ADDED".into(), "new".into());
929        config.save().await.unwrap();
930        let saved = fs::read_to_string(project_dir.join("shine.config.toml"))
931            .await
932            .unwrap();
933        let table: toml::Table = toml::from_str(&saved).unwrap();
934        assert_eq!(
935            table.get("presets_dir").and_then(toml::Value::as_str),
936            Some("project-presets")
937        );
938        assert!(!table.contains_key("gpg_recipients"));
939        let env = table.get("env").and_then(toml::Value::as_table).unwrap();
940        assert_eq!(env.get("ADDED").and_then(toml::Value::as_str), Some("new"));
941        assert!(!env.contains_key("GLOBAL"));
942        assert!(!env.contains_key("GLOBAL_FILE"));
943        assert!(!env.contains_key("PROJECT_FILE"));
944
945        restore_current_dir(&original_dir);
946        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
947        fs::remove_dir_all(&project_dir).await.unwrap();
948        fs::remove_dir_all(&state_dir).await.unwrap();
949    }
950
951    #[allow(clippy::await_holding_lock)]
952    #[tokio::test(flavor = "current_thread")]
953    async fn project_config_overrides_age_backend_settings() {
954        let _guard = env_lock();
955        let original_dir = std::env::current_dir().unwrap();
956        let project_dir = make_temp_dir().await;
957        let state_dir = make_temp_dir().await;
958        fs::write(
959            state_dir.join("config.toml"),
960            "secret_backend = \"gpg\"\nage_recipients = [\"age1global\"]\nage_identity = \"~/.shine/age/global.txt\"\n",
961        )
962        .await
963        .unwrap();
964        fs::write(
965            project_dir.join("shine.config.toml"),
966            "presets_dir = \".\"\nsecret_backend = \"age\"\nage_recipients = [\"age1project-a\", \"age1project-b\"]\n",
967        )
968        .await
969        .unwrap();
970
971        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
972        unsafe { std::env::remove_var("SHINE_PRESETS") };
973        std::env::set_current_dir(&project_dir).unwrap();
974
975        let config = Config::load_or_init().await.unwrap();
976
977        assert_eq!(config.secret_backend.as_deref(), Some("age"));
978        assert_eq!(
979            config.age_recipients,
980            vec!["age1project-a".to_string(), "age1project-b".to_string()]
981        );
982        // age_identity is absent from the project override, so the global value persists.
983        assert_eq!(
984            config.age_identity.as_deref(),
985            Some("~/.shine/age/global.txt")
986        );
987        assert!(!config.project_overrides_age_identities());
988
989        restore_current_dir(&original_dir);
990        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
991        fs::remove_dir_all(&project_dir).await.unwrap();
992        fs::remove_dir_all(&state_dir).await.unwrap();
993    }
994
995    #[allow(clippy::await_holding_lock)]
996    #[tokio::test(flavor = "current_thread")]
997    async fn project_age_identities_replace_the_global_identity_set() {
998        let _guard = env_lock();
999        let original_dir = std::env::current_dir().unwrap();
1000        let project_dir = make_temp_dir().await;
1001        let state_dir = make_temp_dir().await;
1002        fs::write(
1003            state_dir.join("config.toml"),
1004            "age_identity = \"global-primary.txt\"\nage_identities = [\"global-extra.txt\"]\n",
1005        )
1006        .await
1007        .unwrap();
1008        fs::write(
1009            project_dir.join("shine.config.toml"),
1010            "presets_dir = \".\"\nage_identities = [\"project-phone.txt\", \"project-recovery.txt\"]\n",
1011        )
1012        .await
1013        .unwrap();
1014
1015        unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
1016        unsafe { std::env::remove_var("SHINE_PRESETS") };
1017        std::env::set_current_dir(&project_dir).unwrap();
1018
1019        let config = Config::load_or_init().await.unwrap();
1020        assert!(config.age_identity.is_none());
1021        assert_eq!(
1022            config.age_identities,
1023            vec![
1024                "project-phone.txt".to_string(),
1025                "project-recovery.txt".to_string()
1026            ]
1027        );
1028        assert!(config.project_overrides_age_identities());
1029
1030        restore_current_dir(&original_dir);
1031        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
1032        fs::remove_dir_all(&project_dir).await.unwrap();
1033        fs::remove_dir_all(&state_dir).await.unwrap();
1034    }
1035}