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#[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}