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