use std::{collections::BTreeSet, io::IsTerminal as _, path::Path};
use libmoshpit::{KexConfig as _, PathDefaults as _};
use serde::Serialize;
use crate::{cli::Cli, config::Config};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Origin {
CommandLine,
Environment,
ConfigFile,
Default,
}
impl Origin {
fn label(self) -> &'static str {
match self {
Origin::CommandLine => "command line",
Origin::Environment => "environment",
Origin::ConfigFile => "config file",
Origin::Default => "default",
}
}
fn colored(self) -> String {
use crossterm::style::Stylize as _;
match self {
Origin::CommandLine => self.label().cyan().to_string(),
Origin::Environment => self.label().yellow().to_string(),
Origin::ConfigFile => self.label().green().to_string(),
Origin::Default => self.label().dark_grey().to_string(),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct EffectiveRow {
field: String,
value: String,
origin: Origin,
}
#[derive(Serialize)]
struct JsonRow<'a> {
field: &'a str,
value: &'a str,
source: &'a str,
}
fn classify(from_env: bool, from_cli: bool, from_file: bool) -> Origin {
if from_env {
Origin::Environment
} else if from_cli {
Origin::CommandLine
} else if from_file {
Origin::ConfigFile
} else {
Origin::Default
}
}
fn toml_keys(path: &Path) -> BTreeSet<String> {
let mut keys = BTreeSet::new();
let Ok(text) = std::fs::read_to_string(path) else {
return keys;
};
let Ok(table) = text.parse::<toml::Table>() else {
return keys;
};
for (key, value) in &table {
let _ = keys.insert(key.clone());
if let toml::Value::Table(sub) = value {
for sub_key in sub.keys() {
let _ = keys.insert(format!("{key}.{sub_key}"));
}
}
}
keys
}
fn token<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).map_or_else(
|_| "<unserializable>".to_string(),
|s| s.trim_matches('"').to_string(),
)
}
fn opt(value: Option<&str>) -> String {
value.map_or_else(|| "<unset>".to_string(), ToString::to_string)
}
fn list(value: &[String]) -> String {
if value.is_empty() {
"<empty>".to_string()
} else {
value.join(", ")
}
}
struct Ctx<'a> {
cli: &'a Cli,
toml: &'a BTreeSet<String>,
prefix: &'a str,
}
impl Ctx<'_> {
fn row(
&self,
field: &str,
value: String,
clap_id: Option<&str>,
env_suffix: Option<&str>,
toml_key: Option<&str>,
) -> EffectiveRow {
let from_env =
env_suffix.is_some_and(|s| std::env::var_os(format!("{}_{s}", self.prefix)).is_some());
let from_cli = clap_id.is_some_and(|id| self.cli.explicit_args().contains(id));
let from_file = toml_key.is_some_and(|k| self.toml.contains(k));
EffectiveRow {
field: field.to_string(),
value,
origin: classify(from_env, from_cli, from_file),
}
}
}
#[allow(clippy::too_many_lines)] pub(crate) fn resolve_effective(
cli: &Cli,
config: &Config,
config_path: &Path,
) -> Vec<EffectiveRow> {
let toml = toml_keys(config_path);
let prefix = cli.env_prefix();
let ctx = Ctx {
cli,
toml: &toml,
prefix: &prefix,
};
let algos = config.preferred_algorithms();
let destination = if config.server_destination().is_empty() {
"<unset>".to_string()
} else {
config.server_destination().clone()
};
let tracing = serde_json::to_string(config.tracing().file())
.unwrap_or_else(|_| "<unserializable>".to_string());
vec![
ctx.row(
"config_path",
config_path.display().to_string(),
Some("config_absolute_path"),
None,
None,
),
ctx.row(
"tracing_path",
opt(cli.tracing_absolute_path().as_deref()),
Some("tracing_absolute_path"),
None,
None,
),
ctx.row(
"server_destination",
destination,
Some("server_destination"),
Some("SERVER_DESTINATION"),
Some("server_destination"),
),
ctx.row(
"server_port",
config.server_port().to_string(),
Some("server_port"),
Some("SERVER_PORT"),
Some("server_port"),
),
ctx.row(
"private_key_path",
opt(config.private_key_path().as_deref()),
Some("private_key_path"),
Some("PRIVATE_KEY_PATH"),
Some("private_key_path"),
),
ctx.row(
"public_key_path",
opt(config.public_key_path().as_deref()),
Some("public_key_path"),
Some("PUBLIC_KEY_PATH"),
Some("public_key_path"),
),
ctx.row(
"max_reconnect_backoff_secs",
config.max_reconnect_backoff_secs().to_string(),
None,
Some("MAX_RECONNECT_BACKOFF_SECS"),
Some("max_reconnect_backoff_secs"),
),
ctx.row(
"predict",
token(&config.predict()),
Some("predict"),
Some("PREDICT"),
Some("predict"),
),
ctx.row(
"nat_warmup",
config.nat_warmup().to_string(),
Some("nat_warmup"),
Some("NAT_WARMUP"),
Some("nat_warmup"),
),
ctx.row(
"nat_warmup_count",
config.nat_warmup_count().to_string(),
Some("nat_warmup_count"),
Some("NAT_WARMUP_COUNT"),
Some("nat_warmup_count"),
),
ctx.row(
"diff_mode",
token(&config.diff_mode()),
Some("diff_mode"),
Some("DIFF_MODE"),
Some("diff_mode"),
),
ctx.row(
"legacy_passthrough",
config.legacy_passthrough().to_string(),
Some("legacy_passthrough"),
Some("LEGACY_PASSTHROUGH"),
Some("legacy_passthrough"),
),
ctx.row(
"preferred_algorithms.kex",
list(&algos.kex),
Some("kex_algos"),
None,
Some("preferred_algorithms.kex"),
),
ctx.row(
"preferred_algorithms.aead",
list(&algos.aead),
Some("aead_algos"),
None,
Some("preferred_algorithms.aead"),
),
ctx.row(
"preferred_algorithms.mac",
list(&algos.mac),
Some("mac_algos"),
None,
Some("preferred_algorithms.mac"),
),
ctx.row(
"preferred_algorithms.kdf",
list(&algos.kdf),
Some("kdf_algos"),
None,
Some("preferred_algorithms.kdf"),
),
ctx.row(
"send_env",
list(config.send_env()),
None,
None,
Some("send_env"),
),
ctx.row(
"send_path",
list(config.send_path()),
None,
None,
Some("send_path"),
),
ctx.row("tracing", tracing, None, None, Some("tracing")),
]
}
const MAX_VALUE_WIDTH: usize = 72;
fn elide(value: &str) -> String {
if value.chars().count() <= MAX_VALUE_WIDTH {
value.to_string()
} else {
let head: String = value.chars().take(MAX_VALUE_WIDTH - 1).collect();
format!("{head}…")
}
}
pub(crate) fn print_table(rows: &[EffectiveRow]) {
use crossterm::style::Stylize as _;
let color = std::io::stdout().is_terminal();
let values: Vec<String> = rows.iter().map(|r| elide(&r.value)).collect();
let field_w = rows
.iter()
.map(|r| r.field.len())
.max()
.unwrap_or(5)
.max("FIELD".len());
let value_w = values
.iter()
.map(|v| v.chars().count())
.max()
.unwrap_or(5)
.max("VALUE".len());
let header = format!("{:<field_w$} {:<value_w$} SOURCE", "FIELD", "VALUE");
let separator = format!(
"{:<field_w$} {:<value_w$} ------",
"-".repeat(field_w),
"-".repeat(value_w),
);
if color {
println!("{}", header.blue().bold());
println!("{}", separator.blue().bold());
} else {
println!("{header}");
println!("{separator}");
}
for (r, value) in rows.iter().zip(&values) {
if color {
let field = format!("{:<field_w$}", r.field);
let value = format!("{value:<value_w$}");
println!(
"{} {} {}",
field.green().bold(),
value.bold(),
r.origin.colored()
);
} else {
println!(
"{:<field_w$} {value:<value_w$} {}",
r.field,
r.origin.label()
);
}
}
}
pub(crate) fn print_json(rows: &[EffectiveRow]) {
let json: Vec<JsonRow<'_>> = rows
.iter()
.map(|r| JsonRow {
field: &r.field,
value: &r.value,
source: r.origin.label(),
})
.collect();
match serde_json::to_string_pretty(&json) {
Ok(text) => println!("{text}"),
Err(error) => eprintln!("failed to serialize effective config: {error}"),
}
}
#[cfg(test)]
mod tests {
use std::{io::Write as _, path::Path};
use tempfile::TempDir;
use super::{
EffectiveRow, MAX_VALUE_WIDTH, Origin, classify, elide, list, opt, print_json, print_table,
resolve_effective, toml_keys,
};
use crate::{cli::Cli, config::Config};
struct EnvGuard {
key: &'static str,
original: Option<String>,
}
impl EnvGuard {
#[allow(unsafe_code)]
fn new(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var(key).ok();
match value {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
Self { key, original }
}
}
#[allow(unsafe_code)]
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.original {
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
fn write_toml(contents: &str) -> (TempDir, std::path::PathBuf) {
let dir = TempDir::new().expect("temp dir creation");
let path = dir.path().join("config.toml");
let mut file = std::fs::File::create(&path).expect("create config.toml");
file.write_all(contents.as_bytes()).expect("write config");
(dir, path)
}
fn row<'a>(rows: &'a [EffectiveRow], field: &str) -> &'a EffectiveRow {
rows.iter()
.find(|r| r.field == field)
.unwrap_or_else(|| panic!("row {field} not found"))
}
#[test]
fn classify_precedence_is_env_cli_file() {
assert_eq!(classify(true, true, true), Origin::Environment);
assert_eq!(classify(true, false, false), Origin::Environment);
assert_eq!(classify(false, true, true), Origin::CommandLine);
assert_eq!(classify(false, false, true), Origin::ConfigFile);
assert_eq!(classify(false, false, false), Origin::Default);
}
#[test]
fn origin_labels_and_colors() {
for (origin, label) in [
(Origin::CommandLine, "command line"),
(Origin::Environment, "environment"),
(Origin::ConfigFile, "config file"),
(Origin::Default, "default"),
] {
assert_eq!(origin.label(), label);
assert!(origin.colored().contains(label));
}
}
#[test]
fn toml_keys_missing_file_is_empty() {
let keys = toml_keys(Path::new("/nonexistent/moshpit-test-config.toml"));
assert!(keys.is_empty());
}
#[test]
fn toml_keys_reads_top_level_and_nested() {
let (_dir, path) =
write_toml("server_port = 1\n\n[preferred_algorithms]\nkex = [\"x25519-sha256\"]\n");
let keys = toml_keys(&path);
assert!(keys.contains("server_port"));
assert!(keys.contains("preferred_algorithms"));
assert!(keys.contains("preferred_algorithms.kex"));
}
#[test]
fn toml_keys_unparseable_is_empty() {
let (_dir, path) = write_toml("not = = toml");
assert!(toml_keys(&path).is_empty());
}
#[test]
fn resolve_lists_core_fields() -> anyhow::Result<()> {
let cli = Cli::parse_argv(["moshpit", "host"])?;
let config = Config::default();
let rows = resolve_effective(&cli, &config, Path::new("/nonexistent/cfg.toml"));
let fields: Vec<&str> = rows.iter().map(|r| r.field.as_str()).collect();
assert!(fields.contains(&"server_port"));
assert!(fields.contains(&"preferred_algorithms.kex"));
assert!(fields.contains(&"tracing"));
assert!(fields.contains(&"config_path"));
Ok(())
}
#[test]
fn resolve_marks_cli_provenance() -> anyhow::Result<()> {
let cli = Cli::parse_argv(["moshpit", "-s", "1234", "host"])?;
let config = Config::default();
let rows = resolve_effective(&cli, &config, Path::new("/nonexistent/cfg.toml"));
assert_eq!(row(&rows, "server_port").origin, Origin::CommandLine);
assert_eq!(row(&rows, "predict").origin, Origin::Default);
Ok(())
}
#[test]
fn resolve_marks_file_provenance() -> anyhow::Result<()> {
let (_dir, path) =
write_toml("server_port = 4242\n\n[preferred_algorithms]\nkex = [\"x25519-sha256\"]\n");
let cli = Cli::parse_argv(["moshpit", "host"])?;
let config = Config::default();
let rows = resolve_effective(&cli, &config, &path);
assert_eq!(row(&rows, "server_port").origin, Origin::ConfigFile);
assert_eq!(
row(&rows, "preferred_algorithms.kex").origin,
Origin::ConfigFile
);
Ok(())
}
#[test]
fn resolve_marks_env_provenance() -> anyhow::Result<()> {
let _guard = EnvGuard::new("MOSHPIT_NAT_WARMUP_COUNT", Some("9"));
let cli = Cli::parse_argv(["moshpit", "host"])?;
let config = Config::default();
let rows = resolve_effective(&cli, &config, Path::new("/nonexistent/cfg.toml"));
assert_eq!(row(&rows, "nat_warmup_count").origin, Origin::Environment);
Ok(())
}
#[test]
fn resolve_server_destination_unset_vs_set() -> anyhow::Result<()> {
let cli = Cli::parse_argv(["moshpit", "ec"])?;
let config = Config::default();
let rows = resolve_effective(&cli, &config, Path::new("/nonexistent/cfg.toml"));
assert_eq!(row(&rows, "server_destination").value, "<unset>");
let config: Config = toml::from_str("server_destination = \"user@host\"")?;
let rows = resolve_effective(&cli, &config, Path::new("/nonexistent/cfg.toml"));
assert_eq!(row(&rows, "server_destination").value, "user@host");
Ok(())
}
#[test]
fn print_table_and_json_smoke() -> anyhow::Result<()> {
let cli = Cli::parse_argv(["moshpit", "host"])?;
let config = Config::default();
let rows = resolve_effective(&cli, &config, Path::new("/nonexistent/cfg.toml"));
print_table(&rows);
print_json(&rows);
Ok(())
}
#[test]
fn elide_long_value() {
let short = "abc";
assert_eq!(elide(short), short);
let long = "x".repeat(MAX_VALUE_WIDTH + 50);
let elided = elide(&long);
assert!(elided.ends_with('…'));
assert_eq!(elided.chars().count(), MAX_VALUE_WIDTH);
}
#[test]
fn opt_and_list_helpers() {
assert_eq!(opt(None), "<unset>");
assert_eq!(opt(Some("x")), "x");
assert_eq!(list(&[]), "<empty>");
assert_eq!(list(&["a".to_string(), "b".to_string()]), "a, b");
}
}