use std::{
collections::BTreeMap,
fmt, fs,
io::ErrorKind,
path::{Path, PathBuf},
};
use directories::ProjectDirs;
use serde::Deserialize;
use crate::{
env::{CF_API_KEY, Secret},
error::{Error, IoContext},
};
pub const CONFIG_FILE_NAME: &str = ".sculk";
pub fn global_path() -> Option<PathBuf> {
ProjectDirs::from("", "", env!("CARGO_BIN_NAME"))
.map(|dirs| dirs.config_dir().join(CONFIG_FILE_NAME))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scope {
Global,
Pack,
}
pub fn local_path<P>(pack_root: P) -> PathBuf
where
P: AsRef<Path>,
{
pack_root.as_ref().join(CONFIG_FILE_NAME)
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
pub path: Option<PathBuf>,
pub output: Option<PathBuf>,
pub format: Option<String>,
pub verbose: Option<u8>,
pub quiet: Option<bool>,
#[serde(default)]
pub secrets: Secrets,
#[serde(flatten)]
unknown: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Secrets {
pub cf_api_key: Option<Secret>,
#[serde(flatten)]
unknown: BTreeMap<String, toml::Value>,
}
impl Secrets {
fn overlay(&mut self, other: Self) {
let Self {
cf_api_key,
unknown,
} = other;
self.cf_api_key = cf_api_key.or(self.cf_api_key.take());
self.unknown.extend(unknown);
}
fn has_api_key(&self) -> bool {
self.cf_api_key.as_ref().is_some_and(|key| !key.is_blank())
}
}
impl Config {
pub fn load<P>(file: P) -> Result<Option<Self>, Error>
where
P: AsRef<Path>,
{
let file = file.as_ref();
let text = match fs::read_to_string(file) {
Ok(text) => text,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err).path_ctx(file, "reading config"),
};
let mut config: Self = toml::from_str(&text)
.map_err(|err| Error::TomlFile(crate::util::resolve_for_display(file), err))?;
config.rebase(file.parent().unwrap_or(Path::new(".")));
Ok(Some(config))
}
fn rebase(&mut self, dir: &Path) {
for path in [&mut self.path, &mut self.output].into_iter().flatten() {
if path.is_relative() {
*path = dir.join(&*path);
}
}
}
fn overlay(&mut self, other: Self) {
let Self {
path,
output,
format,
verbose,
quiet,
secrets,
unknown,
} = other;
self.path = path.or(self.path.take());
self.output = output.or(self.output.take());
self.format = format.or(self.format.take());
self.verbose = verbose.or(self.verbose);
self.quiet = quiet.or(self.quiet);
self.secrets.overlay(secrets);
self.unknown.extend(unknown);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeySource {
Environment,
Config(PathBuf),
}
impl fmt::Display for KeySource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Environment => f.write_str("the environment"),
Self::Config(file) => write!(f, "{}", file.display()),
}
}
}
fn warn_about_committed_key(file: &Path) -> String {
let elsewhere = match global_path() {
Some(global) => format!(".env or \"{}\"", global.display()),
None => ".env".to_owned(),
};
format!(
"{CF_API_KEY} is set in \"{}\". A pack {CONFIG_FILE_NAME} is normally committed and \
shipped with the pack, so treat that key as public -- keep it in {elsewhere} instead",
file.display()
)
}
#[derive(Debug, Default)]
pub struct Loaded {
pub config: Config,
pub sources: Vec<PathBuf>,
pub warnings: Vec<String>,
secret_source: Option<PathBuf>,
}
impl Loaded {
pub fn discover<P>(pack_root: P) -> Self
where
P: AsRef<Path>,
{
let mut loaded = Self::default();
let files = global_path()
.map(|file| (file, Scope::Global))
.into_iter()
.chain(std::iter::once((local_path(pack_root), Scope::Pack)));
for (file, scope) in files {
match Config::load(&file) {
Ok(Some(config)) => {
loaded.absorb(crate::util::resolve_for_display(&file), scope, config);
}
Ok(None) => {}
Err(err) => loaded
.warnings
.push(format!("ignoring this {CONFIG_FILE_NAME}: {err}")),
}
}
let unknown = loaded.config.unknown.keys().map(String::clone).chain(
loaded
.config
.secrets
.unknown
.keys()
.map(|key| format!("secrets.{key}")),
);
for key in unknown.collect::<Vec<_>>() {
loaded.warnings.push(format!(
"unknown key \"{key}\" in {CONFIG_FILE_NAME}; ignoring it"
));
}
loaded
}
fn absorb(&mut self, file: PathBuf, scope: Scope, config: Config) {
if config.secrets.has_api_key() {
if scope == Scope::Pack {
self.warnings.push(warn_about_committed_key(&file));
}
self.secret_source = Some(file.clone());
}
self.config.overlay(config);
self.sources.push(file);
}
pub fn curseforge_api_key(&self) -> Option<(Secret, KeySource)> {
self.resolve_api_key(crate::env::curseforge_api_key())
}
fn resolve_api_key(&self, from_env: Option<Secret>) -> Option<(Secret, KeySource)> {
if let Some(key) = from_env {
return Some((key, KeySource::Environment));
}
match (&self.config.secrets.cf_api_key, &self.secret_source) {
(Some(key), Some(file)) if !key.is_blank() => {
Some((key.clone(), KeySource::Config(file.clone())))
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(toml: &str) -> Config {
toml::from_str(toml).expect("valid config")
}
fn secret(value: &str) -> Secret {
parse(&format!("[secrets]\ncf-api-key = '{value}'"))
.secrets
.cf_api_key
.expect("a key")
}
fn resolved(loaded: &Loaded, from_env: Option<Secret>) -> Option<(String, KeySource)> {
loaded
.resolve_api_key(from_env)
.map(|(key, source)| (key.expose().to_owned(), source))
}
fn loaded(global: &str, pack: &str) -> Loaded {
let mut loaded = Loaded::default();
loaded.absorb(
PathBuf::from("/home/user/.config/sculkr/.sculk"),
Scope::Global,
parse(global),
);
loaded.absorb(
PathBuf::from("/home/user/modpack/.sculk"),
Scope::Pack,
parse(pack),
);
loaded
}
#[test]
fn an_empty_file_is_a_valid_config() {
let config = parse("");
assert!(config.path.is_none());
assert!(config.format.is_none());
assert!(config.unknown.is_empty());
}
#[test]
fn every_flag_round_trips_from_the_file() {
let config = parse(
r#"
path = "packs/skyblock"
output = "modlist.md"
format = '- {NAME}\n'
verbose = 2
quiet = true
"#,
);
assert_eq!(config.path, Some(PathBuf::from("packs/skyblock")));
assert_eq!(config.output, Some(PathBuf::from("modlist.md")));
assert_eq!(config.format.as_deref(), Some(r"- {NAME}\n"));
assert_eq!(config.verbose, Some(2));
assert_eq!(config.quiet, Some(true));
}
#[test]
fn an_unknown_key_is_kept_rather_than_rejected() {
let config = parse("fromat = \"oops\"");
assert!(config.format.is_none());
assert_eq!(config.unknown.keys().collect::<Vec<_>>(), vec![
&"fromat".to_owned()
]);
}
#[test]
fn a_local_key_wins_over_the_global_one() {
let mut global = parse("output = \"global.md\"\nformat = \"{NAME}\"");
global.overlay(parse("output = \"local.md\""));
assert_eq!(global.output, Some(PathBuf::from("local.md")));
assert_eq!(global.format.as_deref(), Some("{NAME}"));
}
#[test]
fn a_key_is_read_out_of_the_secrets_table() {
let config = parse("[secrets]\ncf-api-key = '$2a$10$abcdefghijklmnope345'");
assert_eq!(
config.secrets.cf_api_key.as_ref().map(Secret::expose),
Some("$2a$10$abcdefghijklmnope345")
);
assert!(config.secrets.unknown.is_empty());
assert!(config.unknown.is_empty());
}
#[test]
fn a_key_stays_redacted_when_the_whole_config_is_printed() {
let config = parse("[secrets]\ncf-api-key = 'hunter2'");
let printed = format!("{config:?}");
assert!(!printed.contains("hunter2"), "{printed}");
assert!(printed.contains("<redacted>"), "{printed}");
}
#[test]
fn an_unknown_secret_is_named_under_its_table() {
let config = parse("[secrets]\ncf-api-kye = 'oops'");
assert!(config.secrets.cf_api_key.is_none());
assert_eq!(config.secrets.unknown.keys().collect::<Vec<_>>(), vec![
&"cf-api-kye".to_owned()
]);
}
#[test]
fn a_key_in_the_packs_file_is_warned_about() {
let loaded = loaded("", "[secrets]\ncf-api-key = 'hunter2'");
assert_eq!(loaded.warnings.len(), 1, "{:?}", loaded.warnings);
assert!(loaded.warnings[0].contains("/home/user/modpack/.sculk"));
assert!(!loaded.warnings[0].contains("hunter2"));
}
#[test]
fn a_key_in_the_global_file_is_not_warned_about() {
let loaded = loaded("[secrets]\ncf-api-key = 'hunter2'", "");
assert!(loaded.warnings.is_empty(), "{:?}", loaded.warnings);
assert_eq!(
resolved(&loaded, None),
Some((
"hunter2".to_owned(),
KeySource::Config(PathBuf::from("/home/user/.config/sculkr/.sculk"))
))
);
}
#[test]
fn the_environment_wins_over_every_file() {
let loaded = loaded("[secrets]\ncf-api-key = 'from-the-global-file'", "");
assert_eq!(
resolved(&loaded, Some(secret("from-the-environment"))),
Some(("from-the-environment".to_owned(), KeySource::Environment))
);
}
#[test]
fn the_packs_key_wins_over_the_global_one() {
let loaded = loaded(
"[secrets]\ncf-api-key = 'global'",
"[secrets]\ncf-api-key = 'pack'",
);
assert_eq!(
resolved(&loaded, None),
Some((
"pack".to_owned(),
KeySource::Config(PathBuf::from("/home/user/modpack/.sculk"))
))
);
}
#[test]
fn a_blank_key_is_the_same_as_no_key() {
let loaded = loaded("[secrets]\ncf-api-key = ' '", "");
assert_eq!(resolved(&loaded, None), None);
assert!(loaded.warnings.is_empty(), "{:?}", loaded.warnings);
}
#[test]
fn relative_paths_resolve_against_the_file() {
let mut config = parse("path = \"packs/skyblock\"\noutput = \"/tmp/modlist.md\"");
config.rebase(Path::new("/home/user/.config/sculkr"));
assert_eq!(
config.path,
Some(PathBuf::from("/home/user/.config/sculkr/packs/skyblock"))
);
assert_eq!(config.output, Some(PathBuf::from("/tmp/modlist.md")));
}
}