use crate::error::{Error, Result};
use crate::paths;
use std::path::{Path, PathBuf};
const CONFIG_EXTS: [&str; 3] = ["xml", "yaml", "yml"];
const OVERLAY_STEM: &str = "chctl-config";
fn has_config_ext(name: &str) -> bool {
Path::new(name)
.extension()
.and_then(|e| e.to_str())
.map(|e| CONFIG_EXTS.contains(&e))
.unwrap_or(false)
}
pub fn list_configs_in(dir: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.flatten()
.filter(|e| e.path().is_file())
.filter_map(|e| {
let path = e.path();
let ext = path.extension().and_then(|e| e.to_str())?;
if CONFIG_EXTS.contains(&ext) {
path.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
} else {
None
}
})
.collect();
names.sort();
names
}
fn validate_config_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.contains('/')
|| name.contains('\\')
|| name.contains('\0')
|| name == "."
|| name == ".."
{
return Err(Error::InvalidConfigName(name.to_string()));
}
Ok(())
}
pub fn resolve_config_in(dir: &Path, name: &str) -> Result<PathBuf> {
validate_config_name(name)?;
if has_config_ext(name) {
let path = dir.join(name);
if path.is_file() {
return Ok(path);
}
return Err(not_found_error(dir, name));
}
let matches: Vec<PathBuf> = CONFIG_EXTS
.iter()
.map(|ext| dir.join(format!("{name}.{ext}")))
.filter(|p| p.is_file())
.collect();
match matches.len() {
1 => Ok(matches.into_iter().next().unwrap()),
0 => Err(not_found_error(dir, name)),
_ => {
let exts = matches
.iter()
.filter_map(|p| p.file_name().and_then(|n| n.to_str()))
.collect::<Vec<_>>()
.join(", ");
Err(Error::ConfigNotFound(format!(
"config '{name}' is ambiguous in {} ({exts}); specify the file extension",
dir.display()
)))
}
}
}
fn not_found_error(dir: &Path, name: &str) -> Error {
let available = list_configs_in(dir);
let avail = if available.is_empty() {
"none".to_string()
} else {
available.join(", ")
};
Error::ConfigNotFound(format!(
"config '{name}' not found in {} (available: {avail})",
dir.display()
))
}
pub fn resolve_config(name: &str) -> Result<PathBuf> {
resolve_config_in(&paths::configs_dir()?, name)
}
pub fn list_configs() -> Result<Vec<String>> {
Ok(list_configs_in(&paths::configs_dir()?))
}
pub fn apply_config_overlay(data_dir: &Path, source: Option<&Path>) -> Result<()> {
let config_d = data_dir.join("config.d");
for ext in CONFIG_EXTS {
let stale = config_d.join(format!("{OVERLAY_STEM}.{ext}"));
if stale.exists() {
std::fs::remove_file(&stale)?;
}
}
let Some(source) = source else {
return Ok(());
};
let ext = source
.extension()
.and_then(|e| e.to_str())
.filter(|e| CONFIG_EXTS.contains(e))
.unwrap_or("xml");
std::fs::create_dir_all(&config_d)?;
std::fs::copy(source, config_d.join(format!("{OVERLAY_STEM}.{ext}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn write_file(dir: &Path, name: &str) {
std::fs::write(dir.join(name), "<clickhouse/>").unwrap();
}
#[test]
fn resolves_by_bare_name() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "analytics.xml");
let path = resolve_config_in(tmp.path(), "analytics").unwrap();
assert_eq!(path, tmp.path().join("analytics.xml"));
}
#[test]
fn resolves_yaml_by_bare_name() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "prod.yaml");
let path = resolve_config_in(tmp.path(), "prod").unwrap();
assert_eq!(path, tmp.path().join("prod.yaml"));
}
#[test]
fn resolves_with_explicit_extension() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "dev.xml");
let path = resolve_config_in(tmp.path(), "dev.xml").unwrap();
assert_eq!(path, tmp.path().join("dev.xml"));
}
#[test]
fn explicit_extension_missing_errors() {
let tmp = tempfile::tempdir().unwrap();
let err = resolve_config_in(tmp.path(), "dev.xml").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not found"), "got: {msg}");
}
#[test]
fn not_found_lists_available() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "dev.xml");
write_file(tmp.path(), "prod.yaml");
let err = resolve_config_in(tmp.path(), "missing").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("missing"), "got: {msg}");
assert!(msg.contains("dev.xml"), "got: {msg}");
assert!(msg.contains("prod.yaml"), "got: {msg}");
}
#[test]
fn not_found_reports_none_when_empty() {
let tmp = tempfile::tempdir().unwrap();
let err = resolve_config_in(tmp.path(), "missing").unwrap_err();
assert!(err.to_string().contains("available: none"));
}
#[test]
fn ambiguous_bare_name_errors() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "shared.xml");
write_file(tmp.path(), "shared.yaml");
let err = resolve_config_in(tmp.path(), "shared").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("ambiguous"), "got: {msg}");
assert!(msg.contains("shared.xml"));
assert!(msg.contains("shared.yaml"));
}
#[test]
fn rejects_parent_dir_escape() {
let tmp = tempfile::tempdir().unwrap();
let outside = tmp.path().join("outside.xml");
std::fs::write(&outside, "<clickhouse/>").unwrap();
let configs = tmp.path().join("configs");
std::fs::create_dir(&configs).unwrap();
let err = resolve_config_in(&configs, "../outside").unwrap_err();
assert!(
matches!(err, Error::InvalidConfigName(_)),
"got: {err:?}"
);
}
#[test]
fn rejects_parent_dir_escape_with_extension() {
let tmp = tempfile::tempdir().unwrap();
let outside = tmp.path().join("outside.xml");
std::fs::write(&outside, "<clickhouse/>").unwrap();
let configs = tmp.path().join("configs");
std::fs::create_dir(&configs).unwrap();
let err = resolve_config_in(&configs, "../outside.xml").unwrap_err();
assert!(
matches!(err, Error::InvalidConfigName(_)),
"got: {err:?}"
);
}
#[test]
fn rejects_absolute_path() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "dev.xml");
let abs = tmp.path().join("dev.xml");
let err = resolve_config_in(tmp.path(), abs.to_str().unwrap()).unwrap_err();
assert!(
matches!(err, Error::InvalidConfigName(_)),
"got: {err:?}"
);
}
#[test]
fn rejects_dotdot() {
let tmp = tempfile::tempdir().unwrap();
let err = resolve_config_in(tmp.path(), "..").unwrap_err();
assert!(
matches!(err, Error::InvalidConfigName(_)),
"got: {err:?}"
);
}
#[test]
fn list_filters_and_sorts() {
let tmp = tempfile::tempdir().unwrap();
write_file(tmp.path(), "prod.yaml");
write_file(tmp.path(), "dev.xml");
write_file(tmp.path(), "notes.txt");
std::fs::create_dir(tmp.path().join("subdir.xml")).unwrap();
let configs = list_configs_in(tmp.path());
assert_eq!(configs, vec!["dev.xml", "prod.yaml"]);
}
#[test]
fn list_nonexistent_dir_is_empty() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does-not-exist");
assert!(list_configs_in(&missing).is_empty());
}
#[test]
fn overlay_stages_file_with_extension() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src.xml");
std::fs::write(&src, "<clickhouse><a>1</a></clickhouse>").unwrap();
let data_dir = tmp.path().join("data");
std::fs::create_dir(&data_dir).unwrap();
apply_config_overlay(&data_dir, Some(&src)).unwrap();
let staged = data_dir.join("config.d").join("chctl-config.xml");
assert!(staged.is_file());
assert_eq!(
std::fs::read_to_string(&staged).unwrap(),
"<clickhouse><a>1</a></clickhouse>"
);
}
#[test]
fn overlay_preserves_yaml_extension() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src.yaml");
std::fs::write(&src, "a: 1").unwrap();
let data_dir = tmp.path().join("data");
apply_config_overlay(&data_dir, Some(&src)).unwrap();
assert!(data_dir.join("config.d").join("chctl-config.yaml").is_file());
}
#[test]
fn overlay_none_clears_previous() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src.xml");
std::fs::write(&src, "<clickhouse/>").unwrap();
let data_dir = tmp.path().join("data");
apply_config_overlay(&data_dir, Some(&src)).unwrap();
assert!(data_dir.join("config.d").join("chctl-config.xml").is_file());
apply_config_overlay(&data_dir, None).unwrap();
assert!(!data_dir.join("config.d").join("chctl-config.xml").exists());
}
#[test]
fn overlay_switching_extension_removes_old() {
let tmp = tempfile::tempdir().unwrap();
let xml = tmp.path().join("a.xml");
std::fs::write(&xml, "<clickhouse/>").unwrap();
let yaml = tmp.path().join("b.yaml");
std::fs::write(&yaml, "a: 1").unwrap();
let data_dir = tmp.path().join("data");
apply_config_overlay(&data_dir, Some(&xml)).unwrap();
apply_config_overlay(&data_dir, Some(&yaml)).unwrap();
let config_d = data_dir.join("config.d");
assert!(!config_d.join("chctl-config.xml").exists());
assert!(config_d.join("chctl-config.yaml").is_file());
}
#[test]
fn overlay_none_on_empty_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().join("data");
apply_config_overlay(&data_dir, None).unwrap();
}
}