Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package nbping for openSUSE:Factory checked in at 2026-06-29 17:32:29 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/nbping (Old) and /work/SRC/openSUSE:Factory/.nbping.new.11887 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "nbping" Mon Jun 29 17:32:29 2026 rev:4 rq:1362404 version:0.7.1 Changes: -------- --- /work/SRC/openSUSE:Factory/nbping/nbping.changes 2026-05-21 18:33:57.946916622 +0200 +++ /work/SRC/openSUSE:Factory/.nbping.new.11887/nbping.changes 2026-06-29 17:34:11.745921840 +0200 @@ -0,0 +1,7 @@ +------------------------------------------------------------------- +Mon Jun 29 09:29:09 UTC 2026 - Martin Hauke <[email protected]> + +- Update to version 0.7.1: + * chore(release): bump version to 0.7.1 + * feat(config): add YAML config support (#115) + Old: ---- Nping-0.7.0.obscpio New: ---- Nping-0.7.1.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ nbping.spec ++++++ --- /var/tmp/diff_new_pack.Vm0DdM/_old 2026-06-29 17:34:12.785957422 +0200 +++ /var/tmp/diff_new_pack.Vm0DdM/_new 2026-06-29 17:34:12.785957422 +0200 @@ -17,7 +17,7 @@ Name: nbping -Version: 0.7.0 +Version: 0.7.1 Release: 0 Summary: A ping tool with real-time data and visualizations License: MIT ++++++ Nping-0.7.0.obscpio -> Nping-0.7.1.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/Cargo.lock new/Nping-0.7.1/Cargo.lock --- old/Nping-0.7.0/Cargo.lock 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/Cargo.lock 2026-06-28 14:29:41.000000000 +0200 @@ -606,7 +606,7 @@ [[package]] name = "nbping" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "clap", @@ -616,6 +616,8 @@ "pinger", "prometheus", "ratatui", + "serde", + "serde_yaml_ng", "tokio", ] @@ -832,6 +834,49 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] name = "signal-hook" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1071,6 +1116,12 @@ checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/Cargo.toml new/Nping-0.7.1/Cargo.toml --- old/Nping-0.7.0/Cargo.toml 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/Cargo.toml 2026-06-28 14:29:41.000000000 +0200 @@ -1,6 +1,6 @@ [package] name = "nbping" -version = "0.7.0" +version = "0.7.1" edition = "2021" license = "MIT License" description = "NBping is a Ping tool developed in Rust. It supports concurrent Ping for multiple addresses, visual chart display, real-time data updates, and other features." @@ -12,6 +12,8 @@ tokio = { version = "1.42.0", features = ["full"] } pinger="2.0.0" anyhow="1.0.89" +serde = { version = "1.0", features = ["derive"] } +serde_yaml_ng = "0.10" prometheus = "0.13" hyper = { version = "1.0", features = ["full"] } hyper-util = { version = "0.1", features = ["tokio", "server", "http1", "http2"] } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/README.md new/Nping-0.7.1/README.md --- old/Nping-0.7.0/README.md 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/README.md 2026-06-28 14:29:41.000000000 +0200 @@ -80,11 +80,12 @@ <TARGET>... target IP address or hostname to ping Options: - -c, --count <COUNT> Number of pings to send [default: 65535] + --config <CONFIG> Path to a YAML config file (CLI flags override its values) + -c, --count <COUNT> Number of pings to send [default: 0 = unlimited] -i, --interval <INTERVAL> Interval in seconds between pings [default: 0] - -6, --force_ipv6 Force using IPv6 + -6, --force_ipv6 Force using IPv6 (config-only field can also enable this) -m, --multiple <MULTIPLE> Specify the maximum number of target addresses, Only works on one target address [default: 0] - -v, --view-type <VIEW_TYPE> View mode graph/table/point/sparkline [default: graph] + -v, --view-type <VIEW_TYPE> Initial view mode: graph/table/point/sparkline (switch at runtime with 1-4 / Tab) [default: graph] -o, --output <OUTPUT> Output file to save ping results -h, --help Print help -V, --version Print version @@ -99,17 +100,60 @@ ./nbping exporter --help Exporter mode for monitoring -Usage: nbping exporter [OPTIONS] <TARGET>... +Usage: nbping exporter [OPTIONS] [TARGET]... Arguments: - <TARGET>... target IP addresses or hostnames to ping + [TARGET]... target IP addresses or hostnames to ping Options: + --config <CONFIG> Path to a YAML config file (CLI flags override its values) -i, --interval <INTERVAL> Interval in seconds between pings [default: 1] -p, --port <PORT> Prometheus metrics HTTP port [default: 9090] + -6, --force_ipv6 Force using IPv6 (config-only field can also enable this) -h, --help Print help ``` +### Configuration file + +Instead of passing everything on the command line, you can start NBping from a +YAML file with `--config`: + +```bash +nbping --config nbping.yaml +``` + +The file mirrors the command-line flags. See [`nbping.example.yaml`](nbping.example.yaml): + +```yaml +mode: tui # tui | exporter (default: tui) +targets: + - google.com + - github.com + - apple.com + - baidu.com + - 1.1.1.1 +count: 0 # 0 = unlimited +interval: 1 # seconds +force_ipv6: false +multiple: 0 # tui mode only +view_type: graph # graph | table | point | sparkline (tui mode only) +# output: results.log # tui mode only +port: 9090 # exporter mode only +``` + +Notes: + +- **Precedence:** command-line flags override the config file, which overrides + built-in defaults (`CLI flag > YAML config > default`). For example, + `nbping --config nbping.yaml -i 1` forces a 1-second interval regardless of the + file. +- **Mode:** the `mode` field selects TUI or exporter mode when no subcommand is + given. Running the explicit `nbping exporter ...` subcommand always uses + exporter mode. +- **`force_ipv6`:** the `-6` flag can only turn IPv6 *on*; to disable IPv6 while a + config enables it, set `force_ipv6: false` in the file. +- Unknown keys are rejected, so typos surface as errors at startup. + ## Acknowledgements Thanks to these people for their feedback and suggestions for 🏎NBping! @@ -123,4 +167,4 @@ | [X:@geekbb](https://x.com/geekbb/status/1875754541905539510) | [公众号:一飞开源](https://mp.weixin.qq.com/s/BZjr54h8dIQgzr8UW3fwOQ) | [公众号: 开源日记](https://mp.weixin.qq.com/s/uGtkD4x_XOFyKNbIy5pHYA) ## Star History -[](https://star-history.com/#hanshuaikang/Nping&Date) \ No newline at end of file +[](https://star-history.com/#hanshuaikang/Nping&Date) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/README_ZH.md new/Nping-0.7.1/README_ZH.md --- old/Nping-0.7.0/README_ZH.md 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/README_ZH.md 2026-06-28 14:29:41.000000000 +0200 @@ -77,11 +77,12 @@ <TARGET>... target IP address or hostname to ping Options: - -c, --count <COUNT> Number of pings to send [default: 65535] + --config <CONFIG> Path to a YAML config file (CLI flags override its values) + -c, --count <COUNT> Number of pings to send [default: 0 = unlimited] -i, --interval <INTERVAL> Interval in seconds between pings [default: 0] - -6, --force_ipv6 Force using IPv6 + -6, --force_ipv6 Force using IPv6 (config-only field can also enable this) -m, --multiple <MULTIPLE> Specify the maximum number of target addresses, Only works on one target address [default: 0] - -v, --view-type <VIEW_TYPE> View mode graph/table/point/sparkline [default: graph] + -v, --view-type <VIEW_TYPE> Initial view mode: graph/table/point/sparkline (switch at runtime with 1-4 / Tab) [default: graph] -o, --output <OUTPUT> Output file to save ping results -h, --help Print help -V, --version Print version @@ -96,17 +97,56 @@ ./nbping exporter --help Exporter mode for monitoring -Usage: nbping exporter [OPTIONS] <TARGET>... +Usage: nbping exporter [OPTIONS] [TARGET]... Arguments: - <TARGET>... target IP addresses or hostnames to ping + [TARGET]... target IP addresses or hostnames to ping Options: + --config <CONFIG> Path to a YAML config file (CLI flags override its values) -i, --interval <INTERVAL> Interval in seconds between pings [default: 1] -p, --port <PORT> Prometheus metrics HTTP port [default: 9090] + -6, --force_ipv6 Force using IPv6 (config-only field can also enable this) -h, --help Print help ``` +### 配置文件 + +除了命令行参数,你也可以通过 `--config` 从 YAML 文件启动 NBping: + +```bash +nbping --config nbping.yaml +``` + +配置文件的字段与命令行参数一一对应,完整示例见 [`nbping.example.yaml`](nbping.example.yaml): + +```yaml +mode: tui # tui | exporter(默认 tui) +targets: + - google.com + - github.com + - apple.com + - baidu.com + - 1.1.1.1 +count: 0 # 0 = 不限次数 +interval: 1 # 间隔秒数 +force_ipv6: false +multiple: 0 # 仅 tui 模式 +view_type: graph # graph | table | point | sparkline(仅 tui 模式) +# output: results.log # 仅 tui 模式 +port: 9090 # 仅 exporter 模式 +``` + +说明: + +- **优先级:** 命令行参数 > YAML 配置 > 内置默认值。例如 + `nbping --config nbping.yaml -i 1` 会强制使用 1 秒间隔,无视配置文件中的值。 +- **模式:** 未使用子命令时,由 `mode` 字段决定走 TUI 还是 exporter 模式;显式执行 + `nbping exporter ...` 子命令则始终为 exporter 模式。 +- **`force_ipv6`:** `-6` 命令行 flag 只能开启 IPv6;若配置文件已开启而想关闭,请在文件中设置 + `force_ipv6: false`。 +- 未知字段会被拒绝,因此拼写错误会在启动时直接报错。 + ## 致谢 感谢这些朋友对 NBping 提出的反馈和建议。 @@ -119,4 +159,4 @@ | [X:@geekbb](https://x.com/geekbb/status/1875754541905539510) | [公众号:一飞开源](https://mp.weixin.qq.com/s/BZjr54h8dIQgzr8UW3fwOQ) | [公众号: 开源日记](https://mp.weixin.qq.com/s/uGtkD4x_XOFyKNbIy5pHYA) ## Star History -[](https://star-history.com/#hanshuaikang/Nping&Date) \ No newline at end of file +[](https://star-history.com/#hanshuaikang/Nping&Date) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/nbping.example.yaml new/Nping-0.7.1/nbping.example.yaml --- old/Nping-0.7.0/nbping.example.yaml 1970-01-01 01:00:00.000000000 +0100 +++ new/Nping-0.7.1/nbping.example.yaml 2026-06-28 14:29:41.000000000 +0200 @@ -0,0 +1,47 @@ +# NBping configuration file example. +# +# Usage: nbping --config nbping.yaml +# +# Precedence: command-line flags override values set here, which in turn +# override the built-in defaults -> CLI flag > YAML config > default. +# Unknown keys are rejected, so a typo'd field name is a hard error. + +# Execution mode: "tui" (interactive charts, default) or "exporter" (Prometheus). +mode: tui + +# Targets to ping (IP addresses or hostnames). Required (here or on the CLI). +targets: + - google.com + - github.com + - cloudflare.com + - apple.com + - x.com + - baidu.com + - qq.com + - 163.com + +# Number of pings to send. 0 = unlimited. (default: 0) +count: 0 + +# Interval between pings, in seconds. +# tui mode: default 0, where 0 means 500ms. +# exporter mode: default 1; a value <= 0 is treated as 1 (no busy-loop). +interval: 1 + +# Force using IPv6. Note: the -6 CLI flag can only turn this ON; to keep it off +# while a config enables it, set this to false here. (default: false) +force_ipv6: false + +# tui mode only: with a single target, resolve up to this many A/AAAA records +# and ping them all in parallel. (default: 0 = disabled) +multiple: 0 + +# tui mode only: initial view. One of graph | table | point | sparkline. +# Switch at runtime with keys 1-4 / Tab. (default: graph) +view_type: graph + +# tui mode only: file to save ping results to. Must not already exist. +# output: results.log + +# exporter mode only: Prometheus metrics HTTP port. (default: 9090) +port: 9090 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/src/config.rs new/Nping-0.7.1/src/config.rs --- old/Nping-0.7.0/src/config.rs 1970-01-01 01:00:00.000000000 +0100 +++ new/Nping-0.7.1/src/config.rs 2026-06-28 14:29:41.000000000 +0200 @@ -0,0 +1,414 @@ +//! YAML configuration file support. +//! +//! `nbping` can be started either from command-line flags or from a YAML file +//! passed via `--config`. The fields here mirror the CLI flags one-to-one +//! (a flat schema). Every field is optional so that command-line arguments can +//! selectively override the file: the resolution order is +//! `CLI explicit flag > YAML config > built-in default`. +//! +//! See `nbping.example.yaml` in the repository root for a documented sample. + +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; + +use crate::view::View; + +/// A configuration file deserialized from YAML. +/// +/// All fields are `Option` so an absent field falls through to the next layer +/// (CLI default or built-in default) instead of clobbering it. `deny_unknown_fields` +/// turns typos into hard errors rather than silently ignored keys. +#[derive(Debug, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct FileConfig { + /// Execution mode: `tui` (default) or `exporter`. + pub mode: Option<String>, + /// Target IP addresses or hostnames to ping. + pub targets: Option<Vec<String>>, + /// Number of pings to send (0 = unlimited). + pub count: Option<usize>, + /// Interval between pings, in seconds. + pub interval: Option<i32>, + /// Force using IPv6. + pub force_ipv6: Option<bool>, + /// Resolve multiple A/AAAA records for a single target (tui mode only). + pub multiple: Option<i32>, + /// Initial view: graph/table/point/sparkline (tui mode only). + pub view_type: Option<String>, + /// File to save ping results to (tui mode only). + pub output: Option<String>, + /// Prometheus metrics HTTP port (exporter mode only). + pub port: Option<u16>, +} + +/// Execution mode selected by the config file's `mode` field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Tui, + Exporter, +} + +impl FileConfig { + /// Read and parse a YAML config file, then validate its contents. + /// + /// Returns a friendly error (with the file path) when the file is missing, + /// malformed, or contains invalid values. + pub fn load(path: &str) -> Result<Self> { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("failed to read config file: {}", path))?; + let config: FileConfig = serde_yaml_ng::from_str(&contents) + .with_context(|| format!("failed to parse config file: {}", path))?; + config.validate()?; + Ok(config) + } + + /// Validate enum-like string fields and numeric ranges up front so errors + /// surface at startup rather than deep inside the run path. + fn validate(&self) -> Result<()> { + if let Some(mode) = &self.mode { + self.parsed_mode_inner(mode)?; + } + if let Some(view) = &self.view_type { + if View::from_str(view).is_none() { + return Err(anyhow!( + "invalid view_type '{}' in config (expected one of: graph, table, point, sparkline)", + view + )); + } + } + // Negative durations/counts are nonsensical and would produce negative + // millisecond sleeps downstream. (`count` is a `usize`, so serde already + // rejects negatives for it.) + if let Some(i) = self.interval { + if i < 0 { + return Err(anyhow!("interval must be >= 0, got {}", i)); + } + if i > 86400 { + return Err(anyhow!("interval must be <= 86400 (24 h), got {}", i)); + } + } + if let Some(m) = self.multiple { + if m < 0 { + return Err(anyhow!("multiple must be >= 0, got {}", m)); + } + } + Ok(()) + } + + /// The execution mode declared by the file, defaulting to `Tui` when absent. + pub fn mode(&self) -> Result<Mode> { + match &self.mode { + Some(m) => self.parsed_mode_inner(m), + None => Ok(Mode::Tui), + } + } + + fn parsed_mode_inner(&self, mode: &str) -> Result<Mode> { + match mode { + "tui" => Ok(Mode::Tui), + "exporter" => Ok(Mode::Exporter), + other => Err(anyhow!( + "invalid mode '{}' in config (expected 'tui' or 'exporter')", + other + )), + } + } +} + +/// Fully-resolved settings for the default TUI mode, after merging +/// `CLI > YAML > default`. +#[derive(Debug, PartialEq)] +pub struct ResolvedTui { + pub targets: Vec<String>, + pub count: usize, + pub interval: i32, + pub force_ipv6: bool, + pub multiple: i32, + pub view_type: String, + pub output: Option<String>, +} + +/// Fully-resolved settings for exporter mode, after merging `CLI > YAML > default`. +#[derive(Debug, PartialEq)] +pub struct ResolvedExporter { + pub targets: Vec<String>, + pub interval: i32, + pub port: u16, + pub force_ipv6: bool, +} + +/// Resolve the target list: CLI targets win when present, otherwise fall back to +/// the config file's `targets`. The result is de-duplicated while preserving the +/// original order. +pub fn resolve_targets(cli_targets: Vec<String>, file: &FileConfig) -> Vec<String> { + let raw = if !cli_targets.is_empty() { + cli_targets + } else { + file.targets.clone().unwrap_or_default() + }; + + let mut seen = std::collections::HashSet::new(); + raw.into_iter().filter(|item| seen.insert(item.clone())).collect() +} + +/// Merge CLI flags and config-file values into the final TUI settings. +/// Precedence per field: CLI explicit value > YAML config > built-in default. +pub fn resolve_tui( + cli_targets: Vec<String>, + cli_count: Option<usize>, + cli_interval: Option<i32>, + cli_force_ipv6: bool, + cli_multiple: Option<i32>, + cli_view_type: Option<String>, + cli_output: Option<String>, + file: &FileConfig, +) -> ResolvedTui { + ResolvedTui { + targets: resolve_targets(cli_targets, file), + count: cli_count.or(file.count).unwrap_or(0), + // 0 is meaningful in TUI mode (run_app treats it as 500ms). + interval: cli_interval.or(file.interval).unwrap_or(0), + // A bool flag can only be turned ON from the CLI; the config can also enable it. + force_ipv6: cli_force_ipv6 || file.force_ipv6.unwrap_or(false), + multiple: cli_multiple.or(file.multiple).unwrap_or(0), + view_type: cli_view_type + .or_else(|| file.view_type.clone()) + .unwrap_or_else(|| "graph".to_string()), + output: cli_output.or_else(|| file.output.clone()), + } +} + +/// Merge CLI flags and config-file values into the final exporter settings. +/// Exporter mode is reachable two ways, so values from the `exporter` subcommand +/// take precedence over top-level flags, then the config file, then defaults. +pub fn resolve_exporter( + sub_targets: Vec<String>, + sub_interval: Option<i32>, + sub_port: Option<u16>, + top_targets: Vec<String>, + top_interval: Option<i32>, + cli_force_ipv6: bool, + file: &FileConfig, +) -> ResolvedExporter { + let cli_targets = if !sub_targets.is_empty() { + sub_targets + } else { + top_targets + }; + let interval = sub_interval.or(top_interval).or(file.interval).unwrap_or(1); + // Unlike TUI mode, exporter mode has no "0 == 500ms" semantics; a zero or + // negative interval would busy-loop, so fall back to 1 second. + let interval = if interval <= 0 { 1 } else { interval }; + ResolvedExporter { + targets: resolve_targets(cli_targets, file), + interval, + port: sub_port.or(file.port).unwrap_or(9090), + force_ipv6: cli_force_ipv6 || file.force_ipv6.unwrap_or(false), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_full_config() { + let yaml = r#" +mode: exporter +targets: + - google.com + - 1.1.1.1 +count: 5 +interval: 2 +force_ipv6: true +multiple: 3 +view_type: table +output: out.log +port: 9100 +"#; + let cfg: FileConfig = serde_yaml_ng::from_str(yaml).unwrap(); + assert_eq!(cfg.mode.as_deref(), Some("exporter")); + assert_eq!( + cfg.targets, + Some(vec!["google.com".to_string(), "1.1.1.1".to_string()]) + ); + assert_eq!(cfg.count, Some(5)); + assert_eq!(cfg.interval, Some(2)); + assert_eq!(cfg.force_ipv6, Some(true)); + assert_eq!(cfg.multiple, Some(3)); + assert_eq!(cfg.view_type.as_deref(), Some("table")); + assert_eq!(cfg.output.as_deref(), Some("out.log")); + assert_eq!(cfg.port, Some(9100)); + assert_eq!(cfg.mode().unwrap(), Mode::Exporter); + } + + #[test] + fn empty_config_defaults_to_tui() { + let cfg: FileConfig = serde_yaml_ng::from_str("targets: [a.com]").unwrap(); + assert_eq!(cfg.mode().unwrap(), Mode::Tui); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn rejects_unknown_fields() { + // A typo'd key must be a hard error, not silently dropped. + let err = serde_yaml_ng::from_str::<FileConfig>("intervall: 5").unwrap_err(); + assert!(err.to_string().contains("unknown field"), "{}", err); + } + + #[test] + fn rejects_invalid_mode() { + let cfg: FileConfig = serde_yaml_ng::from_str("mode: bogus").unwrap(); + assert!(cfg.validate().is_err()); + assert!(cfg.mode().is_err()); + } + + #[test] + fn rejects_invalid_view_type() { + let cfg: FileConfig = serde_yaml_ng::from_str("view_type: pie").unwrap(); + assert!(cfg.validate().is_err()); + } + + #[test] + fn valid_view_types_accepted() { + for v in ["graph", "table", "point", "sparkline"] { + let cfg: FileConfig = + serde_yaml_ng::from_str(&format!("view_type: {}", v)).unwrap(); + assert!(cfg.validate().is_ok(), "{} should be valid", v); + } + } + + #[test] + fn rejects_negative_numbers() { + let cfg: FileConfig = serde_yaml_ng::from_str("interval: -5").unwrap(); + assert!(cfg.validate().is_err()); + let cfg: FileConfig = serde_yaml_ng::from_str("multiple: -1").unwrap(); + assert!(cfg.validate().is_err()); + // serde rejects a negative `count` (usize) before validate() even runs. + assert!(serde_yaml_ng::from_str::<FileConfig>("count: -1").is_err()); + } + + #[test] + fn rejects_interval_over_86400() { + // Values this large would overflow i32 after *1000 and produce an + // enormous sleep duration in the ping worker. + let cfg: FileConfig = serde_yaml_ng::from_str("interval: 86401").unwrap(); + assert!(cfg.validate().is_err()); + // Edge: exactly 86400 is the allowed maximum. + let cfg: FileConfig = serde_yaml_ng::from_str("interval: 86400").unwrap(); + assert!(cfg.validate().is_ok()); + } + + fn file(yaml: &str) -> FileConfig { + serde_yaml_ng::from_str(yaml).unwrap() + } + + // ---- TUI merge precedence ---- + + #[test] + fn tui_cli_overrides_file() { + let f = file("interval: 9\ncount: 100\nview_type: table"); + let r = resolve_tui( + vec!["a.com".into()], + Some(5), // cli count + Some(2), // cli interval + false, + None, + Some("point".into()), // cli view_type + None, + &f, + ); + assert_eq!(r.count, 5); + assert_eq!(r.interval, 2); + assert_eq!(r.view_type, "point"); + assert_eq!(r.targets, vec!["a.com".to_string()]); + } + + #[test] + fn tui_falls_back_to_file_then_default() { + let f = file("interval: 9\nview_type: sparkline"); + let r = resolve_tui(vec!["a.com".into()], None, None, false, None, None, None, &f); + assert_eq!(r.interval, 9); // from file + assert_eq!(r.view_type, "sparkline"); // from file + assert_eq!(r.count, 0); // default + + let empty = FileConfig::default(); + let r = resolve_tui(vec!["a.com".into()], None, None, false, None, None, None, &empty); + assert_eq!(r.interval, 0); // tui default + assert_eq!(r.view_type, "graph"); // default + assert_eq!(r.multiple, 0); + } + + #[test] + fn tui_force_ipv6_cli_or_file() { + let on = file("force_ipv6: true"); + let off = FileConfig::default(); + // CLI flag enables it. + assert!(resolve_tui(vec!["a".into()], None, None, true, None, None, None, &off).force_ipv6); + // File enables it even without the CLI flag. + assert!(resolve_tui(vec!["a".into()], None, None, false, None, None, None, &on).force_ipv6); + // Neither set -> off. + assert!(!resolve_tui(vec!["a".into()], None, None, false, None, None, None, &off).force_ipv6); + } + + // ---- targets resolution ---- + + #[test] + fn cli_targets_win_over_file() { + let f = file("targets: [x.com, y.com]"); + let r = resolve_targets(vec!["a.com".into(), "a.com".into(), "b.com".into()], &f); + // CLI wins, and is de-duplicated while preserving order. + assert_eq!(r, vec!["a.com".to_string(), "b.com".to_string()]); + } + + #[test] + fn empty_cli_targets_fall_back_to_file() { + let f = file("targets: [x.com, y.com, x.com]"); + let r = resolve_targets(vec![], &f); + assert_eq!(r, vec!["x.com".to_string(), "y.com".to_string()]); + } + + // ---- exporter merge precedence (two entry paths) ---- + + #[test] + fn exporter_defaults_match_legacy() { + // No CLI, no file -> exporter interval defaults to 1, port to 9090. + let r = resolve_exporter(vec!["a".into()], None, None, vec![], None, false, &FileConfig::default()); + assert_eq!(r.interval, 1); + assert_eq!(r.port, 9090); + } + + #[test] + fn exporter_subcommand_beats_toplevel_and_file() { + let f = file("interval: 9\nport: 8000"); + // subcommand -i 2, top-level -i 3, file 9 -> subcommand wins. + let r = resolve_exporter(vec!["a".into()], Some(2), Some(9100), vec![], Some(3), false, &f); + assert_eq!(r.interval, 2); + assert_eq!(r.port, 9100); + } + + #[test] + fn exporter_toplevel_interval_used_when_no_subcommand() { + // mode:exporter via config, user passes top-level -i 3; subcommand absent. + let f = file("interval: 9"); + let r = resolve_exporter(vec![], None, None, vec!["a".into()], Some(3), false, &f); + assert_eq!(r.interval, 3); + assert_eq!(r.targets, vec!["a".to_string()]); + } + + #[test] + fn exporter_zero_interval_is_guarded() { + // A 0 interval (no "500ms" meaning in exporter mode) must not busy-loop. + let f = file("interval: 0"); + let r = resolve_exporter(vec!["a".into()], None, None, vec![], None, false, &f); + assert_eq!(r.interval, 1); + } + + #[test] + fn exporter_force_ipv6_honored() { + let f = file("force_ipv6: true"); + // Now actually plumbed through (regression guard for the B1 fix). + assert!(resolve_exporter(vec!["a".into()], None, None, vec![], None, false, &f).force_ipv6); + assert!(resolve_exporter(vec!["a".into()], None, None, vec![], None, true, &FileConfig::default()).force_ipv6); + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/src/draw.rs new/Nping-0.7.1/src/draw.rs --- old/Nping-0.7.0/src/draw.rs 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/src/draw.rs 2026-06-28 14:29:41.000000000 +0200 @@ -90,8 +90,10 @@ output_file: Option<String>, ) -> Result<(), Box<dyn Error>> { let mut output_file_handle = if let Some(ref output_path) = output_file { + // create_new atomically fails if the file already exists, eliminating + // the TOCTOU window between the exists() check in main() and the open. match std::fs::OpenOptions::new() - .create(true) + .create_new(true) .write(true) .open(output_path) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/src/main.rs new/Nping-0.7.1/src/main.rs --- old/Nping-0.7.0/src/main.rs 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/src/main.rs 2026-06-28 14:29:41.000000000 +0200 @@ -7,9 +7,10 @@ mod data_processor; mod exporter; mod view; +mod config; use clap::{Parser, Subcommand}; -use std::collections::{HashSet, VecDeque}; +use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -23,6 +24,7 @@ use crate::network::send_ping; use crate::exporter::{PrometheusMetrics, http_server, spawn_ping_workers}; use crate::view::View; +use crate::config::{FileConfig, Mode}; struct RawModeGuard; @@ -41,7 +43,7 @@ #[derive(Parser, Debug)] #[command( - version = "v0.7.0", + version = "v0.7.1", author = "hanshuaikang<https://github.com/hanshuaikang>", about = "🏎 NBping mean NB Ping, A Ping Tool in Rust with Real-Time Data and Visualizations" )] @@ -50,27 +52,30 @@ #[arg(help = "target IP address or hostname to ping", required = false)] target: Vec<String>, + /// Path to a YAML config file (CLI flags override values from this file) + #[arg(long, global = true, help = "Path to a YAML config file (CLI flags override its values)")] + config: Option<String>, + /// Number of pings to send, when count is 0, the maximum number of pings per address is calculated - #[arg(short, long, default_value_t = 0, help = "Number of pings to send")] - count: usize, + #[arg(short, long, help = "Number of pings to send [default: 0 = unlimited]")] + count: Option<usize>, /// Interval in seconds between pings - #[arg(short, long, default_value_t = 0, help = "Interval in seconds between pings")] - interval: i32, + #[arg(short, long, help = "Interval in seconds between pings [default: 0]")] + interval: Option<i32>, - #[clap(long = "force_ipv6", default_value_t = false, short = '6', help = "Force using IPv6")] + #[clap(long = "force_ipv6", default_value_t = false, short = '6', global = true, help = "Force using IPv6 (config-only field can also enable this)")] pub force_ipv6: bool, #[arg( short = 'm', long, - default_value_t = 0, - help = "Specify the maximum number of target addresses, Only works on one target address" + help = "Specify the maximum number of target addresses, Only works on one target address [default: 0]" )] - multiple: i32, + multiple: Option<i32>, - #[arg(short, long, default_value = "graph", help = "Initial view mode: graph/table/point/sparkline (switch at runtime with 1-4 / Tab)")] - view_type: String, + #[arg(short, long, help = "Initial view mode: graph/table/point/sparkline (switch at runtime with 1-4 / Tab) [default: graph]")] + view_type: Option<String>, #[arg(short = 'o', long = "output", help = "Output file to save ping results")] output: Option<String>, @@ -84,16 +89,16 @@ /// Exporter mode for monitoring Exporter { /// Target IP addresses or hostnames to ping - #[arg(help = "target IP addresses or hostnames to ping", required = true)] + #[arg(help = "target IP addresses or hostnames to ping", required = false)] target: Vec<String>, /// Interval in seconds between pings - #[arg(short, long, default_value_t = 1, help = "Interval in seconds between pings")] - interval: i32, + #[arg(short, long, help = "Interval in seconds between pings [default: 1]")] + interval: Option<i32>, /// Prometheus metrics HTTP port - #[arg(short, long, default_value_t = 9090, help = "Prometheus metrics HTTP port")] - port: u16, + #[arg(short, long, help = "Prometheus metrics HTTP port [default: 9090]")] + port: Option<u16>, }, } @@ -102,69 +107,117 @@ // parse command line arguments let args = Args::parse(); - match args.command { - Some(Commands::Exporter { target, interval, port }) => { - let worker_threads = (target.len() + 1).max(1); - // Create tokio runtime for Exporter mode - let rt = Builder::new_multi_thread() - .worker_threads(worker_threads) - .enable_all() - .build()?; - - let res = rt.block_on(run_exporter_mode(target, interval, port)); - - // if error print error message and exit - if let Err(err) = res { - eprintln!("{}", err); + // Load the YAML config file when one was provided. Values from it act as a + // baseline that command-line flags override (CLI > YAML > built-in default). + let file_cfg = match &args.config { + Some(path) => match FileConfig::load(path) { + Ok(cfg) => cfg, + Err(err) => { + eprintln!("Error: {:#}", err); std::process::exit(1); } }, - None => { - // Default ping mode - if args.target.is_empty() { - eprintln!("Error: target IP address or hostname is required"); + None => FileConfig::default(), + }; + + // Decide which mode to run: + // - an explicit `exporter` subcommand always wins + // - otherwise the config file's `mode` field decides (defaulting to tui) + let run_exporter = match &args.command { + Some(Commands::Exporter { .. }) => true, + None => match file_cfg.mode() { + Ok(mode) => mode == Mode::Exporter, + Err(err) => { + eprintln!("Error: {:#}", err); std::process::exit(1); } + }, + }; - // set Ctrl+C and q and esc to exit - let running = Arc::new(Mutex::new(true)); + if run_exporter { + // Exporter mode is reachable two ways: the `exporter` subcommand (with its + // own target/interval/port) or top-level flags alongside `--config mode: exporter`. + let (sub_target, sub_interval, sub_port) = match args.command { + Some(Commands::Exporter { target, interval, port }) => (target, interval, port), + None => (Vec::new(), None, None), + }; + let cfg = config::resolve_exporter( + sub_target, + sub_interval, + sub_port, + args.target, + args.interval, + args.force_ipv6, + &file_cfg, + ); + if cfg.targets.is_empty() { + eprintln!("Error: at least one target is required (via CLI or config 'targets')"); + std::process::exit(1); + } - // check output file - if let Some(ref output_path) = args.output { - if std::path::Path::new(output_path).exists() { - eprintln!("Output file already exists: {}", output_path); - std::process::exit(1); - } - } + let worker_threads = (cfg.targets.len() + 1).max(1); + let rt = Builder::new_multi_thread() + .worker_threads(worker_threads) + .enable_all() + .build()?; + + let res = rt.block_on(run_exporter_mode(cfg.targets, cfg.interval, cfg.port, cfg.force_ipv6)); + if let Err(err) = res { + eprintln!("{}", err); + std::process::exit(1); + } + } else { + // Default TUI ping mode. Resolve every value as CLI > YAML > default. + let cfg = config::resolve_tui( + args.target, + args.count, + args.interval, + args.force_ipv6, + args.multiple, + args.view_type, + args.output, + &file_cfg, + ); + if cfg.targets.is_empty() { + eprintln!("Error: at least one target is required (via CLI or config 'targets')"); + std::process::exit(1); + } + // YAML interval is validated in config.rs; guard the CLI path here. + if cfg.interval < 0 { + eprintln!("Error: interval must be >= 0, got {}", cfg.interval); + std::process::exit(1); + } - // after de-duplication, the original order is still preserved - let mut seen = HashSet::new(); - let targets: Vec<String> = args.target.into_iter() - .filter(|item| seen.insert(item.clone())) - .collect(); - - // Calculate worker threads based on IP count - let ip_count = if targets.len() == 1 && args.multiple > 0 { - args.multiple as usize - } else { - targets.len() - }; - let worker_threads = (ip_count + 1).max(1); - - // Create tokio runtime with specific worker thread count - let rt = Builder::new_multi_thread() - .worker_threads(worker_threads) - .enable_all() - .build()?; - - let res = rt.block_on(run_app(targets, args.count, args.interval, running.clone(), args.force_ipv6, args.multiple, args.view_type, args.output)); - - // if error print error message and exit - if let Err(err) = res { - eprintln!("{}", err); + // set Ctrl+C and q and esc to exit + let running = Arc::new(Mutex::new(true)); + + // check output file + if let Some(ref output_path) = cfg.output { + if std::path::Path::new(output_path).exists() { + eprintln!("Output file already exists: {}", output_path); std::process::exit(1); } } + + // Calculate worker threads based on IP count + let ip_count = if cfg.targets.len() == 1 && cfg.multiple > 0 { + cfg.multiple as usize + } else { + cfg.targets.len() + }; + let worker_threads = (ip_count + 1).max(1); + + // Create tokio runtime with specific worker thread count + let rt = Builder::new_multi_thread() + .worker_threads(worker_threads) + .enable_all() + .build()?; + + let res = rt.block_on(run_app(cfg.targets, cfg.count, cfg.interval, running.clone(), cfg.force_ipv6, cfg.multiple, cfg.view_type, cfg.output)); + if let Err(err) = res { + eprintln!("{}", err); + std::process::exit(1); + } } Ok(()) } @@ -239,7 +292,8 @@ let errs = Arc::new(Mutex::new(Vec::new())); - let interval = if interval == 0 { 500 } else { interval * 1000 }; + // saturating_mul prevents i32 overflow for very large interval values. + let interval = if interval == 0 { 500 } else { interval.saturating_mul(1000) }; let mut tasks = Vec::new(); for (i, ip) in ips.iter().enumerate() { @@ -304,6 +358,7 @@ targets: Vec<String>, interval: i32, port: u16, + force_ipv6: bool, ) -> Result<(), Box<dyn std::error::Error>> { // 创建 Prometheus metrics 收集器 let prometheus_metrics = Arc::new(PrometheusMetrics::new()?); @@ -333,12 +388,8 @@ } }); - // 去重目标地址,同时保留原始顺序 - let mut seen = std::collections::HashSet::new(); - let targets: Vec<String> = targets.into_iter() - .filter(|item| seen.insert(item.clone())) - .collect(); - + // Targets are already de-duplicated and non-empty by the time they reach + // here (see config::resolve_exporter), but guard defensively. if targets.is_empty() { return Err("No valid targets provided".into()); } @@ -346,7 +397,7 @@ // 解析目标地址为 IP 地址 let mut target_pairs = Vec::new(); for target in &targets { - let ip = network::get_host_ipaddr(target, false)?; + let ip = network::get_host_ipaddr(target, force_ipv6)?; target_pairs.push((target.clone(), ip)); } @@ -378,7 +429,7 @@ ).await }); - let interval_ms = interval * 1000; + let interval_ms = interval.saturating_mul(1000); let ping_threads = spawn_ping_workers( target_pairs, Duration::from_millis(interval_ms as u64), diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/src/ui/layout.rs new/Nping-0.7.1/src/ui/layout.rs --- old/Nping-0.7.0/src/ui/layout.rs 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/src/ui/layout.rs 2026-06-28 14:29:41.000000000 +0200 @@ -61,11 +61,10 @@ let alive = if d.received > 0 { a + 1 } else { a }; (r + d.received, t + d.timeout, s + avg, alive) }); - let global_avg = if total_targets > 0 { - sum_avg / total_targets as f64 - } else { - 0.0 - }; + // Divide by alive (targets with at least one successful ping), not total_targets. + // Dead/unreachable targets contribute 0.0 to sum_avg, so including them in the + // denominator makes the header average misleadingly low. + let global_avg = if alive > 0 { sum_avg / alive as f64 } else { 0.0 }; let global_loss = calculate_loss_pkg(total_timeout, total_recv); let beat = HEARTBEAT[(ctx.tick as usize) % HEARTBEAT.len()]; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/src/ui/point.rs new/Nping-0.7.1/src/ui/point.rs --- old/Nping-0.7.0/src/ui/point.rs 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/src/ui/point.rs 2026-06-28 14:29:41.000000000 +0200 @@ -16,7 +16,9 @@ theme: &Theme, ) { let ip_height: u16 = 5; - let total_height = (ip_data.len() as u16) * ip_height + 2; + // Use u32 arithmetic to avoid u16 overflow when ip_data.len() > 13106, + // then clamp to u16::MAX before converting. + let total_height = ((ip_data.len() as u32) * ip_height as u32 + 2).min(u16::MAX as u32) as u16; let chunks = Layout::default() .direction(Direction::Vertical) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/src/ui/sparkline.rs new/Nping-0.7.1/src/ui/sparkline.rs --- old/Nping-0.7.0/src/ui/sparkline.rs 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/src/ui/sparkline.rs 2026-06-28 14:29:41.000000000 +0200 @@ -6,7 +6,7 @@ use crate::ip_data::IpData; use crate::ui::theme::Theme; -use crate::ui::utils::{calculate_avg_rtt, calculate_jitter, calculate_loss_pkg, calculate_p95, draw_errors_section}; +use crate::ui::utils::{calculate_avg_rtt, calculate_jitter, calculate_loss_pkg, calculate_p95, draw_errors_section, rtt_to_spark_unit}; pub fn draw_sparkline_view( f: &mut Frame, @@ -16,12 +16,15 @@ theme: &Theme, ) { let n = ip_data.len().max(1); + // 8 rows per cell: 2 for borders + 1 for the info line + 5 for the sparkline. + // 5 sparkline rows = 40 sub-levels, enough to make sub-ms bars (▂▃▄▅) visible. + const CELL_HEIGHT: u16 = 8; let chunks = Layout::default() .direction(Direction::Vertical) .constraints( std::iter::once(Constraint::Length(1)) - .chain(std::iter::repeat_n(Constraint::Length(5), n)) + .chain(std::iter::repeat_n(Constraint::Length(CELL_HEIGHT), n)) .chain([Constraint::Min(6)]) .collect::<Vec<_>>(), ) @@ -113,17 +116,31 @@ let width = spark_rect.width as usize; let rtts_len = ip.rtts.len(); let skip = rtts_len.saturating_sub(width); - let spark_data: Vec<u64> = ip + // Scale RTT to 1/100 ms units so sub-millisecond RTTs (e.g. LAN/localhost + // at 0.3ms) are not truncated to 0 by integer cast. Without this, every + // healthy bar with rtt < 1ms maps to 0 and the sparkline looks empty + // (indistinguishable from 100% packet loss). Timeouts stay at 0 (blank gap). + let raw: Vec<u64> = ip .rtts .iter() .skip(skip) - .map(|&rtt| if rtt < 0.0 { 0 } else { rtt as u64 }) + .map(|&rtt| rtt_to_spark_unit(rtt)) .collect(); + // Right-align: pad the front with zeros so the most recent bar is always + // at the right edge. The blank leading region is visually distinct from + // timeout gaps (timeouts also show as 0/blank) but only appears while the + // history is shorter than the widget width — i.e. early in a session. + let spark_data: Vec<u64> = if raw.len() < width { + let mut padded = vec![0u64; width - raw.len()]; + padded.extend_from_slice(&raw); + padded + } else { + raw + }; - // Cap auto-scale at P95 so a single outlier (e.g. a one-off - // 1200ms spike) doesn't pull every typical RTT down to level 0. - // Values above the cap clip to a full bar, which highlights spikes. - let spark_max = (p95 as u64).max(1); + // Cap auto-scale at P95 (same unit) so a single outlier doesn't crush + // the typical bars. Values above the cap clip to full bar height. + let spark_max = ((p95 * 100.0).round() as u64).max(1); let spark = Sparkline::default() .data(&spark_data) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/src/ui/utils.rs new/Nping-0.7.1/src/ui/utils.rs --- old/Nping-0.7.0/src/ui/utils.rs 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/src/ui/utils.rs 2026-06-28 14:29:41.000000000 +0200 @@ -21,10 +21,13 @@ } pub fn calculate_jitter(rtt: &VecDeque<f64>) -> f64 { - if rtt.len() > 1 { - let diffs: Vec<f64> = rtt.iter().zip(rtt.iter().skip(1)).map(|(y1, y2)| (y2 - y1).abs()).collect(); - let sum: f64 = diffs.iter().sum(); - sum / diffs.len() as f64 + // Filter out -1.0 timeout sentinels before computing adjacent differences. + // Without this, a timeout between two 1ms pings inflates jitter by ~2ms per + // sentinel (|1.0 - (-1.0)| = 2.0, |-1.0 - 1.0| = 2.0). + let valid: Vec<f64> = rtt.iter().copied().filter(|&r| r >= 0.0).collect(); + if valid.len() > 1 { + let sum: f64 = valid.windows(2).map(|w| (w[1] - w[0]).abs()).sum(); + sum / (valid.len() - 1) as f64 } else { 0.0 } @@ -42,6 +45,13 @@ valid[idx] } +/// Convert an RTT float (ms) to a scaled u64 for sparkline display. +/// Multiplies by 100 to preserve 0.01 ms precision — without this, sub-ms +/// RTTs (e.g. 0.3 ms on LAN) would be truncated to 0 and appear as blank bars. +pub fn rtt_to_spark_unit(rtt: f64) -> u64 { + if rtt < 0.0 { 0 } else { (rtt * 100.0).round() as u64 } +} + pub fn calculate_loss_pkg(timeout: usize, received: usize) -> f64 { if timeout > 0 { (timeout as f64 / (received as f64 + timeout as f64)) * 100.0 @@ -83,3 +93,70 @@ .wrap(Wrap { trim: true }); f.render_widget(errors_paragraph, area); } + +#[cfg(test)] +mod tests { + use super::*; + + fn deque(v: &[f64]) -> VecDeque<f64> { + v.iter().copied().collect() + } + + // ---- calculate_jitter ---- + + #[test] + fn jitter_ignores_timeout_sentinels() { + // [1.0, -1.0, 2.0]: real jitter between 1.0→2.0 = 1.0 ms. + // Before the fix, the sentinel inflated this to (2.0 + 3.0) / 2 = 2.5 ms. + let j = calculate_jitter(&deque(&[1.0, -1.0, 2.0])); + assert!((j - 1.0).abs() < 1e-9, "jitter={}", j); + } + + #[test] + fn jitter_all_sentinels_returns_zero() { + assert_eq!(calculate_jitter(&deque(&[-1.0, -1.0, -1.0])), 0.0); + } + + #[test] + fn jitter_single_valid_returns_zero() { + assert_eq!(calculate_jitter(&deque(&[-1.0, 5.0, -1.0])), 0.0); + } + + #[test] + fn jitter_no_sentinels_unchanged() { + // [1.0, 3.0, 2.0] → diffs [2.0, 1.0] → jitter 1.5 + let j = calculate_jitter(&deque(&[1.0, 3.0, 2.0])); + assert!((j - 1.5).abs() < 1e-9, "jitter={}", j); + } + + // ---- rtt_to_spark_unit ---- + + #[test] + fn spark_unit_preserves_submillisecond() { + assert_eq!(rtt_to_spark_unit(0.3), 30); // 0.3 ms → 30 units + assert_eq!(rtt_to_spark_unit(0.01), 1); // 0.01 ms → 1 unit (not 0) + assert_eq!(rtt_to_spark_unit(1.0), 100); // 1.0 ms → 100 units + assert_eq!(rtt_to_spark_unit(10.5), 1050); + } + + #[test] + fn spark_unit_timeout_sentinel_is_blank() { + assert_eq!(rtt_to_spark_unit(-1.0), 0); + assert_eq!(rtt_to_spark_unit(-0.001), 0); + } + + // ---- calculate_p95 ---- + + #[test] + fn p95_filters_sentinels() { + // Only the three valid values should be considered. + let p = calculate_p95(&deque(&[-1.0, 1.0, 2.0, 3.0, -1.0])); + // 95th percentile of [1.0, 2.0, 3.0] = 3.0 + assert!((p - 3.0).abs() < 1e-9, "p95={}", p); + } + + #[test] + fn p95_all_sentinels_returns_zero() { + assert_eq!(calculate_p95(&deque(&[-1.0, -1.0])), 0.0); + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/Nping-0.7.0/src/view.rs new/Nping-0.7.1/src/view.rs --- old/Nping-0.7.0/src/view.rs 2026-05-20 17:10:30.000000000 +0200 +++ new/Nping-0.7.1/src/view.rs 2026-06-28 14:29:41.000000000 +0200 @@ -1,5 +1,7 @@ use std::sync::atomic::{AtomicU8, Ordering}; +const VIEW_COUNT: u8 = 4; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum View { @@ -40,7 +42,7 @@ } pub fn next(self) -> Self { - Self::from_u8((self as u8 + 1) % 4) + Self::from_u8((self as u8 + 1) % VIEW_COUNT) } } ++++++ Nping.obsinfo ++++++ --- /var/tmp/diff_new_pack.Vm0DdM/_old 2026-06-29 17:34:13.177970834 +0200 +++ /var/tmp/diff_new_pack.Vm0DdM/_new 2026-06-29 17:34:13.185971108 +0200 @@ -1,5 +1,5 @@ name: Nping -version: 0.7.0 -mtime: 1779289830 -commit: 1c88a7e89e8727aa83ec76c33e4e59d7d059ece7 +version: 0.7.1 +mtime: 1782649781 +commit: a0371cc7210f9a6e15e1a314b80419acb01d3e12 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.Vm0DdM/_old 2026-06-29 17:34:13.221972340 +0200 +++ /var/tmp/diff_new_pack.Vm0DdM/_new 2026-06-29 17:34:13.225972477 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/hanshuaikang/Nping</param> <param name="versionformat">@PARENT_TAG@</param> <param name="scm">git</param> - <param name="revision">v0.7.0</param> + <param name="revision">v0.7.1</param> <param name="versionrewrite-pattern">v(\d+\.\d+\.\d+)</param> <param name="changesgenerate">enable</param> </service> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.Vm0DdM/_old 2026-06-29 17:34:13.261973709 +0200 +++ /var/tmp/diff_new_pack.Vm0DdM/_new 2026-06-29 17:34:13.265973846 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/hanshuaikang/Nping</param> - <param name="changesrevision">1c88a7e89e8727aa83ec76c33e4e59d7d059ece7</param></service></servicedata> + <param name="changesrevision">a0371cc7210f9a6e15e1a314b80419acb01d3e12</param></service></servicedata> (No newline at EOF) ++++++ vendor.tar.zst ++++++ /work/SRC/openSUSE:Factory/nbping/vendor.tar.zst /work/SRC/openSUSE:Factory/.nbping.new.11887/vendor.tar.zst differ: char 7, line 1
