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}