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