use serde::Deserialize;
use std::path::{Path, PathBuf};
use super::{GLOBAL_CONFIG_FILE, PROJECT_CONFIG_FILE, tilde_expand};
use crate::home::default_config_and_presets_dir;
#[derive(Clone, Debug)]
pub(super) struct ProjectConfig {
pub(super) path: PathBuf,
pub(super) root: PathBuf,
}
pub(super) fn find_project_config(start: &Path) -> Option<ProjectConfig> {
find_ancestor_file(start, PROJECT_CONFIG_FILE).and_then(|path| {
let root = path.parent()?.to_path_buf();
Some(ProjectConfig { root, path })
})
}
fn find_ancestor_file(start: &Path, file_name: &str) -> Option<PathBuf> {
for dir in start.ancestors() {
let path = dir.join(file_name);
if path.is_file() {
return Some(path);
}
}
None
}
#[derive(Deserialize)]
pub(super) struct MinimalConfig {
#[serde(default)]
pub(super) presets_dir: Option<PathBuf>,
#[serde(default)]
pub(super) presets_overlay_dir: Option<PathBuf>,
#[serde(default)]
pub(super) presets_overlay_git: Option<String>,
#[serde(default)]
pub(super) schema_version: u32,
}
pub(super) fn read_minimal_config(content: &str) -> Result<MinimalConfig, toml::de::Error> {
toml::from_str(content)
}
pub(super) fn preliminary_shine_dir_from_env(default: &Path) -> PathBuf {
if let Ok(val) = std::env::var("SHINE_CONFIG_DIR") {
let val = val.trim().to_string();
if !val.is_empty() {
return PathBuf::from(tilde_expand(&val));
}
}
default.to_owned()
}
pub(super) async fn read_presets_override_from_toml(config_path: &Path) -> Option<PathBuf> {
let content = tokio::fs::read_to_string(config_path).await.ok()?;
read_minimal_config(&content).ok()?.presets_dir
}
pub(super) fn resolve_config_presets_path(path: &Path, config_dir: &Path) -> PathBuf {
if let Some(s) = path.to_str()
&& s.starts_with('~')
{
return PathBuf::from(tilde_expand(s));
}
if path.is_absolute() {
path.to_path_buf()
} else {
config_dir.join(path)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ReadOnlyRuntimePaths {
pub(crate) shine_dir: PathBuf,
pub(crate) presets_dir: PathBuf,
pub(crate) presets_overlay_dir: Option<PathBuf>,
pub(crate) is_external_presets: bool,
}
pub(crate) fn discover_runtime_paths_read_only() -> Option<ReadOnlyRuntimePaths> {
let (default_shine_dir, default_presets_dir) = default_config_and_presets_dir().ok()?;
let preliminary_shine_dir = preliminary_shine_dir_from_env(&default_shine_dir);
let global_path = preliminary_shine_dir.join(GLOBAL_CONFIG_FILE);
let global_dir = global_path.parent().unwrap_or_else(|| Path::new("."));
let global = read_minimal_config_file(&global_path);
let global_presets = global
.as_ref()
.and_then(|config| config.presets_dir.as_deref())
.map(|path| resolve_config_presets_path(path, global_dir));
let (global_shine_dir, global_presets_dir, global_is_external) = resolve_runtime_config_dirs(
&default_shine_dir,
&default_presets_dir,
global_presets.as_deref(),
false,
);
let global_overlay = global.as_ref().and_then(|config| {
config
.presets_overlay_dir
.as_deref()
.map(|path| resolve_config_presets_path(path, global_dir))
.or_else(|| {
config
.presets_overlay_git
.as_ref()
.map(|_| global_shine_dir.join("overlay"))
.filter(|path| path.exists())
})
});
let current_dir = std::env::current_dir().ok()?;
let Some(project) = find_project_config(¤t_dir) else {
return Some(ReadOnlyRuntimePaths {
shine_dir: global_shine_dir,
presets_dir: global_presets_dir,
presets_overlay_dir: global_overlay,
is_external_presets: global_is_external,
});
};
let project_config = read_minimal_config_file(&project.path);
let project_presets = project_config
.as_ref()
.and_then(|config| config.presets_dir.as_deref())
.map(|path| resolve_config_presets_path(path, &project.root));
let effective_presets = project_presets
.clone()
.or_else(|| global_is_external.then_some(global_presets_dir.clone()));
let runtime_presets = if project_presets.is_none()
&& std::env::var("SHINE_CONFIG_DIR").is_ok_and(|value| !value.trim().is_empty())
{
None
} else {
effective_presets
};
let (shine_dir, presets_dir, is_external_presets) = resolve_runtime_config_dirs(
&default_shine_dir,
&default_presets_dir,
runtime_presets.as_deref(),
true,
);
let presets_overlay_dir = project_config
.as_ref()
.and_then(|config| config.presets_overlay_dir.as_deref())
.map(|path| resolve_config_presets_path(path, &project.root))
.or(global_overlay);
Some(ReadOnlyRuntimePaths {
shine_dir,
presets_dir,
presets_overlay_dir,
is_external_presets,
})
}
fn read_minimal_config_file(path: &Path) -> Option<MinimalConfig> {
let content = std::fs::read_to_string(path).ok()?;
read_minimal_config(&content).ok()
}
pub(super) fn resolve_runtime_config_dirs(
default_shine_dir: &Path,
default_presets_dir: &Path,
config_toml_presets: Option<&Path>,
has_local_config: bool,
) -> (PathBuf, PathBuf, bool) {
if let Ok(val) = std::env::var("SHINE_CONFIG_DIR") {
let val = val.trim().to_string();
if !val.is_empty() {
let dir = PathBuf::from(tilde_expand(&val));
if !has_local_config {
return (dir.clone(), dir.join("presets"), true);
}
if let Ok(val) = std::env::var("SHINE_PRESETS") {
let val = val.trim().to_string();
if !val.is_empty() {
let presets = PathBuf::from(tilde_expand(&val));
return (dir, presets, true);
}
}
if let Some(p) = config_toml_presets
&& let Some(s) = p.to_str()
{
let presets = PathBuf::from(tilde_expand(s));
return (dir, presets, true);
}
return (dir.clone(), dir.join("presets"), false);
}
}
if let Ok(val) = std::env::var("SHINE_PRESETS") {
let val = val.trim().to_string();
if !val.is_empty() {
let presets = PathBuf::from(tilde_expand(&val));
return (default_shine_dir.to_owned(), presets, true);
}
}
if let Some(p) = config_toml_presets
&& let Some(s) = p.to_str()
{
let presets = PathBuf::from(tilde_expand(s));
return (default_shine_dir.to_owned(), presets, true);
}
(
default_shine_dir.to_owned(),
default_presets_dir.to_owned(),
false,
)
}
#[cfg(test)]
mod tests {
use super::super::test_util::make_temp_dir;
use super::*;
use crate::test_support::env_lock;
use tokio::fs;
#[tokio::test]
async fn project_config_discovery_finds_shine_config() {
let project_dir = make_temp_dir().await;
let child_dir = project_dir.join("subdir");
fs::create_dir_all(&child_dir).await.unwrap();
fs::write(
project_dir.join("shine.config.toml"),
"presets_dir = \".\"\n",
)
.await
.unwrap();
let config = find_project_config(&child_dir).expect("project config should be found");
assert_eq!(config.path, project_dir.join("shine.config.toml"));
fs::remove_dir_all(&project_dir).await.unwrap();
}
#[tokio::test]
async fn generic_config_toml_is_not_project_config() {
let project_dir = make_temp_dir().await;
fs::write(
project_dir.join("config.toml"),
"presets_dir = \"legacy\"\n",
)
.await
.unwrap();
assert!(find_project_config(&project_dir).is_none());
fs::remove_dir_all(&project_dir).await.unwrap();
}
#[test]
fn shine_config_dir_overrides_everything() {
let _guard = env_lock();
let default_shine = PathBuf::from("/home/user/.shine");
let default_presets = PathBuf::from("/home/user/.shine/presets");
let custom = std::env::temp_dir().join("shine-override-test");
unsafe { std::env::set_var("SHINE_CONFIG_DIR", custom.to_str().unwrap()) };
let (shine, presets, _) =
resolve_runtime_config_dirs(&default_shine, &default_presets, None, false);
unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
assert_eq!(shine, custom);
assert_eq!(presets, custom.join("presets"));
}
#[test]
fn shine_presets_overrides_presets_only() {
let _guard = env_lock();
let default_shine = PathBuf::from("/home/user/.shine");
let default_presets = PathBuf::from("/home/user/.shine/presets");
let custom_presets = std::env::temp_dir().join("my-presets");
unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
unsafe { std::env::set_var("SHINE_PRESETS", custom_presets.to_str().unwrap()) };
let (shine, presets, _) =
resolve_runtime_config_dirs(&default_shine, &default_presets, None, false);
unsafe { std::env::remove_var("SHINE_PRESETS") };
assert_eq!(shine, default_shine);
assert_eq!(presets, custom_presets);
}
#[test]
fn shine_config_dir_takes_precedence_over_shine_presets() {
let _guard = env_lock();
let default_shine = PathBuf::from("/home/user/.shine");
let default_presets = PathBuf::from("/home/user/.shine/presets");
let custom_dir = std::env::temp_dir().join("shine-cfg-dir");
let custom_presets = std::env::temp_dir().join("shine-presets-ignored");
unsafe { std::env::set_var("SHINE_CONFIG_DIR", custom_dir.to_str().unwrap()) };
unsafe { std::env::set_var("SHINE_PRESETS", custom_presets.to_str().unwrap()) };
let (shine, presets, _) =
resolve_runtime_config_dirs(&default_shine, &default_presets, None, false);
unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
unsafe { std::env::remove_var("SHINE_PRESETS") };
assert_eq!(shine, custom_dir);
assert_eq!(presets, custom_dir.join("presets"));
}
#[test]
fn local_config_keeps_shine_config_dir_as_state_only() {
let _guard = env_lock();
let default_shine = PathBuf::from("/home/user/.shine");
let default_presets = PathBuf::from("/home/user/.shine/presets");
let custom_dir = std::env::temp_dir().join("shine-local-state");
let toml_presets = PathBuf::from("/project/presets");
unsafe { std::env::set_var("SHINE_CONFIG_DIR", custom_dir.to_str().unwrap()) };
unsafe { std::env::remove_var("SHINE_PRESETS") };
let (shine, presets, _) = resolve_runtime_config_dirs(
&default_shine,
&default_presets,
Some(toml_presets.as_path()),
true,
);
unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
assert_eq!(shine, custom_dir);
assert_eq!(presets, toml_presets);
}
#[test]
fn config_toml_presets_dir_is_used_when_no_env() {
let _guard = env_lock();
let default_shine = PathBuf::from("/home/user/.shine");
let default_presets = PathBuf::from("/home/user/.shine/presets");
let toml_presets = PathBuf::from("/custom/presets");
unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
unsafe { std::env::remove_var("SHINE_PRESETS") };
let (shine, presets, _) = resolve_runtime_config_dirs(
&default_shine,
&default_presets,
Some(toml_presets.as_path()),
false,
);
assert_eq!(shine, default_shine);
assert_eq!(presets, toml_presets);
}
#[test]
fn shine_presets_takes_precedence_over_config_toml() {
let _guard = env_lock();
let default_shine = PathBuf::from("/home/user/.shine");
let default_presets = PathBuf::from("/home/user/.shine/presets");
let env_presets = std::env::temp_dir().join("env-presets");
let toml_presets = PathBuf::from("/toml/presets");
unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
unsafe { std::env::set_var("SHINE_PRESETS", env_presets.to_str().unwrap()) };
let (_, presets, _) = resolve_runtime_config_dirs(
&default_shine,
&default_presets,
Some(toml_presets.as_path()),
false,
);
unsafe { std::env::remove_var("SHINE_PRESETS") };
assert_eq!(presets, env_presets);
}
#[tokio::test]
async fn read_presets_override_returns_none_when_file_missing() {
let missing =
std::env::temp_dir().join(format!("shine-no-config-{}.toml", uuid::Uuid::new_v4()));
let result = read_presets_override_from_toml(&missing).await;
assert!(result.is_none());
}
#[tokio::test]
async fn read_presets_override_returns_none_when_key_absent() {
let dir = make_temp_dir().await;
let path = dir.join("config.toml");
fs::write(&path, "schema_version = 0\n").await.unwrap();
let result = read_presets_override_from_toml(&path).await;
assert!(result.is_none());
fs::remove_dir_all(&dir).await.unwrap();
}
#[tokio::test]
async fn read_presets_override_returns_path_when_key_present() {
let dir = make_temp_dir().await;
let path = dir.join("config.toml");
fs::write(&path, "schema_version = 0\npresets_dir = \"/my/presets\"\n")
.await
.unwrap();
let result = read_presets_override_from_toml(&path).await;
assert_eq!(result, Some(PathBuf::from("/my/presets")));
fs::remove_dir_all(&dir).await.unwrap();
}
#[test]
fn is_external_presets_true_when_shine_config_dir_set() {
let _guard = env_lock();
let default = PathBuf::from("/home/user/.shine");
let presets = PathBuf::from("/home/user/.shine/presets");
let custom = std::env::temp_dir().join("shine-ext-test");
unsafe { std::env::set_var("SHINE_CONFIG_DIR", custom.to_str().unwrap()) };
let (_, _, is_external) = resolve_runtime_config_dirs(&default, &presets, None, false);
unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
assert!(
is_external,
"SHINE_CONFIG_DIR should set is_external_presets"
);
}
#[test]
fn is_external_presets_true_when_shine_presets_set() {
let _guard = env_lock();
let default = PathBuf::from("/home/user/.shine");
let presets = PathBuf::from("/home/user/.shine/presets");
let custom = std::env::temp_dir().join("shine-ext-presets");
unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
unsafe { std::env::set_var("SHINE_PRESETS", custom.to_str().unwrap()) };
let (_, _, is_external) = resolve_runtime_config_dirs(&default, &presets, None, false);
unsafe { std::env::remove_var("SHINE_PRESETS") };
assert!(is_external, "SHINE_PRESETS should set is_external_presets");
}
#[test]
fn is_external_presets_true_when_toml_presets_dir_set() {
let _guard = env_lock();
let default = PathBuf::from("/home/user/.shine");
let presets = PathBuf::from("/home/user/.shine/presets");
let toml_override = PathBuf::from("/toml/presets");
unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
unsafe { std::env::remove_var("SHINE_PRESETS") };
let (_, _, is_external) =
resolve_runtime_config_dirs(&default, &presets, Some(toml_override.as_path()), false);
assert!(
is_external,
"config.toml presets_dir should set is_external_presets"
);
}
#[test]
fn is_external_presets_false_when_no_override() {
let _guard = env_lock();
let default = PathBuf::from("/home/user/.shine");
let presets = PathBuf::from("/home/user/.shine/presets");
unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
unsafe { std::env::remove_var("SHINE_PRESETS") };
let (_, _, is_external) = resolve_runtime_config_dirs(&default, &presets, None, false);
assert!(
!is_external,
"no override should leave is_external_presets false"
);
}
}