1use 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(¤t_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(¤t_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(¤t_dir);
85 let Some(project_config) = project_config else {
86 return Self::load_global_runtime_or_init().await;
87 };
88 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 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 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 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 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 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 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 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 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 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
432 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 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 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 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 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 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 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 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 unsafe { std::env::set_var("HOME", home_dir.to_str().unwrap()) };
592 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
595 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 unsafe { std::env::set_var("HOME", home) };
623 }
624 None => {
625 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 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
661 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 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 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 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 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}