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