use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Debug, Clone, Copy, ValueEnum, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BridgeMode {
Tcp,
Udp,
}
pub fn parse_duration(input: &str) -> Option<Duration> {
if input.trim().is_empty() {
return None;
}
let lower = input.trim().to_ascii_lowercase();
if lower == "0" {
return Some(Duration::from_millis(0));
}
let (num_str, unit) = if lower.ends_with("ms") {
(&lower[..lower.len() - 2], "ms")
} else if lower.ends_with('h') {
(&lower[..lower.len() - 1], "h")
} else if lower.ends_with('m') {
(&lower[..lower.len() - 1], "m")
} else if lower.ends_with('s') {
(&lower[..lower.len() - 1], "s")
} else {
(lower.as_str(), "ms")
};
let value: u64 = num_str.parse().ok()?;
match unit {
"ms" => Some(Duration::from_millis(value)),
"s" => Some(Duration::from_secs(value)),
"m" => Some(Duration::from_secs(value * 60)),
"h" => Some(Duration::from_secs(value * 60 * 60)),
_ => None,
}
}
impl std::fmt::Display for BridgeMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BridgeMode::Tcp => write!(f, "tcp"),
BridgeMode::Udp => write!(f, "udp"),
}
}
}
pub(crate) const DEFAULT_BIND: &str = "127.0.0.1:8121";
pub(crate) const DEFAULT_TARGET: &str = "127.0.0.1:8121";
pub(crate) const DEFAULT_MODE: BridgeMode = BridgeMode::Tcp;
pub(crate) const DEFAULT_IDLE_EXIT: &str = "2h"; pub(crate) const DEFAULT_MAX_BYTES: u64 = 1_073_741_824; pub(crate) const DEFAULT_SOCKET: &str = "/run/wsl-clip/socket";
pub mod loader {
use super::*;
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
pub struct BridgeConfig {
pub bind: Option<String>,
pub target: Option<String>,
pub mode: Option<BridgeMode>,
pub idle_exit: Option<String>,
pub ttl: Option<String>,
pub max_bytes: Option<u64>,
pub debug: Option<bool>,
pub socket: Option<PathBuf>,
pub log_file: Option<PathBuf>,
pub token: Option<String>,
pub token_file: Option<PathBuf>,
}
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
pub struct AppConfig {
pub bridge: Option<BridgeConfig>,
}
fn default_config_path() -> Option<PathBuf> {
if let Ok(home) = std::env::var("HOME") {
let p = Path::new(&home).join(".config/wsl-clip/config.toml");
return Some(p);
}
None
}
pub fn load_config() -> AppConfig {
let path = if let Some(p) = default_config_path() {
p
} else {
return AppConfig::default();
};
let data = match fs::read_to_string(&path) {
Ok(d) => d,
Err(_) => return AppConfig::default(),
};
toml::from_str(&data).unwrap_or_default()
}
}
pub mod resolved {
use super::*;
#[derive(Debug, Clone)]
pub enum BridgeCliOverrides {
Listen {
bind: Option<String>,
mode: Option<BridgeMode>,
idle_exit: Option<String>,
ttl: Option<String>,
max_bytes: Option<u64>,
token: Option<String>,
token_file: Option<PathBuf>,
daemon: bool,
log_file: Option<PathBuf>,
},
Connect {
to: Option<String>,
socket: Option<PathBuf>,
mode: Option<BridgeMode>,
token: Option<String>,
token_file: Option<PathBuf>,
},
Status,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum ResolvedBridgeCommand {
Listen(ResolvedBridgeListen),
Connect(ResolvedBridgeConnect),
Status(ResolvedBridgeStatus),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResolvedBridgeListen {
pub bind: String,
pub mode: BridgeMode,
pub idle_exit: Option<String>,
pub ttl: Option<String>,
pub max_bytes: u64,
pub token: Option<String>,
pub token_file: Option<PathBuf>,
pub daemon: bool,
pub log_file: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResolvedBridgeConnect {
pub to: String,
pub socket: PathBuf,
pub mode: BridgeMode,
pub token: Option<String>,
pub token_file: Option<PathBuf>,
pub max_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ResolvedBridgeStatus {
pub bind: String,
pub socket: PathBuf,
pub mode: BridgeMode,
}
pub fn resolve_bridge_command(
overrides: BridgeCliOverrides,
cfg: &crate::config::loader::AppConfig,
) -> ResolvedBridgeCommand {
let bridge_cfg = cfg.bridge.clone().unwrap_or_default();
match overrides {
BridgeCliOverrides::Listen {
bind,
mode,
idle_exit,
ttl,
max_bytes,
token,
token_file,
daemon,
log_file,
} => {
let bind = bind
.or(bridge_cfg.bind.clone())
.unwrap_or_else(|| DEFAULT_BIND.to_string());
let mode = mode.or(bridge_cfg.mode).unwrap_or(DEFAULT_MODE);
let idle_exit = idle_exit
.or(bridge_cfg.idle_exit.clone())
.or_else(|| Some(DEFAULT_IDLE_EXIT.to_string()));
let ttl = ttl.or(bridge_cfg.ttl.clone());
let max_bytes = max_bytes
.or(bridge_cfg.max_bytes)
.unwrap_or(DEFAULT_MAX_BYTES);
let token = token.or(bridge_cfg.token.clone());
let token_file = token_file.or(bridge_cfg.token_file.clone());
let log_file = log_file.or(bridge_cfg.log_file.clone());
ResolvedBridgeCommand::Listen(ResolvedBridgeListen {
bind,
mode,
idle_exit,
ttl,
max_bytes,
token,
token_file,
daemon,
log_file,
})
}
BridgeCliOverrides::Connect {
to,
socket,
mode,
token,
token_file,
} => {
let to = to
.or(bridge_cfg.target.clone())
.or(bridge_cfg.bind.clone())
.unwrap_or_else(|| DEFAULT_TARGET.to_string());
let socket = socket
.or(bridge_cfg.socket.clone())
.unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET));
let mode = mode.or(bridge_cfg.mode).unwrap_or(DEFAULT_MODE);
let token = token.or(bridge_cfg.token.clone());
let token_file = token_file.or(bridge_cfg.token_file.clone());
ResolvedBridgeCommand::Connect(ResolvedBridgeConnect {
to,
socket,
mode,
token,
token_file,
max_bytes: bridge_cfg.max_bytes.unwrap_or(DEFAULT_MAX_BYTES),
})
}
BridgeCliOverrides::Status => {
let bind = bridge_cfg.bind.unwrap_or_else(|| DEFAULT_BIND.to_string());
let socket = bridge_cfg
.socket
.unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET));
let mode = bridge_cfg.mode.unwrap_or(DEFAULT_MODE);
ResolvedBridgeCommand::Status(ResolvedBridgeStatus { bind, socket, mode })
}
}
}
}
pub use loader::{AppConfig, BridgeConfig, load_config};
pub use resolved::{BridgeCliOverrides, ResolvedBridgeCommand, ResolvedBridgeConnect, ResolvedBridgeListen, ResolvedBridgeStatus, resolve_bridge_command};
#[cfg(test)]
mod tests {
use super::*;
fn cfg_from_str(src: &str) -> AppConfig {
toml::from_str(src).unwrap()
}
#[test]
fn value_enum_roundtrip() {
let cfg: BridgeConfig = toml::from_str("mode = \"tcp\"").unwrap();
assert!(matches!(cfg.mode, Some(BridgeMode::Tcp)));
}
#[test]
fn parse_duration_supports_units() {
assert_eq!(parse_duration("1500").unwrap(), Duration::from_millis(1500));
assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2));
assert_eq!(parse_duration("3m").unwrap(), Duration::from_secs(180));
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
assert_eq!(parse_duration("0").unwrap(), Duration::from_millis(0));
}
#[test]
fn listen_defaults_when_empty() {
let cfg = AppConfig::default();
let resolved = resolve_bridge_command(
BridgeCliOverrides::Listen {
bind: None,
mode: None,
idle_exit: None,
ttl: None,
max_bytes: None,
token: None,
token_file: None,
daemon: false,
log_file: None,
},
&cfg,
);
match resolved {
ResolvedBridgeCommand::Listen(listen) => {
assert_eq!(listen.bind, DEFAULT_BIND);
assert_eq!(listen.mode, DEFAULT_MODE);
assert_eq!(listen.idle_exit, Some(DEFAULT_IDLE_EXIT.to_string()));
assert_eq!(listen.max_bytes, DEFAULT_MAX_BYTES);
assert!(!listen.daemon);
}
_ => panic!("expected listen"),
}
}
#[test]
fn cli_overrides_config() {
let cfg = cfg_from_str(
r#"[bridge]
bind = "10.0.0.5:9000"
mode = "udp"
idle_exit = "30m"
max_bytes = 42
"#,
);
let resolved = resolve_bridge_command(
BridgeCliOverrides::Listen {
bind: Some("1.2.3.4:9999".into()),
mode: Some(BridgeMode::Tcp),
idle_exit: Some("10m".into()),
ttl: None,
max_bytes: Some(100),
token: None,
token_file: None,
daemon: true,
log_file: None,
},
&cfg,
);
match resolved {
ResolvedBridgeCommand::Listen(listen) => {
assert_eq!(listen.bind, "1.2.3.4:9999");
assert_eq!(listen.mode, BridgeMode::Tcp);
assert_eq!(listen.idle_exit, Some("10m".into()));
assert_eq!(listen.max_bytes, 100);
assert!(listen.daemon);
}
_ => panic!("expected listen"),
}
}
#[test]
fn connect_target_precedence_cli_over_config_over_default() {
let cfg = cfg_from_str(
r#"[bridge]
target = "10.1.1.1:8121"
bind = "10.2.2.2:9000"
socket = "/tmp/custom.sock"
mode = "udp"
"#,
);
let resolved = resolve_bridge_command(
BridgeCliOverrides::Connect {
to: Some("1.1.1.1:7000".into()),
socket: None,
mode: None,
token: None,
token_file: None,
},
&cfg,
);
match resolved {
ResolvedBridgeCommand::Connect(conn) => {
assert_eq!(conn.to, "1.1.1.1:7000");
assert_eq!(conn.socket, PathBuf::from("/tmp/custom.sock"));
assert_eq!(conn.mode, BridgeMode::Udp);
}
_ => panic!("expected connect"),
}
}
#[test]
fn connect_falls_back_to_default_when_no_config() {
let cfg = AppConfig::default();
let resolved = resolve_bridge_command(
BridgeCliOverrides::Connect {
to: None,
socket: None,
mode: None,
token: None,
token_file: None,
},
&cfg,
);
match resolved {
ResolvedBridgeCommand::Connect(conn) => {
assert_eq!(conn.to, DEFAULT_TARGET);
assert_eq!(conn.socket, PathBuf::from(DEFAULT_SOCKET));
assert_eq!(conn.mode, DEFAULT_MODE);
}
_ => panic!("expected connect"),
}
}
#[test]
fn status_uses_defaults_and_config() {
let cfg = cfg_from_str(
r#"[bridge]
bind = "10.0.0.5:8121"
socket = "/tmp/x.sock"
"#,
);
let resolved = resolve_bridge_command(BridgeCliOverrides::Status, &cfg);
match resolved {
ResolvedBridgeCommand::Status(status) => {
assert_eq!(status.bind, "10.0.0.5:8121");
assert_eq!(status.socket, PathBuf::from("/tmp/x.sock"));
assert_eq!(status.mode, DEFAULT_MODE);
}
_ => panic!("expected status"),
}
}
}