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