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 assert_eq!(
408 config.env.get("IMAGE_QUALITY").map(String::as_str),
409 Some("80")
410 );
411 assert_eq!(
412 config.env.get("IMAGE_MAX_WIDTH").map(String::as_str),
413 Some("1920")
414 );
415 assert_eq!(
416 config.env.get("IMAGE_MAX_HEIGHT").map(String::as_str),
417 Some("1080")
418 );
419 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
420 assert!(content.contains("CUSTOM = \"kept\""));
421 assert!(content.contains("HTTP_PROXY_PORT = \"6152\""));
422 assert!(content.contains("SOCKS5_PROXY_PORT = \"6153\""));
423 assert!(content.contains("IMAGE_QUALITY = \"80\""));
424 assert!(content.contains("IMAGE_MAX_WIDTH = \"1920\""));
425 assert!(content.contains("IMAGE_MAX_HEIGHT = \"1080\""));
426
427 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
430 fs::remove_dir_all(&dir).await.unwrap();
431 }
432
433 #[allow(clippy::await_holding_lock)]
434 #[tokio::test(flavor = "current_thread")]
435 async fn load_or_init_discovers_project_config_from_child_dir() {
436 let _guard = env_lock();
437 let original_dir = std::env::current_dir().unwrap();
438 let project_dir = make_temp_dir().await;
439 let child_dir = project_dir.join("presets/shell/proxy");
440 fs::create_dir_all(&child_dir).await.unwrap();
441 let state_dir = make_temp_dir().await;
442 fs::write(
443 project_dir.join("shine.config.toml"),
444 "presets_dir = \".\"\n[env]\nHTTP_PROXY_PORT = \"1111\"\nCONFIG_ONLY = \"config\"\n",
445 )
446 .await
447 .unwrap();
448 fs::write(
449 project_dir.join("shine.env.toml"),
450 "HTTP_PROXY_PORT = \"2222\"\nDOTENV_ONLY = \"dotenv\"\n",
451 )
452 .await
453 .unwrap();
454 fs::write(
455 project_dir.join(".env.toml"),
456 "HTTP_PROXY_PORT = \"3333\"\n",
457 )
458 .await
459 .unwrap();
460
461 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
464 unsafe { std::env::remove_var("SHINE_PRESETS") };
467 std::env::set_current_dir(&child_dir).unwrap();
468
469 let config = Config::load_or_init().await.unwrap();
470
471 assert_eq!(
472 fs::canonicalize(&config.config_path).await.unwrap(),
473 fs::canonicalize(project_dir.join("shine.config.toml"))
474 .await
475 .unwrap()
476 );
477 assert_eq!(config.shine_dir(), state_dir);
478 assert_eq!(config.bin_dir(), state_dir.join("bin"));
479 assert_eq!(
480 fs::canonicalize(config.presets_dir()).await.unwrap(),
481 fs::canonicalize(&project_dir).await.unwrap()
482 );
483 assert_eq!(
484 config.env.get("HTTP_PROXY_PORT").map(String::as_str),
485 Some("2222")
486 );
487 assert_eq!(
488 config.env.get("CONFIG_ONLY").map(String::as_str),
489 Some("config")
490 );
491 assert_eq!(
492 config.env.get("DOTENV_ONLY").map(String::as_str),
493 Some("dotenv")
494 );
495
496 restore_current_dir(&original_dir);
497 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
500 fs::remove_dir_all(&project_dir).await.unwrap();
501 fs::remove_dir_all(&state_dir).await.unwrap();
502 }
503
504 #[allow(clippy::await_holding_lock)]
505 #[tokio::test(flavor = "current_thread")]
506 async fn load_global_runtime_ignores_project_config() {
507 let _guard = env_lock();
508 let original_dir = std::env::current_dir().unwrap();
509 let project_dir = make_temp_dir().await;
510 let child_dir = project_dir.join("subdir");
511 fs::create_dir_all(&child_dir).await.unwrap();
512 let state_dir = make_temp_dir().await;
513 fs::write(
514 project_dir.join("shine.config.toml"),
515 "schema_version = 7\npresets_dir = \".\"\n",
516 )
517 .await
518 .unwrap();
519 fs::write(
520 state_dir.join("config.toml"),
521 "schema_version = 0\nlast_cleared_schema_version = 0\n",
522 )
523 .await
524 .unwrap();
525
526 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
529 std::env::set_current_dir(&child_dir).unwrap();
530
531 let config = Config::load_global_runtime_or_init().await.unwrap();
532
533 assert_eq!(
534 fs::canonicalize(config.config_path()).await.unwrap(),
535 fs::canonicalize(state_dir.join("config.toml"))
536 .await
537 .unwrap()
538 );
539 assert_eq!(config.schema_version, 0);
540 assert_eq!(config.last_cleared_schema_version, Some(0));
541 assert_eq!(config.shine_dir(), state_dir);
542
543 restore_current_dir(&original_dir);
544 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
547 fs::remove_dir_all(&project_dir).await.unwrap();
548 fs::remove_dir_all(&state_dir).await.unwrap();
549 }
550
551 #[allow(clippy::await_holding_lock)]
552 #[tokio::test(flavor = "current_thread")]
553 async fn load_global_runtime_for_dry_run_does_not_create_state() {
554 let _guard = env_lock();
555 let dir = std::env::temp_dir().join(format!("shine-dry-run-{}", uuid::Uuid::new_v4()));
556 assert!(!dir.exists());
557
558 unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
561
562 let config = Config::load_global_runtime_for_dry_run().await.unwrap();
563
564 assert_eq!(config.config_path(), dir.join("config.toml"));
565 assert_eq!(config.schema_version, CURRENT_RUNTIME_SCHEMA_VERSION);
566 assert!(!dir.exists(), "dry-run loader must not create state dir");
567
568 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
571 }
572
573 #[allow(clippy::await_holding_lock)]
574 #[tokio::test(flavor = "current_thread")]
575 async fn load_global_runtime_for_dry_run_ignores_removed_global_env_file() {
576 let _guard = env_lock();
577 let dir = make_temp_dir().await;
578 let removed_path = dir.join("env.toml");
579 fs::write(&removed_path, "CUSTOM_TOKEN = \"abc\"\n")
580 .await
581 .unwrap();
582
583 unsafe { std::env::set_var("SHINE_CONFIG_DIR", dir.to_str().unwrap()) };
586
587 let config = Config::load_global_runtime_for_dry_run().await.unwrap();
588
589 assert_eq!(config.config_path(), dir.join("config.toml"));
590 assert!(removed_path.exists());
591 assert!(!dir.join("config.toml").exists());
592
593 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
596 fs::remove_dir_all(&dir).await.unwrap();
597 }
598
599 #[allow(clippy::await_holding_lock)]
600 #[tokio::test(flavor = "current_thread")]
601 async fn global_config_with_presets_dir_remains_global_config() {
602 let _guard = env_lock();
603 let original_dir = std::env::current_dir().unwrap();
604 let original_home = std::env::var("HOME").ok();
605 let home_dir = make_temp_dir().await;
606 let shine_dir = home_dir.join(".shine");
607 let child_dir = shine_dir.join("presets/shell/proxy");
608 let external_presets = make_temp_dir().await.join("presets");
609 fs::create_dir_all(&child_dir).await.unwrap();
610 fs::create_dir_all(&external_presets).await.unwrap();
611 fs::write(
612 shine_dir.join("config.toml"),
613 format!(
614 "schema_version = 1\npresets_dir = \"{}\"\n",
615 external_presets.display()
616 ),
617 )
618 .await
619 .unwrap();
620
621 unsafe { std::env::set_var("HOME", home_dir.to_str().unwrap()) };
624 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
627 unsafe { std::env::remove_var("SHINE_PRESETS") };
630 std::env::set_current_dir(&child_dir).unwrap();
631
632 let config = Config::load_or_init().await.unwrap();
633
634 assert_eq!(
635 fs::canonicalize(config.config_path()).await.unwrap(),
636 fs::canonicalize(shine_dir.join("config.toml"))
637 .await
638 .unwrap()
639 );
640 assert!(
641 !config.is_project_config,
642 "global config.toml must not be treated as a project config"
643 );
644 assert_eq!(
645 fs::canonicalize(config.presets_dir()).await.unwrap(),
646 fs::canonicalize(&external_presets).await.unwrap()
647 );
648
649 restore_current_dir(&original_dir);
650 match original_home {
651 Some(home) => {
652 unsafe { std::env::set_var("HOME", home) };
655 }
656 None => {
657 unsafe { std::env::remove_var("HOME") };
660 }
661 }
662 fs::remove_dir_all(&home_dir).await.unwrap();
663 fs::remove_dir_all(external_presets.parent().unwrap())
664 .await
665 .unwrap();
666 }
667
668 #[allow(clippy::await_holding_lock)]
669 #[tokio::test(flavor = "current_thread")]
670 async fn load_or_init_ignores_generic_project_config_and_dotenv() {
671 let _guard = env_lock();
672 let original_dir = std::env::current_dir().unwrap();
673 let project_dir = make_temp_dir().await;
674 let child_dir = project_dir.join("subdir");
675 fs::create_dir_all(&child_dir).await.unwrap();
676 let state_dir = make_temp_dir().await;
677 fs::write(
678 project_dir.join("config.toml"),
679 "presets_dir = \".\"\n[env]\nHTTP_PROXY_PORT = \"1111\"\n",
680 )
681 .await
682 .unwrap();
683 fs::write(
684 project_dir.join(".env.toml"),
685 "HTTP_PROXY_PORT = \"3333\"\n",
686 )
687 .await
688 .unwrap();
689
690 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
693 unsafe { std::env::remove_var("SHINE_PRESETS") };
696 std::env::set_current_dir(&child_dir).unwrap();
697
698 let config = Config::load_or_init().await.unwrap();
699
700 assert_eq!(
701 fs::canonicalize(&config.config_path).await.unwrap(),
702 fs::canonicalize(state_dir.join("config.toml"))
703 .await
704 .unwrap()
705 );
706 assert!(!config.is_project_config);
707 assert_eq!(
708 fs::canonicalize(config.presets_dir()).await.unwrap(),
709 fs::canonicalize(state_dir.join("presets")).await.unwrap()
710 );
711 assert_eq!(
712 config.env.get("HTTP_PROXY_PORT").map(String::as_str),
713 Some("6152")
714 );
715
716 restore_current_dir(&original_dir);
717 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
720 fs::remove_dir_all(&project_dir).await.unwrap();
721 fs::remove_dir_all(&state_dir).await.unwrap();
722 }
723
724 #[allow(clippy::await_holding_lock)]
725 #[tokio::test(flavor = "current_thread")]
726 async fn init_current_dir_config_ignores_generic_config_and_refuses_existing_shine_config() {
727 let _guard = env_lock();
728 let original_dir = std::env::current_dir().unwrap();
729 let project_dir = make_temp_dir().await;
730 fs::write(
731 project_dir.join("config.toml"),
732 "presets_dir = \"other-tool\"\n",
733 )
734 .await
735 .unwrap();
736 std::env::set_current_dir(&project_dir).unwrap();
737
738 let path = Config::init_current_dir_config().await.unwrap();
739 assert_eq!(
740 path.file_name().and_then(|name| name.to_str()),
741 Some("shine.config.toml")
742 );
743 assert_eq!(
744 fs::canonicalize(path.parent().unwrap()).await.unwrap(),
745 fs::canonicalize(&project_dir).await.unwrap()
746 );
747
748 let content = fs::read_to_string(&path).await.unwrap();
749 let parsed: toml::Table = toml::from_str(&content).unwrap();
750 assert_eq!(
751 parsed.get("presets_dir").and_then(|value| value.as_str()),
752 Some(".")
753 );
754 assert!(
755 !parsed.contains_key("schema_version"),
756 "project config must not persist runtime schema_version"
757 );
758 assert!(
759 !parsed.contains_key("last_cleared_schema_version"),
760 "project config must not persist runtime clear state"
761 );
762
763 let err = Config::init_current_dir_config().await.unwrap_err();
764 assert!(
765 err.to_string().contains("already exists"),
766 "error should refuse overwrite: {err:#}"
767 );
768
769 restore_current_dir(&original_dir);
770 fs::remove_dir_all(&project_dir).await.unwrap();
771 }
772
773 #[allow(clippy::await_holding_lock)]
774 #[tokio::test(flavor = "current_thread")]
775 async fn project_config_save_removes_runtime_schema_fields() {
776 let _guard = env_lock();
777 let original_dir = std::env::current_dir().unwrap();
778 let project_dir = make_temp_dir().await;
779 let state_dir = make_temp_dir().await;
780 fs::write(
781 project_dir.join("shine.config.toml"),
782 "schema_version = 0\nlast_cleared_schema_version = 0\npresets_dir = \".\"\n",
783 )
784 .await
785 .unwrap();
786
787 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
790 std::env::set_current_dir(&project_dir).unwrap();
791
792 let config = Config::load_or_init().await.unwrap();
793 assert_eq!(config.schema_version, CURRENT_RUNTIME_SCHEMA_VERSION);
794 assert_eq!(config.last_cleared_schema_version, None);
795
796 config.save().await.unwrap();
797
798 let content = fs::read_to_string(project_dir.join("shine.config.toml"))
799 .await
800 .unwrap();
801 let parsed: toml::Table = toml::from_str(&content).unwrap();
802 assert_eq!(
803 parsed.get("presets_dir").and_then(|value| value.as_str()),
804 Some(".")
805 );
806 assert!(!parsed.contains_key("schema_version"));
807 assert!(!parsed.contains_key("last_cleared_schema_version"));
808
809 restore_current_dir(&original_dir);
810 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
813 fs::remove_dir_all(&project_dir).await.unwrap();
814 fs::remove_dir_all(&state_dir).await.unwrap();
815 }
816
817 #[allow(clippy::await_holding_lock)]
818 #[tokio::test(flavor = "current_thread")]
819 async fn project_config_without_presets_dir_inherits_global_config() {
820 let _guard = env_lock();
821 let original_dir = std::env::current_dir().unwrap();
822 let original_home = std::env::var("HOME").ok();
823 let project_dir = make_temp_dir().await;
824 let home_dir = make_temp_dir().await;
825 let state_dir = home_dir.join(".shine");
826 fs::create_dir_all(&state_dir).await.unwrap();
827 let global_presets = state_dir.join("shared-presets");
828 fs::create_dir_all(&global_presets).await.unwrap();
829 fs::write(
830 state_dir.join("config.toml"),
831 "presets_dir = \"shared-presets\"\ngpg_recipients = [\"global-key\"]\n",
832 )
833 .await
834 .unwrap();
835 fs::write(
836 project_dir.join("shine.config.toml"),
837 "[env]\nLOCAL = \"yes\"\n",
838 )
839 .await
840 .unwrap();
841
842 unsafe { std::env::set_var("HOME", home_dir.to_str().unwrap()) };
843 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
844 unsafe { std::env::remove_var("SHINE_PRESETS") };
845 std::env::set_current_dir(&project_dir).unwrap();
846
847 let config = Config::load_or_init().await.unwrap();
848 assert_eq!(
849 fs::canonicalize(config.presets_dir()).await.unwrap(),
850 fs::canonicalize(&global_presets).await.unwrap()
851 );
852 assert_eq!(config.gpg_recipients, ["global-key"]);
853 assert!(config.is_external_presets);
854
855 restore_current_dir(&original_dir);
856 match original_home {
857 Some(home) => unsafe { std::env::set_var("HOME", home) },
858 None => unsafe { std::env::remove_var("HOME") },
859 }
860 fs::remove_dir_all(&project_dir).await.unwrap();
861 fs::remove_dir_all(&home_dir).await.unwrap();
862 }
863
864 #[allow(clippy::await_holding_lock)]
865 #[tokio::test(flavor = "current_thread")]
866 async fn project_config_merges_layers_and_saves_only_local_changes() {
867 let _guard = env_lock();
868 let original_dir = std::env::current_dir().unwrap();
869 let project_dir = make_temp_dir().await;
870 let state_dir = make_temp_dir().await;
871 fs::create_dir_all(project_dir.join("project-presets"))
872 .await
873 .unwrap();
874 fs::write(
875 state_dir.join("config.toml"),
876 "presets_dir = \"global-presets\"\ngpg_recipients = [\"global-key\"]\n[env]\nGLOBAL = \"config\"\nSHARED = \"global-config\"\n",
877 )
878 .await
879 .unwrap();
880 fs::write(
881 state_dir.join("shine.env.toml"),
882 "SHARED = \"global-env\"\nGLOBAL_FILE = \"yes\"\n",
883 )
884 .await
885 .unwrap();
886 fs::write(
887 project_dir.join("shine.config.toml"),
888 "presets_dir = \"project-presets\"\n[env]\nPROJECT = \"config\"\nSHARED = \"project-config\"\n",
889 )
890 .await
891 .unwrap();
892 fs::write(
893 project_dir.join("shine.env.toml"),
894 "SHARED = \"project-env\"\nPROJECT_FILE = \"yes\"\n",
895 )
896 .await
897 .unwrap();
898
899 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
900 unsafe { std::env::remove_var("SHINE_PRESETS") };
901 std::env::set_current_dir(&project_dir).unwrap();
902
903 let mut config = Config::load_or_init().await.unwrap();
904 assert_eq!(
905 fs::canonicalize(config.presets_dir()).await.unwrap(),
906 fs::canonicalize(project_dir.join("project-presets"))
907 .await
908 .unwrap()
909 );
910 assert_eq!(config.env.get("GLOBAL").map(String::as_str), Some("config"));
911 assert_eq!(
912 config.env.get("PROJECT").map(String::as_str),
913 Some("config")
914 );
915 assert_eq!(
916 config.env.get("GLOBAL_FILE").map(String::as_str),
917 Some("yes")
918 );
919 assert_eq!(
920 config.env.get("PROJECT_FILE").map(String::as_str),
921 Some("yes")
922 );
923 assert_eq!(
924 config.env.get("SHARED").map(String::as_str),
925 Some("project-env")
926 );
927
928 config.env.insert("ADDED".into(), "new".into());
929 config.save().await.unwrap();
930 let saved = fs::read_to_string(project_dir.join("shine.config.toml"))
931 .await
932 .unwrap();
933 let table: toml::Table = toml::from_str(&saved).unwrap();
934 assert_eq!(
935 table.get("presets_dir").and_then(toml::Value::as_str),
936 Some("project-presets")
937 );
938 assert!(!table.contains_key("gpg_recipients"));
939 let env = table.get("env").and_then(toml::Value::as_table).unwrap();
940 assert_eq!(env.get("ADDED").and_then(toml::Value::as_str), Some("new"));
941 assert!(!env.contains_key("GLOBAL"));
942 assert!(!env.contains_key("GLOBAL_FILE"));
943 assert!(!env.contains_key("PROJECT_FILE"));
944
945 restore_current_dir(&original_dir);
946 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
947 fs::remove_dir_all(&project_dir).await.unwrap();
948 fs::remove_dir_all(&state_dir).await.unwrap();
949 }
950
951 #[allow(clippy::await_holding_lock)]
952 #[tokio::test(flavor = "current_thread")]
953 async fn project_config_overrides_age_backend_settings() {
954 let _guard = env_lock();
955 let original_dir = std::env::current_dir().unwrap();
956 let project_dir = make_temp_dir().await;
957 let state_dir = make_temp_dir().await;
958 fs::write(
959 state_dir.join("config.toml"),
960 "secret_backend = \"gpg\"\nage_recipients = [\"age1global\"]\nage_identity = \"~/.shine/age/global.txt\"\n",
961 )
962 .await
963 .unwrap();
964 fs::write(
965 project_dir.join("shine.config.toml"),
966 "presets_dir = \".\"\nsecret_backend = \"age\"\nage_recipients = [\"age1project-a\", \"age1project-b\"]\n",
967 )
968 .await
969 .unwrap();
970
971 unsafe { std::env::set_var("SHINE_CONFIG_DIR", state_dir.to_str().unwrap()) };
972 unsafe { std::env::remove_var("SHINE_PRESETS") };
973 std::env::set_current_dir(&project_dir).unwrap();
974
975 let config = Config::load_or_init().await.unwrap();
976
977 assert_eq!(config.secret_backend.as_deref(), Some("age"));
978 assert_eq!(
979 config.age_recipients,
980 vec!["age1project-a".to_string(), "age1project-b".to_string()]
981 );
982 assert_eq!(
984 config.age_identity.as_deref(),
985 Some("~/.shine/age/global.txt")
986 );
987
988 restore_current_dir(&original_dir);
989 unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
990 fs::remove_dir_all(&project_dir).await.unwrap();
991 fs::remove_dir_all(&state_dir).await.unwrap();
992 }
993}