Skip to main content

cli/config/
env_layer.rs

1//! Env variable layering: `[env]` defaults and parsing, removed global
2//! `env.toml` detection, and `shine.env.toml` override files.
3
4use anyhow::{Context, Result, bail};
5use std::collections::BTreeMap;
6use std::path::Path;
7use tokio::fs;
8
9use super::discovery::ProjectConfig;
10use super::{Config, DEFAULT_ENV_VARS, EnvOverrideKind, EnvOverrideSource, PROJECT_ENV_FILE};
11
12const REMOVED_GLOBAL_ENV_FILE: &str = "env.toml";
13
14#[derive(serde::Deserialize)]
15#[serde(untagged)]
16enum EnvValue {
17    Plain(String),
18    Detailed {
19        value: String,
20        description: Option<String>,
21    },
22}
23
24#[derive(Default)]
25struct EnvOverrides {
26    values: BTreeMap<String, String>,
27    descriptions: BTreeMap<String, String>,
28}
29
30pub(super) fn deserialize_env_values<'de, D>(
31    deserializer: D,
32) -> Result<BTreeMap<String, String>, D::Error>
33where
34    D: serde::Deserializer<'de>,
35{
36    use serde::Deserialize;
37
38    let values = BTreeMap::<String, EnvValue>::deserialize(deserializer)?;
39    Ok(values
40        .into_iter()
41        .map(|(key, value)| {
42            let value = match value {
43                EnvValue::Plain(value) | EnvValue::Detailed { value, .. } => value,
44            };
45            (key, value)
46        })
47        .collect())
48}
49
50pub(super) fn parse_env_descriptions(contents: &str) -> BTreeMap<String, String> {
51    let Ok(table) = toml::from_str::<toml::Table>(contents) else {
52        return BTreeMap::new();
53    };
54    let Some(env) = table.get("env").and_then(toml::Value::as_table) else {
55        return BTreeMap::new();
56    };
57    env.iter()
58        .filter_map(|(key, value)| {
59            value
60                .as_table()
61                .and_then(|entry| entry.get("description"))
62                .and_then(toml::Value::as_str)
63                .map(|description| (key.clone(), description.to_string()))
64        })
65        .collect()
66}
67
68impl Config {
69    pub(super) async fn ensure_env_defaults(&mut self, config_has_env: bool) -> Result<()> {
70        self.reject_removed_global_env_file().await?;
71        let mut needs_save = !config_has_env;
72
73        for (key, value) in DEFAULT_ENV_VARS {
74            if let std::collections::btree_map::Entry::Vacant(entry) =
75                self.env.entry(key.to_string())
76            {
77                entry.insert(value.to_string());
78                needs_save = true;
79            }
80        }
81
82        if needs_save {
83            self.save().await?;
84        }
85
86        Ok(())
87    }
88
89    async fn reject_removed_global_env_file(&self) -> Result<()> {
90        let removed_path = self.shine_dir().join(REMOVED_GLOBAL_ENV_FILE);
91        if !fs::try_exists(&removed_path)
92            .await
93            .with_context(|| format!("checking {}", removed_path.display()))?
94        {
95            return Ok(());
96        }
97
98        let override_path = self.shine_dir().join(PROJECT_ENV_FILE);
99        if fs::try_exists(&override_path)
100            .await
101            .with_context(|| format!("checking {}", override_path.display()))?
102        {
103            bail!(
104                "the removed global env file {} still exists; run a v0.39 shine binary once to \
105                 migrate it, or manually merge its values into {} and then remove the old file",
106                removed_path.display(),
107                override_path.display()
108            );
109        }
110
111        bail!(
112            "the removed global env file {} still exists; run a v0.39 shine binary once to \
113             migrate it, or rename it to {}",
114            removed_path.display(),
115            override_path.display()
116        )
117    }
118
119    pub(super) async fn apply_global_env_override(&mut self) -> Result<()> {
120        let env_path = self.shine_dir().join(PROJECT_ENV_FILE);
121        let Some(overrides) = read_env_file(&env_path).await? else {
122            return Ok(());
123        };
124        self.apply_env_overrides(env_path, EnvOverrideKind::Global, false, overrides);
125        Ok(())
126    }
127
128    pub(super) async fn apply_overlay_env_override(&mut self) -> Result<()> {
129        let Some(overlay_dir) = self.active_presets_overlay_dir() else {
130            return Ok(());
131        };
132        let env_path = overlay_dir.join(PROJECT_ENV_FILE);
133        let Some(overrides) = read_env_file(&env_path).await? else {
134            return Ok(());
135        };
136        // If no manual overlay dir is configured, `active_presets_overlay_dir()`
137        // can only have resolved to the shine-managed Git checkout.
138        let is_managed_overlay = self.presets_overlay_dir_override.is_none();
139        self.apply_env_overrides(
140            env_path,
141            EnvOverrideKind::Overlay,
142            is_managed_overlay,
143            overrides,
144        );
145        Ok(())
146    }
147
148    pub(super) async fn apply_project_env_override(
149        &mut self,
150        project_config: &ProjectConfig,
151    ) -> Result<()> {
152        let env_path = project_config.root.join(PROJECT_ENV_FILE);
153        let Some(overrides) = read_env_file(&env_path).await? else {
154            return Ok(());
155        };
156
157        self.apply_env_overrides(env_path, EnvOverrideKind::Project, false, overrides);
158        Ok(())
159    }
160
161    fn apply_env_overrides(
162        &mut self,
163        path: std::path::PathBuf,
164        kind: EnvOverrideKind,
165        is_managed_overlay: bool,
166        overrides: EnvOverrides,
167    ) {
168        for key in overrides.values.keys() {
169            self.env_override_sources.insert(
170                key.clone(),
171                EnvOverrideSource {
172                    path: path.clone(),
173                    kind,
174                    is_managed_overlay,
175                },
176            );
177        }
178        self.env.extend(overrides.values);
179        self.env_descriptions.extend(overrides.descriptions);
180    }
181}
182
183#[doc(hidden)]
184pub async fn validate_env_override_file(path: &Path) -> Result<()> {
185    read_env_file(path).await.map(|_| ())
186}
187
188/// Set (`Some`) or remove (`None`) one key in a `shine.env.toml`-shaped override
189/// file, preserving comments/formatting of unrelated entries and the
190/// `description` of a `{ value, description }` entry being updated. Creates the
191/// file (and its parent directory) if it doesn't exist yet.
192pub async fn write_env_override_entry(path: &Path, key: &str, value: Option<&str>) -> Result<()> {
193    let existing = match fs::read_to_string(path).await {
194        Ok(content) => content,
195        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
196        Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
197    };
198    let mut target: toml::Table = if existing.is_empty() {
199        toml::Table::new()
200    } else {
201        toml::from_str(&existing).with_context(|| format!("parsing {}", path.display()))?
202    };
203
204    match value {
205        // Always target a plain string, mirroring `Config::env`'s always-flat
206        // `BTreeMap<String, String>` shape. `sync_table` itself detects when the
207        // *document* still has the detailed `{ value, description }` form and
208        // updates only the `value` sub-field, preserving `description` — the
209        // same mechanism `Config::save()` relies on for config.toml `[env]`.
210        Some(value) => {
211            target.insert(key.to_string(), toml::Value::String(value.to_string()));
212        }
213        None => {
214            target.remove(key);
215        }
216    }
217
218    let new_content = if existing.is_empty() {
219        toml::to_string_pretty(&target).context("serializing env override file")?
220    } else {
221        let mut doc: toml_edit::DocumentMut = existing
222            .parse()
223            .context("parsing existing env override file for comment preservation")?;
224        shine_core::migration::sync_table(doc.as_table_mut(), &target);
225        doc.to_string()
226    };
227
228    if let Some(parent) = path.parent() {
229        fs::create_dir_all(parent)
230            .await
231            .with_context(|| format!("creating {}", parent.display()))?;
232    }
233    crate::persist::atomic_write(path, new_content.as_bytes())
234        .await
235        .with_context(|| format!("writing {}", path.display()))
236}
237
238async fn read_env_file(path: &Path) -> Result<Option<EnvOverrides>> {
239    let content = match fs::read_to_string(path).await {
240        Ok(content) => content,
241        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
242        Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
243    };
244
245    let table: toml::Table =
246        toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
247    let mut overrides = EnvOverrides::default();
248    for (key, value) in table {
249        let parsed: EnvValue = value.try_into().with_context(|| {
250            format!(
251                "invalid env variable `{key}` in {}: expected a string or {{ value, description }}",
252                path.display()
253            )
254        })?;
255        match parsed {
256            EnvValue::Plain(value) => {
257                overrides.values.insert(key, value);
258            }
259            EnvValue::Detailed { value, description } => {
260                overrides.values.insert(key.clone(), value);
261                if let Some(description) = description {
262                    overrides.descriptions.insert(key, description);
263                }
264            }
265        }
266    }
267    Ok(Some(overrides))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::super::test_util::{config_in, make_temp_dir, restore_current_dir};
273    use super::*;
274    use crate::config::PROJECT_CONFIG_FILE;
275    use crate::test_support::env_lock;
276
277    #[test]
278    fn detailed_env_values_are_normalized_with_descriptions() {
279        let contents = r#"
280            [env]
281            PLAIN = "value"
282            TOKEN = { value = "secret", description = "Internal token" }
283        "#;
284        let config: Config = toml::from_str(contents).unwrap();
285
286        assert_eq!(config.env.get("PLAIN").map(String::as_str), Some("value"));
287        assert_eq!(config.env.get("TOKEN").map(String::as_str), Some("secret"));
288        assert_eq!(
289            parse_env_descriptions(contents)
290                .get("TOKEN")
291                .map(String::as_str),
292            Some("Internal token")
293        );
294    }
295
296    #[allow(clippy::await_holding_lock)]
297    #[tokio::test(flavor = "current_thread")]
298    async fn load_or_init_rejects_removed_global_env_file_without_mutating_state() {
299        let _guard = env_lock();
300        let dir = make_temp_dir().await;
301        fs::write(dir.join("env.toml"), "not valid = [\n")
302            .await
303            .unwrap();
304        // SAFETY: env_lock() is held for the duration of this block, preventing
305        //          concurrent env mutation from other threads in this test binary.
306        unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
307
308        let error = Config::load_or_init().await.unwrap_err().to_string();
309
310        assert!(error.contains(&dir.join("env.toml").display().to_string()));
311        assert!(error.contains("v0.39"));
312        assert!(error.contains("rename"));
313        assert!(error.contains(&dir.join("shine.env.toml").display().to_string()));
314        assert!(
315            dir.join("env.toml").exists(),
316            "removed env.toml must be left for explicit recovery"
317        );
318        assert!(
319            !dir.join("config.toml").exists(),
320            "rejection must happen before config initialization"
321        );
322
323        // SAFETY: env_lock() is held for the duration of this block, preventing
324        //          concurrent env mutation from other threads in this test binary.
325        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
326        fs::remove_dir_all(&dir).await.unwrap();
327    }
328
329    #[allow(clippy::await_holding_lock)]
330    #[tokio::test(flavor = "current_thread")]
331    async fn load_or_init_reads_global_shine_env_without_presets_dir() {
332        let _guard = env_lock();
333        let dir = make_temp_dir().await;
334        fs::write(
335            dir.join("config.toml"),
336            "[env]\nHTTP_PROXY_PORT = \"1111\"\nCONFIG_ONLY = \"config\"\n",
337        )
338        .await
339        .unwrap();
340        fs::write(
341            dir.join("shine.env.toml"),
342            "HTTP_PROXY_PORT = \"2222\"\nSIDECAR_ONLY = \"sidecar\"\n",
343        )
344        .await
345        .unwrap();
346
347        // SAFETY: env_lock() is held for the duration of this block, preventing
348        //          concurrent env mutation from other threads in this test binary.
349        unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
350        // SAFETY: env_lock() is held for the duration of this block, preventing
351        //          concurrent env mutation from other threads in this test binary.
352        unsafe { std::env::remove_var("SHINE_PRESETS") };
353
354        let config = Config::load_or_init().await.unwrap();
355
356        assert_eq!(
357            config.env.get("HTTP_PROXY_PORT").map(String::as_str),
358            Some("2222")
359        );
360        assert_eq!(
361            config.env.get("CONFIG_ONLY").map(String::as_str),
362            Some("config")
363        );
364        assert_eq!(
365            config.env.get("SIDECAR_ONLY").map(String::as_str),
366            Some("sidecar")
367        );
368        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
369        assert!(
370            !content.contains("SIDECAR_ONLY"),
371            "global shine.env.toml overrides should not be written into config.toml"
372        );
373
374        // SAFETY: env_lock() is held for the duration of this block, preventing
375        //          concurrent env mutation from other threads in this test binary.
376        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
377        fs::remove_dir_all(&dir).await.unwrap();
378    }
379
380    #[allow(clippy::await_holding_lock)]
381    #[tokio::test(flavor = "current_thread")]
382    async fn project_shine_env_overrides_global_shine_env() {
383        let _guard = env_lock();
384        let original_dir = std::env::current_dir().unwrap();
385        let project_dir = make_temp_dir().await;
386        let child_dir = project_dir.join("subdir");
387        fs::create_dir_all(&child_dir).await.unwrap();
388        let state_dir = make_temp_dir().await;
389        fs::write(
390            project_dir.join("shine.config.toml"),
391            "presets_dir = \".\"\n[env]\nHTTP_PROXY_PORT = \"1111\"\nCONFIG_ONLY = \"config\"\n",
392        )
393        .await
394        .unwrap();
395        fs::write(
396            state_dir.join("shine.env.toml"),
397            "HTTP_PROXY_PORT = \"2222\"\nGLOBAL_ONLY = \"global\"\n",
398        )
399        .await
400        .unwrap();
401        fs::write(
402            project_dir.join("shine.env.toml"),
403            "HTTP_PROXY_PORT = \"3333\"\nPROJECT_ONLY = \"project\"\n",
404        )
405        .await
406        .unwrap();
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::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
411        // SAFETY: env_lock() is held for the duration of this block, preventing
412        //          concurrent env mutation from other threads in this test binary.
413        unsafe { std::env::remove_var("SHINE_PRESETS") };
414        std::env::set_current_dir(&child_dir).unwrap();
415
416        let config = Config::load_or_init().await.unwrap();
417
418        assert_eq!(
419            config.env.get("HTTP_PROXY_PORT").map(String::as_str),
420            Some("3333")
421        );
422        assert_eq!(
423            config.env.get("CONFIG_ONLY").map(String::as_str),
424            Some("config")
425        );
426        assert_eq!(
427            config.env.get("GLOBAL_ONLY").map(String::as_str),
428            Some("global")
429        );
430        assert_eq!(
431            config.env.get("PROJECT_ONLY").map(String::as_str),
432            Some("project")
433        );
434
435        restore_current_dir(&original_dir);
436        // SAFETY: env_lock() is held for the duration of this block, preventing
437        //          concurrent env mutation from other threads in this test binary.
438        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
439        fs::remove_dir_all(&project_dir).await.unwrap();
440        fs::remove_dir_all(&state_dir).await.unwrap();
441    }
442
443    #[allow(clippy::await_holding_lock)]
444    #[tokio::test(flavor = "current_thread")]
445    async fn removed_global_env_file_requires_manual_merge_when_override_exists() {
446        let _guard = env_lock();
447        let dir = make_temp_dir().await;
448        fs::write(
449            dir.join("config.toml"),
450            "[env]\nHTTP_PROXY_PORT = \"1111\"\n",
451        )
452        .await
453        .unwrap();
454        fs::write(
455            dir.join("env.toml"),
456            "HTTP_PROXY_PORT = \"2222\"\nCUSTOM_TOKEN = \"abc\"\n",
457        )
458        .await
459        .unwrap();
460        fs::write(
461            dir.join("shine.env.toml"),
462            "HTTP_PROXY_PORT = \"3333\"\nSIDECAR_ONLY = \"sidecar\"\n",
463        )
464        .await
465        .unwrap();
466        let original_config = fs::read_to_string(dir.join("config.toml")).await.unwrap();
467        let original_removed = fs::read_to_string(dir.join("env.toml")).await.unwrap();
468        let original_override = fs::read_to_string(dir.join("shine.env.toml"))
469            .await
470            .unwrap();
471        // SAFETY: env_lock() is held for the duration of this block, preventing
472        //          concurrent env mutation from other threads in this test binary.
473        unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
474
475        let error = Config::load_or_init().await.unwrap_err().to_string();
476
477        assert!(error.contains("v0.39"));
478        assert!(error.contains("manually merge"));
479        assert!(error.contains(&dir.join("env.toml").display().to_string()));
480        assert!(error.contains(&dir.join("shine.env.toml").display().to_string()));
481        assert_eq!(
482            fs::read_to_string(dir.join("config.toml")).await.unwrap(),
483            original_config
484        );
485        assert_eq!(
486            fs::read_to_string(dir.join("env.toml")).await.unwrap(),
487            original_removed
488        );
489        assert_eq!(
490            fs::read_to_string(dir.join("shine.env.toml"))
491                .await
492                .unwrap(),
493            original_override
494        );
495
496        // SAFETY: env_lock() is held for the duration of this block, preventing
497        //          concurrent env mutation from other threads in this test binary.
498        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
499        fs::remove_dir_all(&dir).await.unwrap();
500    }
501
502    #[tokio::test]
503    async fn project_dotenv_toml_is_ignored() {
504        let dir = make_temp_dir().await;
505        let project_dir = dir.join("project");
506        fs::create_dir_all(&project_dir).await.unwrap();
507        fs::write(
508            project_dir.join(".env.toml"),
509            "GENERIC_DOTENV_VALUE = \"ignored\"\n",
510        )
511        .await
512        .unwrap();
513        let mut config = config_in(&dir);
514
515        config
516            .apply_project_env_override(&ProjectConfig {
517                path: project_dir.join(PROJECT_CONFIG_FILE),
518                root: project_dir,
519            })
520            .await
521            .unwrap();
522
523        assert!(!config.env.contains_key("GENERIC_DOTENV_VALUE"));
524        fs::remove_dir_all(&dir).await.unwrap();
525    }
526
527    #[tokio::test]
528    async fn overlay_env_sits_between_global_and_project_env_overrides() {
529        let dir = make_temp_dir().await;
530        let overlay_dir = dir.join("overlay");
531        let project_dir = dir.join("project");
532        fs::create_dir_all(&overlay_dir).await.unwrap();
533        fs::create_dir_all(&project_dir).await.unwrap();
534        fs::write(
535            dir.join(PROJECT_ENV_FILE),
536            "SHARED = { value = \"global\", description = \"global description\" }\n\
537             DETAILED = { value = \"global\", description = \"global detail\" }\n",
538        )
539        .await
540        .unwrap();
541        fs::write(
542            overlay_dir.join(PROJECT_ENV_FILE),
543            "SHARED = { value = \"overlay\", description = \"overlay description\" }\n\
544             DETAILED = { value = \"overlay\", description = \"overlay detail\" }\n\
545             OVERLAY_ONLY = { value = \"yes\", description = \"overlay only\" }\n",
546        )
547        .await
548        .unwrap();
549        fs::write(
550            project_dir.join(PROJECT_ENV_FILE),
551            "SHARED = \"project\"\n\
552             DETAILED = { value = \"project\", description = \"project detail\" }\n",
553        )
554        .await
555        .unwrap();
556
557        let mut config = config_in(&dir);
558        config.presets_overlay_dir_override = Some(overlay_dir);
559        config.apply_global_env_override().await.unwrap();
560        config.apply_overlay_env_override().await.unwrap();
561        assert_eq!(
562            config.env.get("SHARED").map(String::as_str),
563            Some("overlay")
564        );
565        assert_eq!(
566            config.env.get("OVERLAY_ONLY").map(String::as_str),
567            Some("yes")
568        );
569        assert_eq!(
570            config.env_descriptions.get("SHARED").map(String::as_str),
571            Some("overlay description")
572        );
573        assert_eq!(
574            config
575                .env_descriptions
576                .get("OVERLAY_ONLY")
577                .map(String::as_str),
578            Some("overlay only")
579        );
580
581        config
582            .apply_project_env_override(&ProjectConfig {
583                path: project_dir.join(PROJECT_CONFIG_FILE),
584                root: project_dir,
585            })
586            .await
587            .unwrap();
588        assert_eq!(
589            config.env.get("SHARED").map(String::as_str),
590            Some("project")
591        );
592        assert_eq!(
593            config.env_descriptions.get("SHARED").map(String::as_str),
594            Some("overlay description"),
595            "a plain override must preserve the inherited description"
596        );
597        assert_eq!(
598            config.env.get("DETAILED").map(String::as_str),
599            Some("project")
600        );
601        assert_eq!(
602            config.env_descriptions.get("DETAILED").map(String::as_str),
603            Some("project detail")
604        );
605
606        fs::remove_dir_all(&dir).await.unwrap();
607    }
608
609    #[tokio::test]
610    async fn env_override_rejects_invalid_values_with_path_and_key() {
611        let dir = make_temp_dir().await;
612        let path = dir.join(PROJECT_ENV_FILE);
613        let invalid_values = [
614            "TOKEN = 42\n",
615            "TOKEN = [\"one\", \"two\"]\n",
616            "TOKEN = { description = \"missing value\" }\n",
617            "TOKEN = { value = 42, description = \"wrong type\" }\n",
618        ];
619
620        for content in invalid_values {
621            fs::write(&path, content).await.unwrap();
622            let error = validate_env_override_file(&path).await.unwrap_err();
623            let message = format!("{error:#}");
624            assert!(message.contains("TOKEN"), "missing key in: {message}");
625            assert!(
626                message.contains(path.to_str().unwrap()),
627                "missing path in: {message}"
628            );
629        }
630
631        fs::remove_dir_all(&dir).await.unwrap();
632    }
633
634    #[tokio::test]
635    async fn overlay_env_is_loaded_when_external_presets_are_active() {
636        let dir = make_temp_dir().await;
637        let overlay_dir = dir.join("overlay");
638        fs::create_dir_all(&overlay_dir).await.unwrap();
639        fs::write(
640            overlay_dir.join(PROJECT_ENV_FILE),
641            "OVERLAY_ONLY = \"yes\"\n",
642        )
643        .await
644        .unwrap();
645
646        let mut config = config_in(&dir);
647        config.presets_overlay_dir_override = Some(overlay_dir);
648        config.is_external_presets = true;
649        config.apply_overlay_env_override().await.unwrap();
650        assert_eq!(
651            config.env.get("OVERLAY_ONLY").map(String::as_str),
652            Some("yes")
653        );
654
655        fs::remove_dir_all(&dir).await.unwrap();
656    }
657
658    #[tokio::test]
659    async fn global_override_records_key_source() {
660        let dir = make_temp_dir().await;
661        fs::write(dir.join(PROJECT_ENV_FILE), "TOKEN = \"global\"\n")
662            .await
663            .unwrap();
664
665        let mut config = config_in(&dir);
666        config.apply_global_env_override().await.unwrap();
667
668        let source = config.env_override_source("TOKEN").unwrap();
669        assert_eq!(source.path, dir.join(PROJECT_ENV_FILE));
670        assert_eq!(source.kind, EnvOverrideKind::Global);
671        assert!(!source.is_managed_overlay);
672
673        fs::remove_dir_all(&dir).await.unwrap();
674    }
675
676    #[tokio::test]
677    async fn manual_overlay_override_records_unmanaged_source() {
678        let dir = make_temp_dir().await;
679        let overlay_dir = dir.join("overlay");
680        fs::create_dir_all(&overlay_dir).await.unwrap();
681        fs::write(overlay_dir.join(PROJECT_ENV_FILE), "TOKEN = \"overlay\"\n")
682            .await
683            .unwrap();
684
685        let mut config = config_in(&dir);
686        config.presets_overlay_dir_override = Some(overlay_dir.clone());
687        config.apply_overlay_env_override().await.unwrap();
688
689        let source = config.env_override_source("TOKEN").unwrap();
690        assert_eq!(source.path, overlay_dir.join(PROJECT_ENV_FILE));
691        assert_eq!(source.kind, EnvOverrideKind::Overlay);
692        assert!(
693            !source.is_managed_overlay,
694            "a manual overlay dir must not be reported as the managed mirror"
695        );
696
697        fs::remove_dir_all(&dir).await.unwrap();
698    }
699
700    #[tokio::test]
701    async fn managed_git_overlay_override_records_managed_source() {
702        let dir = make_temp_dir().await;
703        let overlay_dir = dir.join("overlay");
704        fs::create_dir_all(&overlay_dir).await.unwrap();
705        fs::write(overlay_dir.join(PROJECT_ENV_FILE), "TOKEN = \"overlay\"\n")
706            .await
707            .unwrap();
708
709        let mut config = config_in(&dir);
710        // No manual override configured; the managed checkout existing on disk
711        // is what `active_presets_overlay_dir()` falls back to.
712        config.managed_overlay_dir = Some(overlay_dir.clone());
713        config.apply_overlay_env_override().await.unwrap();
714
715        let source = config.env_override_source("TOKEN").unwrap();
716        assert_eq!(source.path, overlay_dir.join(PROJECT_ENV_FILE));
717        assert_eq!(source.kind, EnvOverrideKind::Overlay);
718        assert!(
719            source.is_managed_overlay,
720            "the shine-managed Git overlay mirror must be flagged as managed"
721        );
722
723        fs::remove_dir_all(&dir).await.unwrap();
724    }
725
726    #[tokio::test]
727    async fn later_override_layer_wins_the_recorded_source_for_shared_key() {
728        let dir = make_temp_dir().await;
729        let overlay_dir = dir.join("overlay");
730        let project_dir = dir.join("project");
731        fs::create_dir_all(&overlay_dir).await.unwrap();
732        fs::create_dir_all(&project_dir).await.unwrap();
733        fs::write(dir.join(PROJECT_ENV_FILE), "SHARED = \"global\"\n")
734            .await
735            .unwrap();
736        fs::write(overlay_dir.join(PROJECT_ENV_FILE), "SHARED = \"overlay\"\n")
737            .await
738            .unwrap();
739        fs::write(project_dir.join(PROJECT_ENV_FILE), "SHARED = \"project\"\n")
740            .await
741            .unwrap();
742
743        let mut config = config_in(&dir);
744        config.presets_overlay_dir_override = Some(overlay_dir.clone());
745        config.apply_global_env_override().await.unwrap();
746        config.apply_overlay_env_override().await.unwrap();
747        assert_eq!(
748            config.env_override_source("SHARED").unwrap().path,
749            overlay_dir.join(PROJECT_ENV_FILE),
750            "overlay layer must overwrite the global layer's recorded source"
751        );
752
753        config
754            .apply_project_env_override(&ProjectConfig {
755                path: project_dir.join(PROJECT_CONFIG_FILE),
756                root: project_dir.clone(),
757            })
758            .await
759            .unwrap();
760        assert_eq!(
761            config.env_override_source("SHARED").unwrap().path,
762            project_dir.join(PROJECT_ENV_FILE),
763            "project layer must overwrite the overlay layer's recorded source"
764        );
765
766        fs::remove_dir_all(&dir).await.unwrap();
767    }
768
769    #[tokio::test]
770    async fn config_toml_only_key_has_no_override_source() {
771        let dir = make_temp_dir().await;
772        let mut config = config_in(&dir);
773        config.env.insert("PLAIN".into(), "value".into());
774        config.apply_global_env_override().await.unwrap();
775        config.apply_overlay_env_override().await.unwrap();
776
777        assert!(config.env_override_source("PLAIN").is_none());
778
779        fs::remove_dir_all(&dir).await.unwrap();
780    }
781
782    #[tokio::test]
783    async fn write_env_override_entry_inserts_new_plain_key() {
784        let dir = make_temp_dir().await;
785        let path = dir.join(PROJECT_ENV_FILE);
786
787        write_env_override_entry(&path, "TOKEN", Some("secret"))
788            .await
789            .unwrap();
790
791        let content = fs::read_to_string(&path).await.unwrap();
792        let table: toml::Table = toml::from_str(&content).unwrap();
793        assert_eq!(
794            table.get("TOKEN").and_then(toml::Value::as_str),
795            Some("secret")
796        );
797
798        fs::remove_dir_all(&dir).await.unwrap();
799    }
800
801    #[tokio::test]
802    async fn write_env_override_entry_updates_existing_plain_key_and_keeps_comments() {
803        let dir = make_temp_dir().await;
804        let path = dir.join(PROJECT_ENV_FILE);
805        // The comment guards an *unchanged* key: sync_table only preserves
806        // formatting/comments for entries it doesn't have to touch.
807        fs::write(
808            &path,
809            "TOKEN = \"old\"\n# keep this comment\nOTHER = \"unchanged\"\n",
810        )
811        .await
812        .unwrap();
813
814        write_env_override_entry(&path, "TOKEN", Some("new"))
815            .await
816            .unwrap();
817
818        let content = fs::read_to_string(&path).await.unwrap();
819        assert!(content.contains("# keep this comment"));
820        assert!(content.contains("TOKEN = \"new\""));
821        assert!(content.contains("OTHER = \"unchanged\""));
822
823        fs::remove_dir_all(&dir).await.unwrap();
824    }
825
826    #[tokio::test]
827    async fn write_env_override_entry_updates_value_and_preserves_description() {
828        let dir = make_temp_dir().await;
829        let path = dir.join(PROJECT_ENV_FILE);
830        fs::write(
831            &path,
832            "TOKEN = { value = \"old\", description = \"Internal token\" }\n",
833        )
834        .await
835        .unwrap();
836
837        write_env_override_entry(&path, "TOKEN", Some("new"))
838            .await
839            .unwrap();
840
841        let content = fs::read_to_string(&path).await.unwrap();
842        assert!(content.contains("TOKEN = { value = \"new\", description = \"Internal token\" }"));
843
844        fs::remove_dir_all(&dir).await.unwrap();
845    }
846
847    #[tokio::test]
848    async fn write_env_override_entry_removes_key() {
849        let dir = make_temp_dir().await;
850        let path = dir.join(PROJECT_ENV_FILE);
851        fs::write(&path, "TOKEN = \"secret\"\nOTHER = \"unchanged\"\n")
852            .await
853            .unwrap();
854
855        write_env_override_entry(&path, "TOKEN", None)
856            .await
857            .unwrap();
858
859        let content = fs::read_to_string(&path).await.unwrap();
860        let table: toml::Table = toml::from_str(&content).unwrap();
861        assert!(!table.contains_key("TOKEN"));
862        assert_eq!(
863            table.get("OTHER").and_then(toml::Value::as_str),
864            Some("unchanged")
865        );
866
867        fs::remove_dir_all(&dir).await.unwrap();
868    }
869
870    #[tokio::test]
871    async fn write_env_override_entry_creates_missing_file_and_parent_dir() {
872        let dir = make_temp_dir().await;
873        let path = dir.join("nested").join(PROJECT_ENV_FILE);
874
875        write_env_override_entry(&path, "TOKEN", Some("secret"))
876            .await
877            .unwrap();
878
879        let content = fs::read_to_string(&path).await.unwrap();
880        let table: toml::Table = toml::from_str(&content).unwrap();
881        assert_eq!(
882            table.get("TOKEN").and_then(toml::Value::as_str),
883            Some("secret")
884        );
885
886        fs::remove_dir_all(&dir).await.unwrap();
887    }
888}