feat: publish-ready overhaul — lints, tests, docs, CI, fuzzing, mutant testing

- Enable clippy pedantic + nursery lints; fix all warnings
- Add config validation (SF, CR, frequency range checks)
- Zeroize passphrase intermediates after key derivation
- Convert to lib+bin crate for fuzz target accessibility
- Replace Vec with VecDeque for O(1) log/bucket rotation
- Add TUI minimum terminal size guard

Testing:
- 145 unit + property tests (was 15)
- 6 proptest groups (frame roundtrip, hash, SNR, air time, peer list, padding)
- 2 cargo-fuzz targets (gossip frame decode, peer list decode)
- cargo-mutants: 0 missed, 241 caught, 54 unviable

Documentation:
- README with prerequisites, quick start, config reference, architecture
- Module-level and item-level rustdoc on all public items
- LICENSE (MIT), rustfmt.toml, .gitignore

Infrastructure:
- GitHub Actions CI (fmt, clippy, test, audit)
- cargo-mutants config (.cargo/mutants.toml)
- justfile for common tasks
This commit is contained in:
swaits committed 2026-04-06 11:39:43 -06:00
1 parent b3bd92c5f8
commit 5dbec82aa9
24 files changed
+3048 -563

No files matched your search

+41 -24
View File
@@ -17,10 +17,8 @@ const RESET: &str = "\x1b[0m";
/// Run the interactive setup wizard. If `existing` is Some, use those values as defaults.
pub fn run_wizard(config_path: &Path, existing: Option<&Config>) -> Result<()> {
let defaults = existing.map_or_else(
|| (RadioSection::default(), BridgeSection::default()),
|c| (c.radio.clone(), c.bridge.clone()),
);
let defaults = existing
.map_or_else(|| (RadioSection::default(), BridgeSection::default()), |c| (c.radio.clone(), c.bridge.clone()));
let (radio, bridge) = defaults;
println!();
@@ -43,8 +41,9 @@ pub fn run_wizard(config_path: &Path, existing: Option<&Config>) -> Result<()> {
// ── Radio ────────────────────────────────────────────────────
let freq_default = format!("{:.3}", radio.frequency as f64 / 1_000_000.0);
let freq_default = format!("{:.3}", f64::from(radio.frequency) / 1_000_000.0);
let freq_mhz: f64 = prompt_parse("Frequency (MHz)", &freq_default)?;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // validated by prompt_parse
let frequency = (freq_mhz * 1_000_000.0) as u32;
let bandwidth = prompt_default("Bandwidth", &radio.bandwidth)?;
@@ -70,11 +69,7 @@ pub fn run_wizard(config_path: &Path, existing: Option<&Config>) -> Result<()> {
let port_default = radio.port.as_deref().unwrap_or("auto");
let port_str = prompt_default("Serial Port", port_default)?;
let port = if port_str == "auto" || port_str.is_empty() {
None
} else {
Some(port_str)
};
let port = if port_str == "auto" || port_str.is_empty() { None } else { Some(port_str) };
// ── Build and save ───────────────────────────────────────────
@@ -111,12 +106,10 @@ pub fn run_wizard(config_path: &Path, existing: Option<&Config>) -> Result<()> {
fn write_config(path: &Path, cfg: &Config) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating directory: {}", parent.display()))?;
std::fs::create_dir_all(parent).with_context(|| format!("creating directory: {}", parent.display()))?;
}
let content = toml::to_string_pretty(cfg).context("serializing config")?;
std::fs::write(path, content)
.with_context(|| format!("writing config: {}", path.display()))?;
std::fs::write(path, content).with_context(|| format!("writing config: {}", path.display()))?;
Ok(())
}
@@ -128,20 +121,12 @@ fn prompt_default(label: &str, default: &str) -> Result<String> {
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if trimmed.is_empty() {
Ok(default.to_string())
} else {
Ok(trimmed.to_string())
}
if trimmed.is_empty() { Ok(default.to_string()) } else { Ok(trimmed.to_string()) }
}
fn prompt_required(label: &str, default: Option<&str>) -> Result<String> {
loop {
let prompt = if let Some(d) = default {
format!(" {label} {DIM}[{d}]{RESET}: ")
} else {
format!(" {label}: ")
};
let prompt = default.map_or_else(|| format!(" {label}: "), |d| format!(" {label} {DIM}[{d}]{RESET}: "));
print!("{prompt}");
io::stdout().flush()?;
let mut input = String::new();
@@ -175,3 +160,35 @@ fn parse_sync_word(s: &str) -> Result<u16> {
let s = s.strip_prefix("0x").unwrap_or(&s);
u16::from_str_radix(s, 16).with_context(|| format!("invalid sync word: {s}"))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn parse_sync_word_hex_prefix() {
assert_eq!(parse_sync_word("0x3444").unwrap(), 0x3444);
}
#[test]
fn parse_sync_word_no_prefix() {
assert_eq!(parse_sync_word("3444").unwrap(), 0x3444);
}
#[test]
fn parse_sync_word_case_insensitive() {
assert_eq!(parse_sync_word("0xABCD").unwrap(), 0xABCD);
}
#[test]
fn parse_sync_word_whitespace() {
assert_eq!(parse_sync_word(" 0x3444 ").unwrap(), 0x3444);
}
#[test]
fn parse_sync_word_invalid() {
assert!(parse_sync_word("ZZZZ").is_err());
assert!(parse_sync_word("").is_err());
}
}