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