use anyhow::{Context, Result};
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
fn deserialize_opt_string_empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
Ok(value.and_then(|s| if s.is_empty() { None } else { Some(s) }))
}
fn deserialize_opt_u64_zero_as_none<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<u64>::deserialize(deserializer)?;
Ok(value.and_then(|v| if v == 0 { None } else { Some(v) }))
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct Config {
#[serde(default)]
pub discourse: Vec<DiscourseConfig>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct DiscourseConfig {
pub name: String,
pub baseurl: String,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub fullname: Option<String>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub apikey: Option<String>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub api_username: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
pub changelog_topic_id: Option<u64>,
#[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
pub ssh_host: Option<String>,
}
pub fn load_config(path: &Path) -> Result<Config> {
if !path.exists() {
return Ok(Config::default());
}
let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let config: Config = toml::from_str(&raw).with_context(|| "parsing config")?;
warn_on_discourse_names(&config);
Ok(config)
}
pub fn save_config(path: &Path, config: &Config) -> Result<()> {
let raw = toml::to_string_pretty(config).with_context(|| "serializing config")?;
write_config_file(path, raw.as_bytes())?;
Ok(())
}
#[cfg(unix)]
fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)
.with_context(|| format!("writing {}", path.display()))?;
file.write_all(raw)
.with_context(|| format!("writing {}", path.display()))?;
let metadata = fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
eprintln!(
"Warning: unable to tighten permissions on {}: {}",
path.display(),
err
);
}
}
Ok(())
}
#[cfg(not(unix))]
fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
fs::write(path, raw).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
pub fn find_discourse<'a>(config: &'a Config, name: &str) -> Option<&'a DiscourseConfig> {
config.discourse.iter().find(|d| d.name == name)
}
pub fn find_discourse_mut<'a>(
config: &'a mut Config,
name: &str,
) -> Option<&'a mut DiscourseConfig> {
config.discourse.iter_mut().find(|d| d.name == name)
}
fn warn_on_discourse_names(config: &Config) {
for discourse in &config.discourse {
if discourse.name.chars().any(|ch| ch.is_whitespace()) {
eprintln!(
"Warning: discourse name '{}' contains whitespace. Prefer a short, slugified name without spaces; use 'fullname' for display.",
discourse.name
);
}
}
}
pub fn resolve_default_config_path() -> PathBuf {
let local = PathBuf::from("dsc.toml");
let mut candidates = vec![local.clone()];
if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") {
candidates.push(PathBuf::from(xdg_config_home).join("dsc").join("dsc.toml"));
} else if let Some(home) = std::env::var_os("HOME") {
candidates.push(
PathBuf::from(home)
.join(".config")
.join("dsc")
.join("dsc.toml"),
);
}
#[cfg(unix)]
{
if let Some(xdg_config_dirs) = std::env::var_os("XDG_CONFIG_DIRS") {
for dir in std::env::split_paths(&xdg_config_dirs) {
candidates.push(dir.join("dsc").join("dsc.toml"));
}
} else {
candidates.push(PathBuf::from("/etc/xdg/dsc/dsc.toml"));
}
candidates.push(PathBuf::from("/etc/dsc/dsc.toml"));
candidates.push(PathBuf::from("/etc/dsc.toml"));
candidates.push(PathBuf::from("/usr/local/etc/dsc.toml"));
}
first_existing_config_path(candidates).unwrap_or(local)
}
fn first_existing_config_path<I>(candidates: I) -> Option<PathBuf>
where
I: IntoIterator<Item = PathBuf>,
{
candidates.into_iter().find(|candidate| candidate.exists())
}
#[cfg(test)]
mod tests {
use super::first_existing_config_path;
use std::path::PathBuf;
#[test]
fn returns_first_existing_path_in_order() {
let dir = tempfile::tempdir().expect("tempdir");
let first = dir.path().join("first.toml");
let second = dir.path().join("second.toml");
std::fs::write(&second, "").expect("write");
std::fs::write(&first, "").expect("write");
let selected = first_existing_config_path(vec![first.clone(), second]).expect("selected");
assert_eq!(selected, first);
}
#[test]
fn returns_none_when_no_candidates_exist() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("missing.toml");
let selected =
first_existing_config_path(vec![missing, PathBuf::from("/definitely/missing")]);
assert!(selected.is_none());
}
}