1use 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 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
188pub 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 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 utils::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 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 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 unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
350 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 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 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
411 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 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 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 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 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 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}