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 self_install_dest: Option<PathBuf>,
32 #[serde(default)]
33 gpg_recipients: Vec<String>,
34 #[serde(rename = "gpg_key_id")]
35 legacy_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 age_identities: Option<Vec<String>>,
43 #[serde(default, deserialize_with = "deserialize_env_values")]
44 env: BTreeMap<String, String>,
45 #[serde(default)]
46 env_proxy: Option<Vec<EnvProxyRule>>,
47}
48
49impl Config {
50 pub async fn init_current_dir_config() -> Result<PathBuf> {
51 let current_dir = std::env::current_dir().context("resolving current directory")?;
52 let (default_shine_dir, _) = default_config_and_presets_dir()?;
53 let preliminary_shine_dir = preliminary_shine_dir_from_env(&default_shine_dir);
54 if let Some(project_config) = find_project_config(¤t_dir) {
55 bail!(
56 "{} already exists; current directory is already under a shine project at {}",
57 project_config.path.display(),
58 project_config.root.display()
59 );
60 }
61
62 let config_path = current_dir.join(PROJECT_CONFIG_FILE);
63
64 let presets_dir = tokio::fs::canonicalize(¤t_dir)
65 .await
66 .unwrap_or_else(|_| current_dir.clone());
67 let shine_dir = preliminary_shine_dir;
68
69 let config = Config {
70 config_path: config_path.clone(),
71 is_project_config: true,
72 project_save_state: None,
73 shine_dir: shine_dir.clone(),
74 presets_dir: presets_dir.clone(),
75 bin_dir: shine_dir.join("bin"),
76 home_dir: effective_home_dir(),
77 presets_dir_override: Some(PathBuf::from(".")),
78 presets_overlay_dir_override: None,
79 is_external_presets: true,
80 ..Config::default()
81 };
82
83 config.save().await?;
84 Ok(config_path)
85 }
86
87 pub async fn load_or_init() -> Result<Self> {
88 let (default_shine_dir, default_presets_dir) = default_config_and_presets_dir()?;
89 let current_dir = std::env::current_dir().context("resolving current directory")?;
90 let project_config = find_project_config(¤t_dir);
91 let Some(project_config) = project_config else {
92 return Self::load_global_runtime_or_init().await;
93 };
94 let (mut config, global_exists) = Self::load_global_runtime_base().await?;
96 let contents = fs::read_to_string(&project_config.path)
97 .await
98 .context("Failed to read project config file")?;
99 let original: toml::Table =
100 toml::from_str(&contents).context("Failed to parse project config file")?;
101 let overrides: ProjectOverrides =
102 toml::from_str(&contents).context("Failed to parse project config file")?;
103 fs::create_dir_all(config.shine_dir()).await?;
104 fs::create_dir_all(config.presets_dir()).await?;
105 fs::create_dir_all(config.bin_dir()).await?;
106 let global_has_env = if global_exists {
107 let contents = fs::read_to_string(config.config_path()).await?;
108 config_toml_has_env_table(&contents)
109 } else {
110 false
111 };
112 config.ensure_env_defaults(global_has_env).await?;
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(path) = overrides.self_install_dest {
129 config.self_install_dest =
130 Some(resolve_config_presets_path(&path, &project_config.root));
131 }
132 if !overrides.gpg_recipients.is_empty() {
133 config.gpg_recipients = overrides.gpg_recipients;
134 }
135 if overrides.legacy_gpg_key_id.is_some() {
136 config.legacy_gpg_key_id = overrides.legacy_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 let project_overrides_age_identities =
145 overrides.age_identity.is_some() || overrides.age_identities.is_some();
146 if project_overrides_age_identities {
147 config.age_identity = overrides.age_identity;
148 config.age_identities = overrides.age_identities.unwrap_or_default();
149 }
150 config.env.extend(overrides.env);
151 if let Some(project_rules) = overrides.env_proxy {
152 for rule in project_rules {
153 config
154 .env_proxy
155 .retain(|existing| existing.command != rule.command);
156 config.env_proxy.push(rule);
157 }
158 }
159 config
160 .env_descriptions
161 .extend(parse_env_descriptions(&contents));
162
163 let effective_presets = project_presets.clone().or_else(|| {
164 config
165 .is_external_presets
166 .then(|| config.presets_dir().to_path_buf())
167 });
168 if let Some(path) = &effective_presets {
169 config.presets_dir_override = Some(path.clone());
170 }
171 let runtime_presets = if project_presets.is_none()
174 && std::env::var("SHINE_CONFIG_DIR").is_ok_and(|value| !value.trim().is_empty())
175 {
176 None
177 } else {
178 effective_presets.clone()
179 };
180 let (shine_dir, presets_dir, is_external_presets) = resolve_runtime_config_dirs(
181 &default_shine_dir,
182 &default_presets_dir,
183 runtime_presets.as_deref(),
184 true,
185 );
186 config.config_path = project_config.path.clone();
187 config.is_project_config = true;
188 config.project_overrides_age_identities = project_overrides_age_identities;
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 if config
268 .secret_backend
269 .as_deref()
270 .is_some_and(|b| b.trim().eq_ignore_ascii_case("hybrid"))
271 {
272 bail!("global secret_backend cannot be hybrid; use workspace access lists");
273 }
274 if let Some(value) = &config.hybrid_decrypt_backend {
275 match value.parse::<crate::secret::BackendKind>()? {
276 crate::secret::BackendKind::Gpg | crate::secret::BackendKind::Age => {}
277 _ => bail!("hybrid_decrypt_backend must be gpg or age"),
278 }
279 }
280 config.env_descriptions = parse_env_descriptions(&contents);
281 config.config_path = config_path.clone();
282 config.is_project_config = false;
283 config.shine_dir = shine_dir;
284 config.presets_dir = presets_dir;
285 config.bin_dir = bin_dir;
286 config.home_dir = home_dir;
287 config.is_external_presets = is_external_presets;
288 config.resolve_presets_overlay_dir(&config_dir);
289 config.resolve_managed_overlay_dir();
290 if let Some(path) = config.app_default_dest_root_override.as_deref() {
291 config.app_default_dest_root_override =
292 Some(resolve_config_presets_path(path, &config_dir));
293 }
294 if let Some(path) = config.self_install_dest.as_deref() {
295 config.self_install_dest = Some(resolve_config_presets_path(path, &config_dir));
296 }
297 crate::presets::set_overlay_dir(config.active_presets_overlay_dir());
298 Ok((config, true))
299 } else {
300 let config = Config {
301 config_path: config_path.clone(),
302 is_project_config: false,
303 project_save_state: None,
304 shine_dir,
305 presets_dir,
306 bin_dir,
307 home_dir,
308 is_external_presets,
309 ..Config::default()
310 };
311 crate::presets::set_overlay_dir(config.active_presets_overlay_dir());
312 Ok((config, false))
313 }
314 }
315
316 pub async fn read_global_runtime_schema_version() -> Result<u32> {
317 let (default_shine_dir, _) = default_config_and_presets_dir()?;
318 let config_path =
319 preliminary_shine_dir_from_env(&default_shine_dir).join(GLOBAL_CONFIG_FILE);
320 let content = match fs::read_to_string(&config_path).await {
321 Ok(content) => content,
322 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
323 return Ok(super::CURRENT_RUNTIME_SCHEMA_VERSION);
324 }
325 Err(e) => {
326 return Err(e).with_context(|| format!("Failed to read {}", config_path.display()));
327 }
328 };
329
330 read_minimal_config(&content)
331 .map(|config| config.schema_version)
332 .with_context(|| format!("Failed to parse {}", config_path.display()))
333 }
334}
335
336fn config_toml_has_env_table(contents: &str) -> bool {
337 toml::from_str::<toml::Table>(contents)
338 .map(|table| table.contains_key("env"))
339 .unwrap_or(false)
340}
341
342#[cfg(test)]
343mod tests {
344 use super::super::test_util::{make_temp_dir, restore_current_dir};
345 use super::*;
346 use crate::config::CURRENT_RUNTIME_SCHEMA_VERSION;
347 use crate::test_support::env_lock;
348
349 #[allow(clippy::await_holding_lock)]
350 #[tokio::test(flavor = "current_thread")]
351 async fn load_or_init_creates_bin_dir() {
352 let _guard = env_lock();
353 let dir = make_temp_dir().await;
354 unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
357
358 let config = Config::load_or_init().await.unwrap();
359 assert!(config.bin_dir().exists(), "bin dir should be created");
360 assert_eq!(config.bin_dir(), dir.join("bin"));
361
362 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
365 fs::remove_dir_all(&dir).await.unwrap();
366 }
367
368 #[allow(clippy::await_holding_lock)]
369 #[tokio::test(flavor = "current_thread")]
370 async fn load_or_init_creates_env_table_in_config() {
371 let _guard = env_lock();
372 let dir = make_temp_dir().await;
373 unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
376
377 let config = Config::load_or_init().await.unwrap();
378
379 assert_eq!(
380 config.env.get("HTTP_PROXY_PORT").map(String::as_str),
381 Some("6152")
382 );
383 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
384 let parsed: toml::Table = toml::from_str(&content).unwrap();
385 assert!(
386 parsed.get("env").is_some(),
387 "config.toml should contain [env]"
388 );
389
390 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
393 fs::remove_dir_all(&dir).await.unwrap();
394 }
395
396 #[allow(clippy::await_holding_lock)]
397 #[tokio::test(flavor = "current_thread")]
398 async fn load_or_init_backfills_missing_env_defaults() {
399 let _guard = env_lock();
400 let dir = make_temp_dir().await;
401 fs::write(dir.join("config.toml"), "[env]\nCUSTOM = \"kept\"\n")
402 .await
403 .unwrap();
404
405 unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
408
409 let config = Config::load_or_init().await.unwrap();
410
411 assert_eq!(config.env.get("CUSTOM").map(String::as_str), Some("kept"));
412 assert_eq!(
413 config.env.get("HTTP_PROXY_PORT").map(String::as_str),
414 Some("6152")
415 );
416 assert_eq!(
417 config.env.get("SOCKS5_PROXY_PORT").map(String::as_str),
418 Some("6153")
419 );
420 assert_eq!(
421 config.env.get("IMAGE_QUALITY").map(String::as_str),
422 Some("80")
423 );
424 assert_eq!(
425 config.env.get("IMAGE_MAX_WIDTH").map(String::as_str),
426 Some("1920")
427 );
428 assert_eq!(
429 config.env.get("IMAGE_MAX_HEIGHT").map(String::as_str),
430 Some("1080")
431 );
432 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
433 assert!(content.contains("CUSTOM = \"kept\""));
434 assert!(content.contains("HTTP_PROXY_PORT = \"6152\""));
435 assert!(content.contains("SOCKS5_PROXY_PORT = \"6153\""));
436 assert!(content.contains("IMAGE_QUALITY = \"80\""));
437 assert!(content.contains("IMAGE_MAX_WIDTH = \"1920\""));
438 assert!(content.contains("IMAGE_MAX_HEIGHT = \"1080\""));
439
440 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
443 fs::remove_dir_all(&dir).await.unwrap();
444 }
445
446 #[allow(clippy::await_holding_lock)]
447 #[tokio::test(flavor = "current_thread")]
448 async fn load_or_init_discovers_project_config_from_child_dir() {
449 let _guard = env_lock();
450 let original_dir = std::env::current_dir().unwrap();
451 let project_dir = make_temp_dir().await;
452 let child_dir = project_dir.join("presets/shell/proxy");
453 fs::create_dir_all(&child_dir).await.unwrap();
454 let state_dir = make_temp_dir().await;
455 fs::write(
456 project_dir.join("shine.config.toml"),
457 "presets_dir = \".\"\n[env]\nHTTP_PROXY_PORT = \"1111\"\nCONFIG_ONLY = \"config\"\n",
458 )
459 .await
460 .unwrap();
461 fs::write(
462 project_dir.join("shine.env.toml"),
463 "HTTP_PROXY_PORT = \"2222\"\nDOTENV_ONLY = \"dotenv\"\n",
464 )
465 .await
466 .unwrap();
467 fs::write(
468 project_dir.join(".env.toml"),
469 "HTTP_PROXY_PORT = \"3333\"\n",
470 )
471 .await
472 .unwrap();
473
474 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
477 unsafe { std::env::remove_var("SHINE_PRESETS") };
480 std::env::set_current_dir(&child_dir).unwrap();
481
482 let config = Config::load_or_init().await.unwrap();
483
484 assert_eq!(
485 fs::canonicalize(&config.config_path).await.unwrap(),
486 fs::canonicalize(project_dir.join("shine.config.toml"))
487 .await
488 .unwrap()
489 );
490 assert_eq!(config.shine_dir(), state_dir);
491 assert_eq!(config.bin_dir(), state_dir.join("bin"));
492 assert_eq!(
493 fs::canonicalize(config.presets_dir()).await.unwrap(),
494 fs::canonicalize(&project_dir).await.unwrap()
495 );
496 assert_eq!(
497 config.env.get("HTTP_PROXY_PORT").map(String::as_str),
498 Some("2222")
499 );
500 assert_eq!(
501 config.env.get("CONFIG_ONLY").map(String::as_str),
502 Some("config")
503 );
504 assert_eq!(
505 config.env.get("DOTENV_ONLY").map(String::as_str),
506 Some("dotenv")
507 );
508
509 restore_current_dir(&original_dir);
510 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
513 fs::remove_dir_all(&project_dir).await.unwrap();
514 fs::remove_dir_all(&state_dir).await.unwrap();
515 }
516
517 #[allow(clippy::await_holding_lock)]
518 #[tokio::test(flavor = "current_thread")]
519 async fn load_global_runtime_ignores_project_config() {
520 let _guard = env_lock();
521 let original_dir = std::env::current_dir().unwrap();
522 let project_dir = make_temp_dir().await;
523 let child_dir = project_dir.join("subdir");
524 fs::create_dir_all(&child_dir).await.unwrap();
525 let state_dir = make_temp_dir().await;
526 fs::write(
527 project_dir.join("shine.config.toml"),
528 "schema_version = 7\npresets_dir = \".\"\n",
529 )
530 .await
531 .unwrap();
532 fs::write(
533 state_dir.join("config.toml"),
534 "schema_version = 0\nlast_cleared_schema_version = 0\n",
535 )
536 .await
537 .unwrap();
538
539 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
542 std::env::set_current_dir(&child_dir).unwrap();
543
544 let config = Config::load_global_runtime_or_init().await.unwrap();
545
546 assert_eq!(
547 fs::canonicalize(config.config_path()).await.unwrap(),
548 fs::canonicalize(state_dir.join("config.toml"))
549 .await
550 .unwrap()
551 );
552 assert_eq!(config.schema_version, 0);
553 assert_eq!(config.last_cleared_schema_version, Some(0));
554 assert_eq!(config.shine_dir(), state_dir);
555
556 restore_current_dir(&original_dir);
557 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
560 fs::remove_dir_all(&project_dir).await.unwrap();
561 fs::remove_dir_all(&state_dir).await.unwrap();
562 }
563
564 #[allow(clippy::await_holding_lock)]
565 #[tokio::test(flavor = "current_thread")]
566 async fn load_global_runtime_for_dry_run_does_not_create_state() {
567 let _guard = env_lock();
568 let dir = std::env::temp_dir().join(format!("shine-dry-run-{}", uuid::Uuid::new_v4()));
569 assert!(!dir.exists());
570
571 unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
574
575 let config = Config::load_global_runtime_for_dry_run().await.unwrap();
576
577 assert_eq!(config.config_path(), dir.join("config.toml"));
578 assert_eq!(config.schema_version, CURRENT_RUNTIME_SCHEMA_VERSION);
579 assert!(!dir.exists(), "dry-run loader must not create state dir");
580
581 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
584 }
585
586 #[allow(clippy::await_holding_lock)]
587 #[tokio::test(flavor = "current_thread")]
588 async fn load_global_runtime_for_dry_run_ignores_removed_global_env_file() {
589 let _guard = env_lock();
590 let dir = make_temp_dir().await;
591 let removed_path = dir.join("env.toml");
592 fs::write(&removed_path, "CUSTOM_TOKEN = \"abc\"\n")
593 .await
594 .unwrap();
595
596 unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
599
600 let config = Config::load_global_runtime_for_dry_run().await.unwrap();
601
602 assert_eq!(config.config_path(), dir.join("config.toml"));
603 assert!(removed_path.exists());
604 assert!(!dir.join("config.toml").exists());
605
606 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
609 fs::remove_dir_all(&dir).await.unwrap();
610 }
611
612 #[allow(clippy::await_holding_lock)]
613 #[tokio::test(flavor = "current_thread")]
614 async fn global_config_with_presets_dir_remains_global_config() {
615 let _guard = env_lock();
616 let original_dir = std::env::current_dir().unwrap();
617 let original_home = std::env::var("HOME").ok();
618 let home_dir = make_temp_dir().await;
619 let shine_dir = home_dir.join(".shine");
620 let child_dir = shine_dir.join("presets/shell/proxy");
621 let external_presets = make_temp_dir().await.join("presets");
622 fs::create_dir_all(&child_dir).await.unwrap();
623 fs::create_dir_all(&external_presets).await.unwrap();
624 fs::write(
625 shine_dir.join("config.toml"),
626 format!(
627 "schema_version = 1\npresets_dir = {}\n",
628 toml::Value::String(external_presets.to_string_lossy().into_owned())
629 ),
630 )
631 .await
632 .unwrap();
633
634 unsafe { std::env::set_var("HOME", home_dir.to_str().unwrap()) };
637 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
640 unsafe { std::env::remove_var("SHINE_PRESETS") };
643 std::env::set_current_dir(&child_dir).unwrap();
644
645 let config = Config::load_or_init().await.unwrap();
646
647 assert_eq!(
648 fs::canonicalize(config.config_path()).await.unwrap(),
649 fs::canonicalize(shine_dir.join("config.toml"))
650 .await
651 .unwrap()
652 );
653 assert!(
654 !config.is_project_config,
655 "global config.toml must not be treated as a project config"
656 );
657 assert_eq!(
658 fs::canonicalize(config.presets_dir()).await.unwrap(),
659 fs::canonicalize(&external_presets).await.unwrap()
660 );
661
662 restore_current_dir(&original_dir);
663 match original_home {
664 Some(home) => {
665 unsafe { std::env::set_var("HOME", home) };
668 }
669 None => {
670 unsafe { std::env::remove_var("HOME") };
673 }
674 }
675 fs::remove_dir_all(&home_dir).await.unwrap();
676 fs::remove_dir_all(external_presets.parent().unwrap())
677 .await
678 .unwrap();
679 }
680
681 #[allow(clippy::await_holding_lock)]
682 #[tokio::test(flavor = "current_thread")]
683 async fn load_or_init_ignores_generic_project_config_and_dotenv() {
684 let _guard = env_lock();
685 let original_dir = std::env::current_dir().unwrap();
686 let project_dir = make_temp_dir().await;
687 let child_dir = project_dir.join("subdir");
688 fs::create_dir_all(&child_dir).await.unwrap();
689 let state_dir = make_temp_dir().await;
690 fs::write(
691 project_dir.join("config.toml"),
692 "presets_dir = \".\"\n[env]\nHTTP_PROXY_PORT = \"1111\"\n",
693 )
694 .await
695 .unwrap();
696 fs::write(
697 project_dir.join(".env.toml"),
698 "HTTP_PROXY_PORT = \"3333\"\n",
699 )
700 .await
701 .unwrap();
702
703 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
706 unsafe { std::env::remove_var("SHINE_PRESETS") };
709 std::env::set_current_dir(&child_dir).unwrap();
710
711 let config = Config::load_or_init().await.unwrap();
712
713 assert_eq!(
714 fs::canonicalize(&config.config_path).await.unwrap(),
715 fs::canonicalize(state_dir.join("config.toml"))
716 .await
717 .unwrap()
718 );
719 assert!(!config.is_project_config);
720 assert_eq!(
721 fs::canonicalize(config.presets_dir()).await.unwrap(),
722 fs::canonicalize(state_dir.join("presets")).await.unwrap()
723 );
724 assert_eq!(
725 config.env.get("HTTP_PROXY_PORT").map(String::as_str),
726 Some("6152")
727 );
728
729 restore_current_dir(&original_dir);
730 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
733 fs::remove_dir_all(&project_dir).await.unwrap();
734 fs::remove_dir_all(&state_dir).await.unwrap();
735 }
736
737 #[allow(clippy::await_holding_lock)]
738 #[tokio::test(flavor = "current_thread")]
739 async fn init_current_dir_config_ignores_generic_config_and_refuses_existing_shine_config() {
740 let _guard = env_lock();
741 let original_dir = std::env::current_dir().unwrap();
742 let project_dir = make_temp_dir().await;
743 fs::write(
744 project_dir.join("config.toml"),
745 "presets_dir = \"other-tool\"\n",
746 )
747 .await
748 .unwrap();
749 std::env::set_current_dir(&project_dir).unwrap();
750
751 let path = Config::init_current_dir_config().await.unwrap();
752 assert_eq!(
753 path.file_name().and_then(|name| name.to_str()),
754 Some("shine.config.toml")
755 );
756 assert_eq!(
757 fs::canonicalize(path.parent().unwrap()).await.unwrap(),
758 fs::canonicalize(&project_dir).await.unwrap()
759 );
760
761 let content = fs::read_to_string(&path).await.unwrap();
762 let parsed: toml::Table = toml::from_str(&content).unwrap();
763 assert_eq!(
764 parsed.get("presets_dir").and_then(|value| value.as_str()),
765 Some(".")
766 );
767 assert!(
768 !parsed.contains_key("schema_version"),
769 "project config must not persist runtime schema_version"
770 );
771 assert!(
772 !parsed.contains_key("last_cleared_schema_version"),
773 "project config must not persist runtime clear state"
774 );
775
776 let err = Config::init_current_dir_config().await.unwrap_err();
777 assert!(
778 err.to_string().contains("already exists"),
779 "error should refuse overwrite: {err:#}"
780 );
781
782 restore_current_dir(&original_dir);
783 fs::remove_dir_all(&project_dir).await.unwrap();
784 }
785
786 #[allow(clippy::await_holding_lock)]
787 #[tokio::test(flavor = "current_thread")]
788 async fn project_config_save_removes_runtime_schema_fields() {
789 let _guard = env_lock();
790 let original_dir = std::env::current_dir().unwrap();
791 let project_dir = make_temp_dir().await;
792 let state_dir = make_temp_dir().await;
793 fs::write(
794 project_dir.join("shine.config.toml"),
795 "schema_version = 0\nlast_cleared_schema_version = 0\npresets_dir = \".\"\n",
796 )
797 .await
798 .unwrap();
799
800 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
803 std::env::set_current_dir(&project_dir).unwrap();
804
805 let config = Config::load_or_init().await.unwrap();
806 assert_eq!(config.schema_version, CURRENT_RUNTIME_SCHEMA_VERSION);
807 assert_eq!(config.last_cleared_schema_version, None);
808
809 config.save().await.unwrap();
810
811 let content = fs::read_to_string(project_dir.join("shine.config.toml"))
812 .await
813 .unwrap();
814 let parsed: toml::Table = toml::from_str(&content).unwrap();
815 assert_eq!(
816 parsed.get("presets_dir").and_then(|value| value.as_str()),
817 Some(".")
818 );
819 assert!(!parsed.contains_key("schema_version"));
820 assert!(!parsed.contains_key("last_cleared_schema_version"));
821
822 restore_current_dir(&original_dir);
823 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
826 fs::remove_dir_all(&project_dir).await.unwrap();
827 fs::remove_dir_all(&state_dir).await.unwrap();
828 }
829
830 #[allow(clippy::await_holding_lock)]
831 #[tokio::test(flavor = "current_thread")]
832 async fn project_config_without_presets_dir_inherits_global_config() {
833 let _guard = env_lock();
834 let original_dir = std::env::current_dir().unwrap();
835 let original_home = std::env::var("HOME").ok();
836 let project_dir = make_temp_dir().await;
837 let home_dir = make_temp_dir().await;
838 let state_dir = home_dir.join(".shine");
839 fs::create_dir_all(&state_dir).await.unwrap();
840 let global_presets = state_dir.join("shared-presets");
841 fs::create_dir_all(&global_presets).await.unwrap();
842 fs::write(
843 state_dir.join("config.toml"),
844 "presets_dir = \"shared-presets\"\ngpg_recipients = [\"global-key\"]\n",
845 )
846 .await
847 .unwrap();
848 fs::write(
849 project_dir.join("shine.config.toml"),
850 "[env]\nLOCAL = \"yes\"\n",
851 )
852 .await
853 .unwrap();
854
855 unsafe { std::env::set_var("HOME", home_dir.to_str().unwrap()) };
856 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
857 unsafe { std::env::remove_var("SHINE_PRESETS") };
858 std::env::set_current_dir(&project_dir).unwrap();
859
860 let config = Config::load_or_init().await.unwrap();
861 assert_eq!(
862 fs::canonicalize(config.presets_dir()).await.unwrap(),
863 fs::canonicalize(&global_presets).await.unwrap()
864 );
865 assert_eq!(config.gpg_recipients, ["global-key"]);
866 assert!(config.is_external_presets);
867
868 restore_current_dir(&original_dir);
869 match original_home {
870 Some(home) => unsafe { std::env::set_var("HOME", home) },
871 None => unsafe { std::env::remove_var("HOME") },
872 }
873 fs::remove_dir_all(&project_dir).await.unwrap();
874 fs::remove_dir_all(&home_dir).await.unwrap();
875 }
876
877 #[allow(clippy::await_holding_lock)]
878 #[tokio::test(flavor = "current_thread")]
879 async fn project_config_merges_layers_and_saves_only_local_changes() {
880 let _guard = env_lock();
881 let original_dir = std::env::current_dir().unwrap();
882 let project_dir = make_temp_dir().await;
883 let state_dir = make_temp_dir().await;
884 fs::create_dir_all(project_dir.join("project-presets"))
885 .await
886 .unwrap();
887 fs::write(
888 state_dir.join("config.toml"),
889 "presets_dir = \"global-presets\"\ngpg_recipients = [\"global-key\"]\n[env]\nGLOBAL = \"config\"\nSHARED = \"global-config\"\n",
890 )
891 .await
892 .unwrap();
893 fs::write(
894 state_dir.join("shine.env.toml"),
895 "SHARED = \"global-env\"\nGLOBAL_FILE = \"yes\"\n",
896 )
897 .await
898 .unwrap();
899 fs::write(
900 project_dir.join("shine.config.toml"),
901 "presets_dir = \"project-presets\"\n[env]\nPROJECT = \"config\"\nSHARED = \"project-config\"\n",
902 )
903 .await
904 .unwrap();
905 fs::write(
906 project_dir.join("shine.env.toml"),
907 "SHARED = \"project-env\"\nPROJECT_FILE = \"yes\"\n",
908 )
909 .await
910 .unwrap();
911
912 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
913 unsafe { std::env::remove_var("SHINE_PRESETS") };
914 std::env::set_current_dir(&project_dir).unwrap();
915
916 let mut config = Config::load_or_init().await.unwrap();
917 assert_eq!(
918 fs::canonicalize(config.presets_dir()).await.unwrap(),
919 fs::canonicalize(project_dir.join("project-presets"))
920 .await
921 .unwrap()
922 );
923 assert_eq!(config.env.get("GLOBAL").map(String::as_str), Some("config"));
924 assert_eq!(
925 config.env.get("PROJECT").map(String::as_str),
926 Some("config")
927 );
928 assert_eq!(
929 config.env.get("GLOBAL_FILE").map(String::as_str),
930 Some("yes")
931 );
932 assert_eq!(
933 config.env.get("PROJECT_FILE").map(String::as_str),
934 Some("yes")
935 );
936 assert_eq!(
937 config.env.get("SHARED").map(String::as_str),
938 Some("project-env")
939 );
940
941 config.env.insert("ADDED".into(), "new".into());
942 config.save().await.unwrap();
943 let saved = fs::read_to_string(project_dir.join("shine.config.toml"))
944 .await
945 .unwrap();
946 let table: toml::Table = toml::from_str(&saved).unwrap();
947 assert_eq!(
948 table.get("presets_dir").and_then(toml::Value::as_str),
949 Some("project-presets")
950 );
951 assert!(!table.contains_key("gpg_recipients"));
952 let env = table.get("env").and_then(toml::Value::as_table).unwrap();
953 assert_eq!(env.get("ADDED").and_then(toml::Value::as_str), Some("new"));
954 assert!(!env.contains_key("GLOBAL"));
955 assert!(!env.contains_key("GLOBAL_FILE"));
956 assert!(!env.contains_key("PROJECT_FILE"));
957
958 restore_current_dir(&original_dir);
959 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
960 fs::remove_dir_all(&project_dir).await.unwrap();
961 fs::remove_dir_all(&state_dir).await.unwrap();
962 }
963
964 #[allow(clippy::await_holding_lock)]
965 #[tokio::test(flavor = "current_thread")]
966 async fn project_config_overrides_age_backend_settings() {
967 let _guard = env_lock();
968 let original_dir = std::env::current_dir().unwrap();
969 let project_dir = make_temp_dir().await;
970 let state_dir = make_temp_dir().await;
971 fs::write(
972 state_dir.join("config.toml"),
973 "hybrid_decrypt_backend = \"gpg\"\nsecret_backend = \"gpg\"\nage_recipients = [\"age1global\"]\nage_identity = \"~/.shine/age/global.txt\"\n",
974 )
975 .await
976 .unwrap();
977 fs::write(
978 project_dir.join("shine.config.toml"),
979 "hybrid_decrypt_backend = \"age\"\npresets_dir = \".\"\nsecret_backend = \"age\"\nage_recipients = [\"age1project-a\", \"age1project-b\"]\n",
980 )
981 .await
982 .unwrap();
983
984 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
985 unsafe { std::env::remove_var("SHINE_PRESETS") };
986 std::env::set_current_dir(&project_dir).unwrap();
987
988 let config = Config::load_or_init().await.unwrap();
989
990 assert_eq!(config.secret_backend.as_deref(), Some("age"));
991 assert_eq!(config.hybrid_decrypt_backend.as_deref(), Some("gpg"));
992 config.save().await.unwrap();
993 let saved = fs::read_to_string(config.config_path()).await.unwrap();
994 assert!(!saved.contains("hybrid_decrypt_backend"));
995 assert_eq!(
996 config.age_recipients,
997 vec!["age1project-a".to_string(), "age1project-b".to_string()]
998 );
999 assert_eq!(
1001 config.age_identity.as_deref(),
1002 Some("~/.shine/age/global.txt")
1003 );
1004 assert!(!config.project_overrides_age_identities());
1005
1006 restore_current_dir(&original_dir);
1007 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
1008 fs::remove_dir_all(&project_dir).await.unwrap();
1009 fs::remove_dir_all(&state_dir).await.unwrap();
1010 }
1011
1012 #[allow(clippy::await_holding_lock)]
1013 #[tokio::test(flavor = "current_thread")]
1014 async fn project_age_identities_replace_the_global_identity_set() {
1015 let _guard = env_lock();
1016 let original_dir = std::env::current_dir().unwrap();
1017 let project_dir = make_temp_dir().await;
1018 let state_dir = make_temp_dir().await;
1019 fs::write(
1020 state_dir.join("config.toml"),
1021 "age_identity = \"global-primary.txt\"\nage_identities = [\"global-extra.txt\"]\n",
1022 )
1023 .await
1024 .unwrap();
1025 fs::write(
1026 project_dir.join("shine.config.toml"),
1027 "presets_dir = \".\"\nage_identities = [\"project-phone.txt\", \"project-recovery.txt\"]\n",
1028 )
1029 .await
1030 .unwrap();
1031
1032 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
1033 unsafe { std::env::remove_var("SHINE_PRESETS") };
1034 std::env::set_current_dir(&project_dir).unwrap();
1035
1036 let config = Config::load_or_init().await.unwrap();
1037 assert!(config.age_identity.is_none());
1038 assert_eq!(
1039 config.age_identities,
1040 vec![
1041 "project-phone.txt".to_string(),
1042 "project-recovery.txt".to_string()
1043 ]
1044 );
1045 assert!(config.project_overrides_age_identities());
1046
1047 restore_current_dir(&original_dir);
1048 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
1049 fs::remove_dir_all(&project_dir).await.unwrap();
1050 fs::remove_dir_all(&state_dir).await.unwrap();
1051 }
1052}