Skip to main content

koi_proxy/
config.rs

1use serde::{Deserialize, Serialize};
2
3use koi_common::paths;
4
5use crate::ProxyError;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
8pub struct ProxyEntry {
9    pub name: String,
10    pub listen_port: u16,
11    pub backend: String,
12    #[serde(default)]
13    pub allow_remote: bool,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17struct ProxySection {
18    #[serde(default)]
19    entries: Vec<ProxyEntry>,
20}
21
22pub fn config_path() -> std::path::PathBuf {
23    paths::koi_data_dir().join("config.toml")
24}
25
26pub fn config_path_with_override(data_dir: Option<&std::path::Path>) -> std::path::PathBuf {
27    paths::koi_data_dir_with_override(data_dir).join("config.toml")
28}
29
30pub fn load_entries() -> Result<Vec<ProxyEntry>, ProxyError> {
31    load_entries_from(&config_path())
32}
33
34pub fn load_entries_with_data_dir(
35    data_dir: Option<&std::path::Path>,
36) -> Result<Vec<ProxyEntry>, ProxyError> {
37    load_entries_from(&config_path_with_override(data_dir))
38}
39
40fn load_entries_from(path: &std::path::Path) -> Result<Vec<ProxyEntry>, ProxyError> {
41    if !path.exists() {
42        return Ok(Vec::new());
43    }
44    let raw = std::fs::read_to_string(path).map_err(|e| ProxyError::Io(e.to_string()))?;
45    let value: toml::Value = raw
46        .parse()
47        .map_err(|e| ProxyError::Config(format!("Invalid config.toml: {e}")))?;
48    let proxy = value
49        .get("proxy")
50        .cloned()
51        .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
52    let proxy: ProxySection = proxy
53        .try_into()
54        .map_err(|e| ProxyError::Config(format!("Invalid proxy section: {e}")))?;
55    Ok(proxy.entries)
56}
57
58pub fn save_entries(entries: &[ProxyEntry]) -> Result<(), ProxyError> {
59    save_entries_to(entries, &config_path())
60}
61
62fn save_entries_to(entries: &[ProxyEntry], path: &std::path::Path) -> Result<(), ProxyError> {
63    if let Some(parent) = path.parent() {
64        std::fs::create_dir_all(parent).map_err(|e| ProxyError::Io(e.to_string()))?;
65    }
66
67    let mut root = if path.exists() {
68        let raw = std::fs::read_to_string(path).map_err(|e| ProxyError::Io(e.to_string()))?;
69        raw.parse::<toml::Value>()
70            .unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new()))
71    } else {
72        toml::Value::Table(toml::map::Map::new())
73    };
74
75    let proxy = ProxySection {
76        entries: entries.to_vec(),
77    };
78    let proxy_value = toml::Value::try_from(proxy)
79        .map_err(|e| ProxyError::Config(format!("Proxy config serialize error: {e}")))?;
80
81    if let toml::Value::Table(table) = &mut root {
82        table.insert("proxy".to_string(), proxy_value);
83    }
84
85    let raw = toml::to_string_pretty(&root)
86        .map_err(|e| ProxyError::Config(format!("Config serialize error: {e}")))?;
87    std::fs::write(path, raw).map_err(|e| ProxyError::Io(e.to_string()))?;
88    Ok(())
89}
90
91pub fn upsert_entry(entry: ProxyEntry) -> Result<Vec<ProxyEntry>, ProxyError> {
92    upsert_entry_with_data_dir(entry, None)
93}
94
95pub fn upsert_entry_with_data_dir(
96    entry: ProxyEntry,
97    data_dir: Option<&std::path::Path>,
98) -> Result<Vec<ProxyEntry>, ProxyError> {
99    let path = config_path_with_override(data_dir);
100    let mut entries = load_entries_from(&path)?;
101    if let Some(existing) = entries.iter_mut().find(|e| e.name == entry.name) {
102        *existing = entry;
103    } else {
104        entries.push(entry);
105    }
106    entries.sort_by(|a, b| a.name.cmp(&b.name));
107    save_entries_to(&entries, &path)?;
108    Ok(entries)
109}
110
111pub fn remove_entry(name: &str) -> Result<Vec<ProxyEntry>, ProxyError> {
112    remove_entry_with_data_dir(name, None)
113}
114
115pub fn remove_entry_with_data_dir(
116    name: &str,
117    data_dir: Option<&std::path::Path>,
118) -> Result<Vec<ProxyEntry>, ProxyError> {
119    let path = config_path_with_override(data_dir);
120    let mut entries = load_entries_from(&path)?;
121    let before = entries.len();
122    entries.retain(|e| e.name != name);
123    if entries.len() == before {
124        return Err(ProxyError::NotFound(name.to_string()));
125    }
126    save_entries_to(&entries, &path)?;
127    Ok(entries)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn config_path_is_under_data_dir() {
136        let _ = koi_common::test::ensure_data_dir("koi-proxy-config-tests");
137        let path = config_path();
138        assert!(path.ends_with("config.toml"));
139    }
140
141    #[test]
142    fn proxy_entry_round_trip() {
143        let entry = ProxyEntry {
144            name: "grafana".to_string(),
145            listen_port: 443,
146            backend: "http://localhost:3000".to_string(),
147            allow_remote: false,
148        };
149        let proxy = ProxySection {
150            entries: vec![entry.clone()],
151        };
152        let value = toml::Value::try_from(proxy).unwrap();
153        let decoded: ProxySection = value.try_into().unwrap();
154        assert_eq!(decoded.entries[0], entry);
155    }
156}