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#[derive(Debug, Serialize, Deserialize, Default, Clone)]
25pub struct Config {
26 #[serde(default)]
27 pub discourse: Vec<DiscourseConfig>,
28}
29
30#[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
49pub 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
60pub 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
102pub fn find_discourse<'a>(config: &'a Config, name: &str) -> Option<&'a DiscourseConfig> {
104 config.discourse.iter().find(|d| d.name == name)
105}
106
107pub 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
126pub 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}