diff --git a/packages/devkit/Cargo.toml b/packages/devkit/Cargo.toml index 8d04bd1..859a8e0 100644 --- a/packages/devkit/Cargo.toml +++ b/packages/devkit/Cargo.toml @@ -7,7 +7,8 @@ description = "Developer toolkit for testing and simulating the Stellar fee trac [dependencies] axum = "0.7" indicatif = "0.17" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } +clap_complete = "4" rand = { version = "0.8", features = ["small_rng"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/packages/devkit/build.rs b/packages/devkit/build.rs new file mode 100644 index 0000000..c04971f --- /dev/null +++ b/packages/devkit/build.rs @@ -0,0 +1,44 @@ +use std::process::Command; + +fn main() { + // Capture git commit SHA + let commit_sha = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + // Capture build timestamp + let build_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_else(|_| "0".to_string()); + + // Capture rustc version + let rustc_version = Command::new("rustc") + .arg("--version") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + // Capture target triple + let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".to_string()); + + // Determine debug build + let debug = std::env::var("PROFILE") + .map(|p| p == "debug") + .unwrap_or(true); + + println!("cargo:rustc-env=DEVKIT_COMMIT_SHA={}", commit_sha); + println!("cargo:rustc-env=DEVKIT_BUILD_TIME={}", build_time); + println!("cargo:rustc-env=DEVKIT_RUSTC_VERSION={}", rustc_version); + println!("cargo:rustc-env=DEVKIT_TARGET={}", target); + println!("cargo:rustc-env=DEVKIT_DEBUG={}", debug); + + // Rerun if git HEAD changes + println!("cargo:rerun-if-changed=.git/HEAD"); +} diff --git a/packages/devkit/src/cli/completions.rs b/packages/devkit/src/cli/completions.rs new file mode 100644 index 0000000..8ec1052 --- /dev/null +++ b/packages/devkit/src/cli/completions.rs @@ -0,0 +1,139 @@ +use clap::CommandFactory; +use clap_complete::{generate, Shell}; +use std::io; + +use crate::cli::Cli; + +/// Supported shell types for completion generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShellType { + Bash, + Zsh, + Fish, +} + +impl ShellType { + /// Parse a shell name string into a `ShellType`. + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "bash" => Some(Self::Bash), + "zsh" => Some(Self::Zsh), + "fish" => Some(Self::Fish), + _ => None, + } + } + + /// Return the corresponding `clap_complete::Shell` value. + pub fn to_clap_shell(self) -> Shell { + match self { + Self::Bash => Shell::Bash, + Self::Zsh => Shell::Zsh, + Self::Fish => Shell::Fish, + } + } +} + +impl std::fmt::Display for ShellType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Bash => write!(f, "bash"), + Self::Zsh => write!(f, "zsh"), + Self::Fish => write!(f, "fish"), + } + } +} + +/// Arguments for the `completions` subcommand. +pub struct CompletionsArgs { + /// Shell to generate completions for. + pub shell: ShellType, +} + +impl Default for CompletionsArgs { + fn default() -> Self { + Self { + shell: ShellType::Bash, + } + } +} + +impl CompletionsArgs { + /// Generate and print the shell completion script to stdout. + pub fn run(&self) { + let mut cmd = Cli::command(); + generate(self.shell.to_clap_shell(), &mut cmd, "devkit", &mut io::stdout()); + } + + /// Generate the completion script into a `String` (useful for testing). + pub fn generate_to_string(&self) -> String { + let mut cmd = Cli::command(); + let mut buf = Vec::new(); + generate(self.shell.to_clap_shell(), &mut cmd, "devkit", &mut buf); + String::from_utf8_lossy(&buf).into_owned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_type_parse_bash() { + assert_eq!(ShellType::parse("bash"), Some(ShellType::Bash)); + } + + #[test] + fn shell_type_parse_zsh() { + assert_eq!(ShellType::parse("zsh"), Some(ShellType::Zsh)); + } + + #[test] + fn shell_type_parse_fish() { + assert_eq!(ShellType::parse("fish"), Some(ShellType::Fish)); + } + + #[test] + fn shell_type_parse_unknown_returns_none() { + assert_eq!(ShellType::parse("powershell"), None); + } + + #[test] + fn shell_type_parse_case_insensitive() { + assert_eq!(ShellType::parse("ZSH"), Some(ShellType::Zsh)); + assert_eq!(ShellType::parse("BASH"), Some(ShellType::Bash)); + assert_eq!(ShellType::parse("Fish"), Some(ShellType::Fish)); + } + + #[test] + fn completions_generate_bash_is_nonempty() { + let args = CompletionsArgs { + shell: ShellType::Bash, + }; + let script = args.generate_to_string(); + assert!(!script.is_empty()); + } + + #[test] + fn completions_generate_zsh_is_nonempty() { + let args = CompletionsArgs { + shell: ShellType::Zsh, + }; + let script = args.generate_to_string(); + assert!(!script.is_empty()); + } + + #[test] + fn completions_generate_fish_is_nonempty() { + let args = CompletionsArgs { + shell: ShellType::Fish, + }; + let script = args.generate_to_string(); + assert!(!script.is_empty()); + } + + #[test] + fn completions_default_shell_is_bash() { + let args = CompletionsArgs::default(); + assert_eq!(args.shell, ShellType::Bash); + } +} diff --git a/packages/devkit/src/cli/config.rs b/packages/devkit/src/cli/config.rs new file mode 100644 index 0000000..212b597 --- /dev/null +++ b/packages/devkit/src/cli/config.rs @@ -0,0 +1,273 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Active configuration for the devkit CLI tool. +#[derive(Debug, Clone)] +pub struct Config { + /// Path to the fee database. + pub db_path: PathBuf, + /// Default scenario file for mock data. + pub scenario: String, + /// Mock server port. + pub port: u16, + /// Whether to show detailed output. + pub verbose: bool, + /// Custom key-value overrides. + pub overrides: BTreeMap, +} + +impl Default for Config { + fn default() -> Self { + Self { + db_path: PathBuf::from("stellar_fees.db"), + scenario: String::from("normal"), + port: 8090, + verbose: false, + overrides: BTreeMap::new(), + } + } +} + +impl Config { + /// Load configuration from environment variables and an optional config file. + pub fn load(path: Option<&PathBuf>) -> Self { + let mut cfg = match path { + Some(p) => Self::from_file(p), + None => Self::default(), + }; + cfg.apply_env(); + cfg + } + + /// Parse config from a simple `key = value` file (one pair per line). + pub fn from_file(path: &PathBuf) -> Self { + let content = std::fs::read_to_string(path).unwrap_or_default(); + let mut cfg = Self::default(); + for line in content.lines() { + let line = line.trim(); + if line.starts_with('#') || line.is_empty() { + continue; + } + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + let value = value.trim().trim_matches('"'); + match key { + "db_path" => cfg.db_path = PathBuf::from(value), + "scenario" => cfg.scenario = value.to_string(), + "port" => cfg.port = value.parse().unwrap_or(8090), + "verbose" => cfg.verbose = value == "true", + _ => { + cfg.overrides.insert(key.to_string(), value.to_string()); + } + } + } + } + cfg + } + + /// Apply environment variable overrides on top of the current config. + pub fn apply_env(&mut self) { + if let Ok(v) = std::env::var("DEVKIT_DB_PATH") { + self.db_path = PathBuf::from(v); + } + if let Ok(v) = std::env::var("DEVKIT_SCENARIO") { + self.scenario = v; + } + if let Ok(v) = std::env::var("DEVKIT_PORT") { + self.port = v.parse().unwrap_or(self.port); + } + if let Ok(v) = std::env::var("DEVKIT_VERBOSE") { + self.verbose = v == "true" || v == "1"; + } + } + + /// Return the effective database path. + pub fn db_path(&self) -> &PathBuf { + &self.db_path + } + + /// Display the full configuration as a formatted key/value report. + /// + /// Each row shows the key, current value, and source (env var or default). + pub fn display(&self) -> String { + let db_source = if std::env::var("DEVKIT_DB_PATH").is_ok() { + "env" + } else { + "default" + }; + let scenario_source = if std::env::var("DEVKIT_SCENARIO").is_ok() { + "env" + } else { + "default" + }; + let port_source = if std::env::var("DEVKIT_PORT").is_ok() { + "env" + } else { + "default" + }; + let verbose_source = if std::env::var("DEVKIT_VERBOSE").is_ok() { + "env" + } else { + "default" + }; + + let mut out = String::new(); + out.push_str("devkit configuration\n"); + out.push_str("====================\n"); + out.push_str(&format!( + "{:<12} {:<30} {}\n", + "key", + "value", + "source" + )); + out.push_str(&format!( + "{:<12} {:<30} {}\n", + "db_path", + self.db_path.display(), + db_source + )); + out.push_str(&format!( + "{:<12} {:<30} {}\n", + "scenario", + self.scenario, + scenario_source + )); + out.push_str(&format!( + "{:<12} {:<30} {}\n", + "port", + self.port, + port_source + )); + out.push_str(&format!( + "{:<12} {:<30} {}\n", + "verbose", + self.verbose, + verbose_source + )); + + if !self.overrides.is_empty() { + out.push_str("overrides:\n"); + for (k, v) in &self.overrides { + out.push_str(&format!(" {} = {}\n", k, v)); + } + } + out + } +} + +/// Arguments for the `config` subcommand. +pub struct ConfigArgs { + /// Optional path to a configuration file. + pub config_file: Option, + /// Show the effective configuration and exit. + pub show: bool, + /// Set a configuration key=value pair (may be specified multiple times). + pub set: Vec, +} + +impl Default for ConfigArgs { + fn default() -> Self { + Self { + config_file: None, + show: false, + set: Vec::new(), + } + } +} + +impl ConfigArgs { + /// Run the config subcommand: print all active key/value pairs with source. + pub fn run(&self) { + let mut cfg = Config::load(self.config_file.as_ref()); + for pair in &self.set { + if let Some((key, value)) = pair.split_once('=') { + cfg.overrides + .insert(key.trim().to_string(), value.trim().to_string()); + } + } + print!("{}", cfg.display()); + } + + /// Return the list of active configuration keys. + pub fn keys(&self) -> Vec { + let cfg = Config::load(self.config_file.as_ref()); + let mut keys: Vec = vec![ + "db_path".into(), + "scenario".into(), + "port".into(), + "verbose".into(), + ]; + keys.extend(cfg.overrides.keys().cloned()); + keys.sort(); + keys + } + + /// Validate the configuration, returning a list of issues found. + pub fn validate(&self) -> Vec { + let cfg = Config::load(self.config_file.as_ref()); + let mut issues = Vec::new(); + if !cfg.db_path.exists() { + issues.push(format!("db_path not found: {}", cfg.db_path.display())); + } + if cfg.port == 0 { + issues.push("port must be > 0".into()); + } + issues + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> Config { + Config { + db_path: PathBuf::from("/tmp/test.db"), + scenario: "congested".into(), + port: 9090, + verbose: true, + overrides: BTreeMap::from([("timeout".into(), "30".into())]), + } + } + + #[test] + fn config_default_has_sensible_values() { + let cfg = Config::default(); + assert_eq!(cfg.port, 8090); + assert!(!cfg.verbose); + } + + #[test] + fn config_display_includes_all_fields() { + let out = test_config().display(); + assert!(out.contains("/tmp/test.db")); + assert!(out.contains("congested")); + assert!(out.contains("9090")); + assert!(out.contains("timeout")); + } + + #[test] + fn config_args_default_does_not_panic() { + let args = ConfigArgs::default(); + assert!(args.set.is_empty()); + } + + #[test] + fn config_validate_handles_missing_db() { + let args = ConfigArgs { + config_file: None, + show: false, + set: vec![], + }; + let issues = args.validate(); + assert!(issues.iter().any(|i| i.contains("db_path"))); + } + + #[test] + fn config_keys_includes_base_keys() { + let args = ConfigArgs::default(); + let keys = args.keys(); + assert!(keys.contains(&"db_path".into())); + assert!(keys.contains(&"port".into())); + } +} diff --git a/packages/devkit/src/cli/mod.rs b/packages/devkit/src/cli/mod.rs index f2a1663..9eb66ea 100644 --- a/packages/devkit/src/cli/mod.rs +++ b/packages/devkit/src/cli/mod.rs @@ -1,9 +1,16 @@ pub mod benchmark; +pub mod completions; +pub mod config; pub mod export; pub mod replay; +pub mod version; use clap::{Parser, Subcommand}; +pub use completions::{CompletionsArgs, ShellType}; +pub use config::ConfigArgs; +pub use version::VersionArgs; + /// Arguments for the `simulate` subcommand. pub struct SimulateArgs { /// Base fee floor in stroops. @@ -54,6 +61,70 @@ impl MockArgs { } } +// --------------------------------------------------------------------------- +// #403: Piped-input helpers for validate and inspect subcommands +// --------------------------------------------------------------------------- + +/// Input source for subcommands that accept fee data (file or stdin pipe). +#[derive(Debug, Clone)] +pub enum InputSource { + /// Read from a file at the given path. + File(std::path::PathBuf), + /// Read from stdin (piped input). + Stdin, +} + +impl Default for InputSource { + fn default() -> Self { + Self::Stdin + } +} + +impl InputSource { + /// Detect whether data is being piped into the process. + /// + /// Returns `true` when stdin is not a terminal (i.e., it has been redirected + /// or data is being piped into the process). + pub fn is_piped() -> bool { + // atty is not a dependency; use a simple heuristic: check if + // stdin has data by testing the fd type via std libc metadata. + // Safer portable approach: fall back to checking an env hint. + // In practice callers pass `InputSource::Stdin` explicitly when piping. + !std::io::IsTerminal::is_terminal(&std::io::stdin()) + } + + /// Load fee points using the CSV reader. + pub fn load_csv( + &self, + ) -> Result { + match self { + Self::File(path) => crate::utilities::csv_reader::read_csv_file(path), + Self::Stdin => Ok(crate::utilities::csv_reader::read_csv_stdin()), + } + } + + /// Load fee points using the JSON reader. + pub fn load_json( + &self, + ) -> Result, crate::utilities::json_reader::JsonReadError> + { + match self { + Self::File(path) => crate::utilities::json_reader::read_json_file(path), + Self::Stdin => crate::utilities::json_reader::read_json_stdin(), + } + } +} + +/// Arguments for the `validate` subcommand. +/// +/// Supports piped input: `cat fees.csv | devkit validate --format csv` +pub struct ValidateArgs { + /// Input source (file path or stdin). + pub input: InputSource, + /// Format of the input data: "csv" or "json". + pub format: String, + /// Output format for the report: "text" or "json". + pub output_format: String, // ── validate (issue #391) ──────────────────────────────────────────────────── /// Arguments for the `validate` subcommand. @@ -80,6 +151,9 @@ pub struct ValidateArgs { impl Default for ValidateArgs { fn default() -> Self { Self { + input: InputSource::Stdin, + format: "csv".to_string(), + output_format: "text".to_string(), file: std::path::PathBuf::from("fees.csv"), format: "csv".into(), output_format: "text".into(), @@ -89,6 +163,42 @@ impl Default for ValidateArgs { } impl ValidateArgs { + /// Run the validate subcommand, printing a data quality report. + pub fn run(&self) { + let points = match self.format.as_str() { + "csv" => { + match self.input.load_csv() { + Ok(result) => { + if result.skipped > 0 { + eprintln!("Warning: {} malformed rows were skipped", result.skipped); + } + result.points + } + Err(e) => { + eprintln!("Error reading CSV: {}", e); + std::process::exit(1); + } + } + } + "json" => match self.input.load_json() { + Ok(pts) => pts, + Err(e) => { + eprintln!("Error reading JSON: {}", e); + std::process::exit(1); + } + }, + fmt => { + eprintln!("Unsupported format: {}. Use 'csv' or 'json'.", fmt); + std::process::exit(1); + } + }; + + if points.is_empty() { + eprintln!("No fee records found in input."); + std::process::exit(1); + } + + let report = DataQualityReport::from_points(&points); /// Run the validate subcommand on the given fee points. pub fn run(&self, points: &[crate::simulation::fee_model::FeePoint]) { let report = crate::data_quality::validator::Validator::run(points); @@ -97,6 +207,227 @@ impl ValidateArgs { } else { println!("{}", report.display()); } + } +} + +/// Arguments for the `inspect` subcommand. +/// +/// Supports piped input: `cat fees.csv | devkit inspect --format csv` +pub struct InspectArgs { + /// Input source (file path or stdin). + pub input: InputSource, + /// Format of the input data: "csv" or "json". + pub format: String, + /// Output format: "text" or "json". + pub output_format: String, +} + +impl Default for InspectArgs { + fn default() -> Self { + Self { + input: InputSource::Stdin, + format: "csv".to_string(), + output_format: "text".to_string(), + } + } +} + +impl InspectArgs { + /// Run the inspect subcommand, printing a summary of the fee data. + pub fn run(&self) { + let points = match self.format.as_str() { + "csv" => match self.input.load_csv() { + Ok(result) => { + if result.skipped > 0 { + eprintln!("Warning: {} malformed rows were skipped", result.skipped); + } + result.points + } + Err(e) => { + eprintln!("Error reading CSV: {}", e); + std::process::exit(1); + } + }, + "json" => match self.input.load_json() { + Ok(pts) => pts, + Err(e) => { + eprintln!("Error reading JSON: {}", e); + std::process::exit(1); + } + }, + fmt => { + eprintln!("Unsupported format: {}. Use 'csv' or 'json'.", fmt); + std::process::exit(1); + } + }; + + if points.is_empty() { + eprintln!("No fee records found in input."); + std::process::exit(1); + } + + let summary = FeeSummary::from_points(&points); + if self.output_format == "json" { + println!("{}", summary.to_json()); + } else { + println!("{}", summary.display()); + } + } +} + +// --------------------------------------------------------------------------- +// Embedded report types (used by validate and inspect) +// --------------------------------------------------------------------------- + +use crate::simulation::fee_model::FeePoint; + +/// A basic data quality report for fee data. +pub struct DataQualityReport { + pub total: usize, + pub spike_count: usize, + pub min_fee: u64, + pub max_fee: u64, + pub mean_fee: f64, +} + +impl DataQualityReport { + pub fn from_points(points: &[FeePoint]) -> Self { + let total = points.len(); + let spike_count = points.iter().filter(|p| p.is_spike).count(); + let fees: Vec = points.iter().map(|p| p.fee).collect(); + let min_fee = *fees.iter().min().unwrap_or(&0); + let max_fee = *fees.iter().max().unwrap_or(&0); + let mean_fee = fees.iter().sum::() as f64 / total as f64; + Self { + total, + spike_count, + min_fee, + max_fee, + mean_fee, + } + } + + pub fn display(&self) -> String { + format!( + "Data Quality Report\n\ + ===================\n\ + total records: {}\n\ + spike count: {}\n\ + min fee: {} stroops\n\ + max fee: {} stroops\n\ + mean fee: {:.2} stroops", + self.total, self.spike_count, self.min_fee, self.max_fee, self.mean_fee, + ) + } + + pub fn to_json(&self) -> String { + format!( + r#"{{"total":{},"spike_count":{},"min_fee":{},"max_fee":{},"mean_fee":{:.2}}}"#, + self.total, self.spike_count, self.min_fee, self.max_fee, self.mean_fee, + ) + } +} + +/// Statistical summary for a set of fee points (used by inspect). +pub struct FeeSummary { + pub count: usize, + pub min: u64, + pub max: u64, + pub mean: f64, + pub median: u64, + pub spike_count: usize, + pub p25: u64, + pub p75: u64, + pub p95: u64, + pub p99: u64, +} + +impl FeeSummary { + pub fn from_points(points: &[FeePoint]) -> Self { + if points.is_empty() { + return Self { + count: 0, + min: 0, + max: 0, + mean: 0.0, + median: 0, + spike_count: 0, + p25: 0, + p75: 0, + p95: 0, + p99: 0, + }; + } + let mut fees: Vec = points.iter().map(|p| p.fee).collect(); + fees.sort_unstable(); + let count = fees.len(); + let min = fees[0]; + let max = fees[count - 1]; + let mean = fees.iter().sum::() as f64 / count as f64; + let median = fees[count / 2]; + let spike_count = points.iter().filter(|p| p.is_spike).count(); + let p = |pct: f64| fees[((count as f64 * pct) as usize).min(count - 1)]; + Self { + count, + min, + max, + mean, + median, + spike_count, + p25: p(0.25), + p75: p(0.75), + p95: p(0.95), + p99: p(0.99), + } + } + + pub fn display(&self) -> String { + format!( + "Fee Summary\n\ + ===========\n\ + count: {}\n\ + min: {} stroops\n\ + max: {} stroops\n\ + mean: {:.2} stroops\n\ + median: {} stroops\n\ + spike_count: {}\n\ + p25: {} stroops\n\ + p75: {} stroops\n\ + p95: {} stroops\n\ + p99: {} stroops", + self.count, + self.min, + self.max, + self.mean, + self.median, + self.spike_count, + self.p25, + self.p75, + self.p95, + self.p99, + ) + } + + pub fn to_json(&self) -> String { + format!( + r#"{{"count":{},"min":{},"max":{},"mean":{:.2},"median":{},"spike_count":{},"p25":{},"p75":{},"p95":{},"p99":{}}}"#, + self.count, + self.min, + self.max, + self.mean, + self.median, + self.spike_count, + self.p25, + self.p75, + self.p95, + self.p99, + ) + } +} + +// --------------------------------------------------------------------------- +// Clap CLI definition +// --------------------------------------------------------------------------- if !self.quiet && !report.is_clean() { eprintln!("Validation failed: {} issue(s) found.", report.findings.len()); } @@ -329,6 +660,61 @@ pub enum Commands { Benchmark, /// Serve mock fee data Mock, + /// Print devkit version and build info + /// + /// Use `--json` for machine-readable output. + Version { + /// Output as JSON + #[arg(long)] + json: bool, + /// Check if current version is newer than the given version + #[arg(long, value_name = "VERSION")] + check: Option, + }, + /// Show active devkit configuration (env vars and defaults) + Config { + /// Path to a configuration file + #[arg(long, value_name = "FILE")] + config_file: Option, + /// Set a key=value configuration override (repeatable) + #[arg(long, value_name = "KEY=VALUE")] + set: Vec, + }, + /// Generate shell completion scripts + /// + /// Example: `devkit completions --shell zsh >> ~/.zshrc` + Completions { + /// Shell to generate completions for (bash, zsh, fish) + #[arg(long, value_name = "SHELL", default_value = "bash")] + shell: String, + }, + /// Validate fee data from a file or piped stdin + /// + /// Example: `cat fees.csv | devkit validate --format csv` + Validate { + /// Path to the fee data file (omit to read from stdin) + #[arg(long, value_name = "FILE")] + file: Option, + /// Input format: csv or json + #[arg(long, default_value = "csv", value_name = "FORMAT")] + format: String, + /// Output format for the report: text or json + #[arg(long, default_value = "text", value_name = "FORMAT")] + output_format: String, + }, + /// Inspect fee data and print a statistical summary + /// + /// Example: `cat fees.csv | devkit inspect --format csv` + Inspect { + /// Path to the fee data file (omit to read from stdin) + #[arg(long, value_name = "FILE")] + file: Option, + /// Input format: csv or json + #[arg(long, default_value = "csv", value_name = "FORMAT")] + format: String, + /// Output format: text or json + #[arg(long, default_value = "text", value_name = "FORMAT")] + output_format: String, /// Validate fee data quality Validate, /// Repair fee data (gap fill, de-duplicate) diff --git a/packages/devkit/src/cli/version.rs b/packages/devkit/src/cli/version.rs new file mode 100644 index 0000000..d3d4fce --- /dev/null +++ b/packages/devkit/src/cli/version.rs @@ -0,0 +1,220 @@ +/// Semantic version of the devkit crate. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Build metadata collected at compile time via build.rs. +#[derive(Debug, Clone)] +pub struct BuildInfo { + /// Crate version from Cargo.toml. + pub version: String, + /// Git commit SHA at build time. + pub commit_sha: String, + /// Unix timestamp of the build. + pub build_time: String, + /// Rust compiler version. + pub rustc_version: String, + /// Target triple. + pub target: String, + /// Whether the build was compiled in debug mode. + pub debug: bool, + /// Enabled feature flags. + pub features: Vec, +} + +impl Default for BuildInfo { + fn default() -> Self { + Self { + version: VERSION.to_string(), + commit_sha: option_env!("DEVKIT_COMMIT_SHA") + .unwrap_or("unknown") + .to_string(), + build_time: option_env!("DEVKIT_BUILD_TIME") + .unwrap_or("unknown") + .to_string(), + rustc_version: option_env!("DEVKIT_RUSTC_VERSION") + .unwrap_or("unknown") + .to_string(), + target: option_env!("DEVKIT_TARGET") + .unwrap_or("unknown") + .to_string(), + debug: option_env!("DEVKIT_DEBUG") + .map(|v| v == "true") + .unwrap_or(cfg!(debug_assertions)), + features: Vec::new(), + } + } +} + +impl BuildInfo { + /// Format build info as a human-readable block. + pub fn display(&self) -> String { + let features = if self.features.is_empty() { + "none".to_string() + } else { + self.features.join(", ") + }; + format!( + "devkit {}\n commit: {}\n build time: {}\n rustc: {}\n target: {}\n debug: {}\n features: {}", + self.version, + self.commit_sha, + self.build_time, + self.rustc_version, + self.target, + self.debug, + features, + ) + } + + /// Format build info as a JSON object. + pub fn to_json(&self) -> String { + let features_json = self + .features + .iter() + .map(|f| format!("\"{}\"", f)) + .collect::>() + .join(","); + format!( + r#"{{"version":"{}","commit":"{}","build_time":"{}","rustc":"{}","target":"{}","debug":{},"features":[{}]}}"#, + self.version, + self.commit_sha, + self.build_time, + self.rustc_version, + self.target, + self.debug, + features_json, + ) + } + + /// Compare whether this build is newer than `other` using semver logic. + pub fn is_newer_than(&self, other: &str) -> bool { + fn parse_version(v: &str) -> Vec { + v.trim_start_matches('v') + .split('.') + .filter_map(|s| s.parse().ok()) + .collect() + } + let ours = parse_version(&self.version); + let theirs = parse_version(other); + for (a, b) in ours.iter().zip(theirs.iter()) { + if a != b { + return a > b; + } + } + ours.len() > theirs.len() + } +} + +/// Arguments for the `version` subcommand. +pub struct VersionArgs { + /// Output as JSON instead of plain text. + pub json: bool, + /// Check if the current version is newer than this version string. + pub check: Option, +} + +impl Default for VersionArgs { + fn default() -> Self { + Self { + json: false, + check: None, + } + } +} + +impl VersionArgs { + /// Run the version subcommand, printing build info to stdout. + pub fn run(&self) { + let info = BuildInfo::default(); + if let Some(other) = &self.check { + let newer = info.is_newer_than(other); + println!("{}", if newer { "true" } else { "false" }); + return; + } + if self.json { + println!("{}", info.to_json()); + } else { + println!("{}", info.display()); + } + } + + /// Return just the crate version string. + pub fn version_only(&self) -> &'static str { + VERSION + } + + /// Check if a minimum version requirement is met. + pub fn meets_minimum(&self, minimum: &str) -> bool { + let info = BuildInfo::default(); + info.is_newer_than(minimum) || info.version == minimum + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_is_not_empty() { + assert!(!VERSION.is_empty()); + } + + #[test] + fn build_info_default_contains_version() { + let info = BuildInfo::default(); + assert!(info.display().contains(&info.version)); + } + + #[test] + fn build_info_json_is_valid_object() { + let json = BuildInfo { + version: "1.0.0".into(), + commit_sha: "abc123".into(), + build_time: "1234567890".into(), + rustc_version: "1.75.0".into(), + target: "x86_64-linux".into(), + debug: false, + features: vec!["json".into()], + } + .to_json(); + assert!(json.starts_with('{')); + assert!(json.contains("\"version\":\"1.0.0\"")); + } + + #[test] + fn is_newer_than_returns_true_for_higher_major() { + let info = BuildInfo { + version: "2.0.0".into(), + ..Default::default() + }; + assert!(info.is_newer_than("1.9.9")); + } + + #[test] + fn is_newer_than_returns_false_for_lower() { + let info = BuildInfo { + version: "0.9.0".into(), + ..Default::default() + }; + assert!(!info.is_newer_than("1.0.0")); + } + + #[test] + fn is_newer_than_same_version() { + let info = BuildInfo { + version: "1.0.0".into(), + ..Default::default() + }; + assert!(!info.is_newer_than("1.0.0")); + } + + #[test] + fn version_args_default_does_not_panic() { + let args = VersionArgs::default(); + assert!(!args.json); + } + + #[test] + fn version_only_returns_str() { + let args = VersionArgs::default(); + assert!(!args.version_only().is_empty()); + } +} diff --git a/packages/devkit/src/utilities/csv_reader.rs b/packages/devkit/src/utilities/csv_reader.rs new file mode 100644 index 0000000..bbd1161 --- /dev/null +++ b/packages/devkit/src/utilities/csv_reader.rs @@ -0,0 +1,196 @@ +//! CSV reader for fee data — supports both file and piped stdin input. +//! +//! Expected columns (header required): +//! `timestamp,fee_amount,ledger_sequence,is_spike` +//! +//! Malformed rows are skipped; the count of skipped rows is reported. + +use std::io::{BufRead, BufReader, Read}; +use std::path::Path; + +use crate::simulation::fee_model::FeePoint; + +/// Result of reading fee data from a CSV source. +#[derive(Debug, Default)] +pub struct CsvReadResult { + /// Successfully parsed fee points. + pub points: Vec, + /// Number of rows that were skipped due to parse errors. + pub skipped: usize, +} + +/// Read `FeePoint` records from a CSV string. +/// +/// The header row (`timestamp,fee_amount,ledger_sequence,is_spike`) is required and +/// is skipped automatically. Rows that are malformed are silently counted in +/// `CsvReadResult::skipped`. +pub fn read_csv_str(input: &str) -> CsvReadResult { + read_csv_reader(input.as_bytes()) +} + +/// Read `FeePoint` records from a file at `path`. +pub fn read_csv_file(path: &Path) -> std::io::Result { + let file = std::fs::File::open(path)?; + Ok(read_csv_reader(file)) +} + +/// Read `FeePoint` records from stdin (piped input). +/// +/// Blocks until EOF. Use when detecting that stdin is not a TTY, e.g. via +/// `atty::isnt(Stream::Stdin)`, or simply let the user pipe data. +pub fn read_csv_stdin() -> CsvReadResult { + read_csv_reader(std::io::stdin()) +} + +/// Core reader: parses CSV from any `Read` source. +fn read_csv_reader(reader: R) -> CsvReadResult { + let buf = BufReader::new(reader); + let mut result = CsvReadResult::default(); + let mut header_seen = false; + + for line in buf.lines() { + let line = match line { + Ok(l) => l, + Err(_) => { + result.skipped += 1; + continue; + } + }; + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + // Skip the header row + if !header_seen { + header_seen = true; + continue; + } + + match parse_csv_row(&line) { + Some(point) => result.points.push(point), + None => result.skipped += 1, + } + } + + result +} + +/// Parse a single CSV row into a `FeePoint`. +/// +/// Expected format: `,,,` +fn parse_csv_row(row: &str) -> Option { + let mut cols = row.splitn(4, ','); + let timestamp: u64 = cols.next()?.trim().parse().ok()?; + let fee: u64 = cols.next()?.trim().parse().ok()?; + let ledger: u64 = cols.next()?.trim().parse().ok()?; + let is_spike_str = cols.next()?.trim().to_lowercase(); + let is_spike = matches!(is_spike_str.as_str(), "true" | "1" | "yes"); + + Some(FeePoint { + timestamp, + fee, + ledger, + is_spike, + }) +} + +/// Write `FeePoint` records to a CSV string. +pub fn write_csv_str(points: &[FeePoint]) -> String { + let mut out = String::from("timestamp,fee_amount,ledger_sequence,is_spike\n"); + for p in points { + out.push_str(&format!( + "{},{},{},{}\n", + p.timestamp, p.fee, p.ledger, p.is_spike + )); + } + out +} + +/// Write `FeePoint` records to a file. +pub fn write_csv_file(points: &[FeePoint], path: &Path) -> std::io::Result<()> { + std::fs::write(path, write_csv_str(points)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_CSV: &str = "\ +timestamp,fee_amount,ledger_sequence,is_spike +1000,100,1,false +2000,200,2,true +3000,150,3,false +"; + + #[test] + fn read_valid_csv_parses_all_rows() { + let result = read_csv_str(VALID_CSV); + assert_eq!(result.points.len(), 3); + assert_eq!(result.skipped, 0); + } + + #[test] + fn read_csv_first_row_timestamp() { + let result = read_csv_str(VALID_CSV); + assert_eq!(result.points[0].timestamp, 1000); + assert_eq!(result.points[0].fee, 100); + assert!(!result.points[0].is_spike); + } + + #[test] + fn read_csv_spike_flag_true() { + let result = read_csv_str(VALID_CSV); + assert!(result.points[1].is_spike); + } + + #[test] + fn malformed_row_is_skipped() { + let csv = "timestamp,fee_amount,ledger_sequence,is_spike\nbad_row\n1000,100,1,false\n"; + let result = read_csv_str(csv); + assert_eq!(result.points.len(), 1); + assert_eq!(result.skipped, 1); + } + + #[test] + fn write_csv_produces_header() { + let points = vec![FeePoint { + timestamp: 1000, + fee: 100, + ledger: 1, + is_spike: false, + }]; + let csv = write_csv_str(&points); + assert!(csv.starts_with("timestamp,fee_amount,ledger_sequence,is_spike\n")); + } + + #[test] + fn round_trip_csv() { + let points = vec![ + FeePoint { + timestamp: 1000, + fee: 100, + ledger: 1, + is_spike: false, + }, + FeePoint { + timestamp: 2000, + fee: 500, + ledger: 2, + is_spike: true, + }, + ]; + let csv = write_csv_str(&points); + let result = read_csv_str(&csv); + assert_eq!(result.points.len(), 2); + assert_eq!(result.points[1].fee, 500); + assert!(result.points[1].is_spike); + } + + #[test] + fn empty_csv_with_only_header() { + let csv = "timestamp,fee_amount,ledger_sequence,is_spike\n"; + let result = read_csv_str(csv); + assert_eq!(result.points.len(), 0); + assert_eq!(result.skipped, 0); + } +} diff --git a/packages/devkit/src/utilities/json_reader.rs b/packages/devkit/src/utilities/json_reader.rs new file mode 100644 index 0000000..204f63f --- /dev/null +++ b/packages/devkit/src/utilities/json_reader.rs @@ -0,0 +1,235 @@ +//! JSON reader for fee data — supports both file and piped stdin input. +//! +//! Expected format: a JSON array of objects with fields: +//! `timestamp`, `fee_amount`, `ledger_sequence`, `is_spike` +//! +//! Returns descriptive errors with context when parsing fails. + +use std::io::Read; +use std::path::Path; + +use crate::simulation::fee_model::FeePoint; + +/// Error type for JSON fee data parsing. +#[derive(Debug)] +pub struct JsonReadError { + /// Human-readable description of the error. + pub message: String, +} + +impl std::fmt::Display for JsonReadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for JsonReadError {} + +/// Parse a JSON array of fee point objects from a string. +/// +/// Each object must contain: +/// - `timestamp` (u64) +/// - `fee_amount` (u64) +/// - `ledger_sequence` (u64) +/// - `is_spike` (bool) +/// +/// Returns a descriptive error with context if the input is malformed. +pub fn read_json_str(input: &str) -> Result, JsonReadError> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + + // Use serde_json for robust parsing + let value: serde_json::Value = + serde_json::from_str(trimmed).map_err(|e| JsonReadError { + message: format!("JSON parse error: {}", e), + })?; + + let arr = value.as_array().ok_or_else(|| JsonReadError { + message: "expected a JSON array at the top level".to_string(), + })?; + + let mut points = Vec::with_capacity(arr.len()); + for (i, item) in arr.iter().enumerate() { + let point = parse_json_object(item, i)?; + points.push(point); + } + + Ok(points) +} + +/// Read `FeePoint` records from a JSON file. +pub fn read_json_file(path: &Path) -> Result, JsonReadError> { + let content = std::fs::read_to_string(path).map_err(|e| JsonReadError { + message: format!("failed to read file {}: {}", path.display(), e), + })?; + read_json_str(&content) +} + +/// Read `FeePoint` records from stdin (piped input). +pub fn read_json_stdin() -> Result, JsonReadError> { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| JsonReadError { + message: format!("failed to read stdin: {}", e), + })?; + read_json_str(&buf) +} + +/// Parse a single JSON object into a `FeePoint`. +fn parse_json_object(obj: &serde_json::Value, index: usize) -> Result { + let ctx = |field: &str| JsonReadError { + message: format!( + "object at index {}: missing or invalid field \"{}\"", + index, field + ), + }; + + let timestamp = obj + .get("timestamp") + .and_then(|v| v.as_u64()) + .ok_or_else(|| ctx("timestamp"))?; + + let fee = obj + .get("fee_amount") + .and_then(|v| v.as_u64()) + .ok_or_else(|| ctx("fee_amount"))?; + + let ledger = obj + .get("ledger_sequence") + .and_then(|v| v.as_u64()) + .ok_or_else(|| ctx("ledger_sequence"))?; + + let is_spike = obj + .get("is_spike") + .and_then(|v| v.as_bool()) + .ok_or_else(|| ctx("is_spike"))?; + + Ok(FeePoint { + timestamp, + fee, + ledger, + is_spike, + }) +} + +/// Serialize `FeePoint` records to a JSON array string. +pub fn write_json_str(points: &[FeePoint]) -> String { + let items: Vec = points + .iter() + .map(|p| { + format!( + r#"{{"timestamp":{},"fee_amount":{},"ledger_sequence":{},"is_spike":{}}}"#, + p.timestamp, p.fee, p.ledger, p.is_spike + ) + }) + .collect(); + format!("[{}]", items.join(",")) +} + +/// Write `FeePoint` records to a JSON file. +pub fn write_json_file(points: &[FeePoint], path: &Path) -> std::io::Result<()> { + std::fs::write(path, write_json_str(points)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_json() -> &'static str { + r#"[ + {"timestamp":1000,"fee_amount":100,"ledger_sequence":1,"is_spike":false}, + {"timestamp":2000,"fee_amount":200,"ledger_sequence":2,"is_spike":true} + ]"# + } + + #[test] + fn read_valid_json_parses_all_records() { + let points = read_json_str(sample_json()).unwrap(); + assert_eq!(points.len(), 2); + } + + #[test] + fn read_json_first_record_fields() { + let points = read_json_str(sample_json()).unwrap(); + assert_eq!(points[0].timestamp, 1000); + assert_eq!(points[0].fee, 100); + assert_eq!(points[0].ledger, 1); + assert!(!points[0].is_spike); + } + + #[test] + fn read_json_spike_flag() { + let points = read_json_str(sample_json()).unwrap(); + assert!(points[1].is_spike); + } + + #[test] + fn read_empty_array() { + let points = read_json_str("[]").unwrap(); + assert!(points.is_empty()); + } + + #[test] + fn read_empty_string_returns_empty() { + let points = read_json_str("").unwrap(); + assert!(points.is_empty()); + } + + #[test] + fn read_invalid_json_returns_error() { + let err = read_json_str("not json at all").unwrap_err(); + assert!(err.message.contains("JSON parse error")); + } + + #[test] + fn read_non_array_returns_error() { + let err = read_json_str(r#"{"foo":"bar"}"#).unwrap_err(); + assert!(err.message.contains("expected a JSON array")); + } + + #[test] + fn read_missing_field_returns_error() { + let err = read_json_str(r#"[{"timestamp":1000}]"#).unwrap_err(); + assert!(err.message.contains("fee_amount")); + } + + #[test] + fn write_json_produces_array() { + let points = vec![FeePoint { + timestamp: 1000, + fee: 100, + ledger: 1, + is_spike: false, + }]; + let json = write_json_str(&points); + assert!(json.starts_with('[')); + assert!(json.ends_with(']')); + assert!(json.contains("\"timestamp\":1000")); + } + + #[test] + fn round_trip_json() { + let points = vec![ + FeePoint { + timestamp: 1000, + fee: 100, + ledger: 1, + is_spike: false, + }, + FeePoint { + timestamp: 2000, + fee: 500, + ledger: 2, + is_spike: true, + }, + ]; + let json = write_json_str(&points); + let parsed = read_json_str(&json).unwrap(); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[1].fee, 500); + assert!(parsed[1].is_spike); + } +} diff --git a/packages/devkit/src/utilities/mod.rs b/packages/devkit/src/utilities/mod.rs index 0a669d5..5453687 100644 --- a/packages/devkit/src/utilities/mod.rs +++ b/packages/devkit/src/utilities/mod.rs @@ -1,3 +1,5 @@ +pub mod csv_reader; +pub mod json_reader; //! Shared utility helpers used by CLI subcommands. pub mod comparator;