1use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use anyhow::{Context, Result, anyhow};
9use serde::{Deserialize, Serialize};
10
11pub fn sanitize_path(path: &Path) -> Result<PathBuf> {
14 let canonical = fs::canonicalize(path)
16 .with_context(|| format!("failed to resolve path: {}", path.display()))?;
17 Ok(canonical)
18}
19
20pub fn safe_read_to_string(path: &Path) -> Result<String> {
22 let safe_path = sanitize_path(path)?;
23 fs::read_to_string(&safe_path) .with_context(|| format!("failed to read file: {}", safe_path.display()))
26}
27
28pub fn safe_copy(from: &Path, to: &Path) -> Result<u64> {
30 let safe_from = sanitize_path(from)?;
31 let to_parent = to
33 .parent()
34 .ok_or_else(|| anyhow!("invalid destination path"))?;
35 let _ = sanitize_path(to_parent)?;
36 #[allow(clippy::let_and_return)]
38 let bytes = fs::copy(&safe_from, to)?; Ok(bytes)
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize, Default)]
44pub struct Config {
45 pub servers: HashMap<String, ServerConfig>,
46}
47
48#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct ServerConfig {
51 pub socket: Option<String>,
52 pub cmd: Option<String>,
53 pub args: Option<Vec<String>>,
54 pub max_active_clients: Option<usize>,
55 pub tray: Option<bool>,
56 pub service_name: Option<String>,
57 pub log_level: Option<String>,
58 pub lazy_start: Option<bool>,
59 pub max_request_bytes: Option<usize>,
60 pub request_timeout_ms: Option<u64>,
61 pub restart_backoff_ms: Option<u64>,
62 pub restart_backoff_max_ms: Option<u64>,
63 pub max_restarts: Option<u64>,
64 pub status_file: Option<String>,
65}
66
67#[derive(Clone, Debug)]
69pub struct ResolvedParams {
70 pub socket: PathBuf,
71 pub cmd: String,
72 pub args: Vec<String>,
73 pub max_clients: usize,
74 pub tray_enabled: bool,
75 pub log_level: String,
76 pub service_name: String,
77 pub lazy_start: bool,
78 pub max_request_bytes: usize,
79 pub request_timeout: Duration,
80 pub restart_backoff: Duration,
81 pub restart_backoff_max: Duration,
82 pub max_restarts: u64,
83 pub status_file: Option<PathBuf>,
84}
85
86pub trait CliOptions {
91 fn socket(&self) -> Option<PathBuf>;
92 fn cmd(&self) -> Option<String>;
93 fn args(&self) -> Vec<String>;
94 fn max_active_clients(&self) -> usize;
95 fn lazy_start(&self) -> Option<bool>;
96 fn max_request_bytes(&self) -> Option<usize>;
97 fn request_timeout_ms(&self) -> Option<u64>;
98 fn restart_backoff_ms(&self) -> Option<u64>;
99 fn restart_backoff_max_ms(&self) -> Option<u64>;
100 fn max_restarts(&self) -> Option<u64>;
101 fn log_level(&self) -> String;
102 fn tray(&self) -> bool;
103 fn service_name(&self) -> Option<String>;
104 fn service(&self) -> Option<String>;
105 fn status_file(&self) -> Option<PathBuf>;
106}
107
108pub fn expand_path(raw: impl AsRef<str>) -> PathBuf {
109 let s = raw.as_ref();
110 if let Some(stripped) = s.strip_prefix("~/")
111 && let Some(home) = std::env::var_os("HOME")
112 {
113 return PathBuf::from(home).join(stripped);
114 }
115 PathBuf::from(s)
116}
117
118pub fn load_config(path: &Path) -> Result<Option<Config>> {
119 if !path.exists() {
120 return Ok(None);
121 }
122 let data = safe_read_to_string(path)?;
123
124 let ext = path
125 .extension()
126 .and_then(|e| e.to_str())
127 .unwrap_or("")
128 .to_ascii_lowercase();
129
130 let cfg: Config = match ext.as_str() {
131 "yaml" | "yml" => serde_yaml::from_str(&data)
132 .with_context(|| format!("failed to parse yaml config {}", path.display()))?,
133 "toml" => toml::from_str(&data)
134 .with_context(|| format!("failed to parse toml config {}", path.display()))?,
135 _ => serde_json::from_str(&data)
136 .with_context(|| format!("failed to parse json config {}", path.display()))?,
137 };
138 Ok(Some(cfg))
139}
140
141pub fn resolve_params<C: CliOptions>(cli: &C, config: Option<&Config>) -> Result<ResolvedParams> {
145 let service_cfg = if let Some(cfg) = config {
146 if let Some(name) = cli.service() {
147 let found = cfg
148 .servers
149 .get(&name)
150 .cloned()
151 .ok_or_else(|| anyhow!("service '{name}' not found in config"))?;
152 Some((name, found))
153 } else {
154 None
155 }
156 } else {
157 None
158 };
159
160 if config.is_some() && cli.service().is_none() {
161 return Err(anyhow!("--service is required when using --config"));
162 }
163
164 let socket = cli
165 .socket()
166 .or_else(|| {
167 service_cfg
168 .as_ref()
169 .and_then(|(_, c)| c.socket.clone().map(expand_path))
170 })
171 .ok_or_else(|| anyhow!("socket path not provided (use --socket or config)"))?;
172
173 let cmd = cli
174 .cmd()
175 .or_else(|| service_cfg.as_ref().and_then(|(_, c)| c.cmd.clone()))
176 .ok_or_else(|| anyhow!("cmd not provided (use --cmd or config)"))?;
177
178 let cli_args = cli.args();
179 let args = if !cli_args.is_empty() {
180 cli_args
181 } else {
182 service_cfg
183 .as_ref()
184 .and_then(|(_, c)| c.args.clone())
185 .unwrap_or_default()
186 };
187
188 let max_clients = service_cfg
189 .as_ref()
190 .and_then(|(_, c)| c.max_active_clients)
191 .unwrap_or_else(|| cli.max_active_clients());
192
193 let tray_enabled = if cli.tray() {
194 true
195 } else {
196 service_cfg
197 .as_ref()
198 .and_then(|(_, c)| c.tray)
199 .unwrap_or(false)
200 };
201
202 let log_level = service_cfg
203 .as_ref()
204 .and_then(|(_, c)| c.log_level.clone())
205 .unwrap_or_else(|| cli.log_level());
206
207 let lazy_start = cli.lazy_start().unwrap_or_else(|| {
208 service_cfg
209 .as_ref()
210 .and_then(|(_, c)| c.lazy_start)
211 .unwrap_or(false)
212 });
213
214 let max_request_bytes = cli.max_request_bytes().unwrap_or_else(|| {
215 service_cfg
216 .as_ref()
217 .and_then(|(_, c)| c.max_request_bytes)
218 .unwrap_or(1_048_576)
219 });
220
221 let request_timeout = Duration::from_millis(cli.request_timeout_ms().unwrap_or_else(|| {
222 service_cfg
223 .as_ref()
224 .and_then(|(_, c)| c.request_timeout_ms)
225 .unwrap_or(30_000)
226 }));
227
228 let restart_backoff = Duration::from_millis(
229 cli.restart_backoff_ms()
230 .or_else(|| service_cfg.as_ref().and_then(|(_, c)| c.restart_backoff_ms))
231 .unwrap_or(1_000),
232 );
233 let restart_backoff_max = Duration::from_millis(
234 cli.restart_backoff_max_ms()
235 .or_else(|| {
236 service_cfg
237 .as_ref()
238 .and_then(|(_, c)| c.restart_backoff_max_ms)
239 })
240 .unwrap_or(30_000),
241 );
242 let max_restarts = cli
243 .max_restarts()
244 .or_else(|| service_cfg.as_ref().and_then(|(_, c)| c.max_restarts))
245 .unwrap_or(5);
246
247 let status_file = cli
248 .status_file()
249 .map(|p| p.to_str().map(expand_path).unwrap_or_else(|| p.clone()))
250 .or_else(|| {
251 service_cfg
252 .as_ref()
253 .and_then(|(_, c)| c.status_file.as_deref().map(expand_path))
254 });
255
256 let service_name_raw = cli
257 .service_name()
258 .or_else(|| {
259 service_cfg
260 .as_ref()
261 .and_then(|(_, c)| c.service_name.clone())
262 })
263 .or_else(|| {
264 socket
265 .file_name()
266 .and_then(|n| n.to_string_lossy().split('.').next().map(|s| s.to_string()))
267 })
268 .unwrap_or_else(|| "rmcp_mux".to_string());
269
270 Ok(ResolvedParams {
271 socket,
272 cmd,
273 args,
274 max_clients,
275 tray_enabled,
276 log_level,
277 service_name: service_name_raw,
278 lazy_start,
279 max_request_bytes,
280 request_timeout,
281 restart_backoff,
282 restart_backoff_max,
283 max_restarts,
284 status_file,
285 })
286}