Skip to main content

dsc/
config.rs

1use anyhow::{Context, Result};
2use serde::de::Deserializer;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7fn deserialize_opt_string_empty_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
8where
9    D: Deserializer<'de>,
10{
11    let value = Option::<String>::deserialize(deserializer)?;
12    Ok(value.and_then(|s| if s.is_empty() { None } else { Some(s) }))
13}
14
15fn deserialize_opt_u64_zero_as_none<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
16where
17    D: Deserializer<'de>,
18{
19    let value = Option::<u64>::deserialize(deserializer)?;
20    Ok(value.and_then(|v| if v == 0 { None } else { Some(v) }))
21}
22
23/// Top-level configuration for dsc.
24#[derive(Debug, Serialize, Deserialize, Default, Clone)]
25pub struct Config {
26    #[serde(default)]
27    pub discourse: Vec<DiscourseConfig>,
28}
29
30/// Configuration for a single Discourse install.
31#[derive(Debug, Serialize, Deserialize, Default, Clone)]
32pub struct DiscourseConfig {
33    pub name: String,
34    pub baseurl: String,
35    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
36    pub fullname: Option<String>,
37    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
38    pub apikey: Option<String>,
39    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
40    pub api_username: Option<String>,
41    #[serde(default)]
42    pub tags: Option<Vec<String>>,
43    #[serde(default, deserialize_with = "deserialize_opt_u64_zero_as_none")]
44    pub changelog_topic_id: Option<u64>,
45    #[serde(default, deserialize_with = "deserialize_opt_string_empty_as_none")]
46    pub ssh_host: Option<String>,
47}
48
49/// Load configuration from a TOML file.
50pub fn load_config(path: &Path) -> Result<Config> {
51    if !path.exists() {
52        return Ok(Config::default());
53    }
54    let raw = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
55    let config: Config = toml::from_str(&raw).with_context(|| "parsing config")?;
56    warn_on_discourse_names(&config);
57    Ok(config)
58}
59
60/// Save configuration to a TOML file.
61pub fn save_config(path: &Path, config: &Config) -> Result<()> {
62    let raw = toml::to_string_pretty(config).with_context(|| "serializing config")?;
63    write_config_file(path, raw.as_bytes())?;
64    Ok(())
65}
66
67#[cfg(unix)]
68fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
69    use std::io::Write;
70    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
71
72    let mut file = fs::OpenOptions::new()
73        .create(true)
74        .truncate(true)
75        .write(true)
76        .mode(0o600)
77        .open(path)
78        .with_context(|| format!("writing {}", path.display()))?;
79    file.write_all(raw)
80        .with_context(|| format!("writing {}", path.display()))?;
81
82    let metadata = fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
83    let mode = metadata.permissions().mode() & 0o777;
84    if mode & 0o077 != 0 {
85        if let Err(err) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
86            eprintln!(
87                "Warning: unable to tighten permissions on {}: {}",
88                path.display(),
89                err
90            );
91        }
92    }
93    Ok(())
94}
95
96#[cfg(not(unix))]
97fn write_config_file(path: &Path, raw: &[u8]) -> Result<()> {
98    fs::write(path, raw).with_context(|| format!("writing {}", path.display()))?;
99    Ok(())
100}
101
102/// Find a discourse by name.
103pub fn find_discourse<'a>(config: &'a Config, name: &str) -> Option<&'a DiscourseConfig> {
104    config.discourse.iter().find(|d| d.name == name)
105}
106
107/// Find a discourse by name (mutable).
108pub fn find_discourse_mut<'a>(
109    config: &'a mut Config,
110    name: &str,
111) -> Option<&'a mut DiscourseConfig> {
112    config.discourse.iter_mut().find(|d| d.name == name)
113}
114
115fn warn_on_discourse_names(config: &Config) {
116    for discourse in &config.discourse {
117        if discourse.name.chars().any(|ch| ch.is_whitespace()) {
118            eprintln!(
119                "Warning: discourse name '{}' contains whitespace. Prefer a short, slugified name without spaces; use 'fullname' for display.",
120                discourse.name
121            );
122        }
123    }
124}
125
126/// Resolve the default config path when `--config` is not provided.
127///
128/// Search order:
129/// 1. `./dsc.toml`
130/// 2. `$XDG_CONFIG_HOME/dsc/dsc.toml` (or `~/.config/dsc/dsc.toml`)
131/// 3. System config locations (`$XDG_CONFIG_DIRS` + common Unix paths)
132///
133/// If none exist, defaults to `./dsc.toml`.
134pub fn resolve_default_config_path() -> PathBuf {
135    let local = PathBuf::from("dsc.toml");
136    let mut candidates = vec![local.clone()];
137
138    if let Some(xdg_config_home) = std::env::var_os("XDG_CONFIG_HOME") {
139        candidates.push(PathBuf::from(xdg_config_home).join("dsc").join("dsc.toml"));
140    } else if let Some(home) = std::env::var_os("HOME") {
141        candidates.push(
142            PathBuf::from(home)
143                .join(".config")
144                .join("dsc")
145                .join("dsc.toml"),
146        );
147    }
148
149    #[cfg(unix)]
150    {
151        if let Some(xdg_config_dirs) = std::env::var_os("XDG_CONFIG_DIRS") {
152            for dir in std::env::split_paths(&xdg_config_dirs) {
153                candidates.push(dir.join("dsc").join("dsc.toml"));
154            }
155        } else {
156            candidates.push(PathBuf::from("/etc/xdg/dsc/dsc.toml"));
157        }
158        candidates.push(PathBuf::from("/etc/dsc/dsc.toml"));
159        candidates.push(PathBuf::from("/etc/dsc.toml"));
160        candidates.push(PathBuf::from("/usr/local/etc/dsc.toml"));
161    }
162
163    first_existing_config_path(candidates).unwrap_or(local)
164}
165
166fn first_existing_config_path<I>(candidates: I) -> Option<PathBuf>
167where
168    I: IntoIterator<Item = PathBuf>,
169{
170    candidates.into_iter().find(|candidate| candidate.exists())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::first_existing_config_path;
176    use std::path::PathBuf;
177
178    #[test]
179    fn returns_first_existing_path_in_order() {
180        let dir = tempfile::tempdir().expect("tempdir");
181        let first = dir.path().join("first.toml");
182        let second = dir.path().join("second.toml");
183        std::fs::write(&second, "").expect("write");
184        std::fs::write(&first, "").expect("write");
185
186        let selected = first_existing_config_path(vec![first.clone(), second]).expect("selected");
187        assert_eq!(selected, first);
188    }
189
190    #[test]
191    fn returns_none_when_no_candidates_exist() {
192        let dir = tempfile::tempdir().expect("tempdir");
193        let missing = dir.path().join("missing.toml");
194        let selected =
195            first_existing_config_path(vec![missing, PathBuf::from("/definitely/missing")]);
196        assert!(selected.is_none());
197    }
198}