diff --git a/crates/rskim/src/analytics/mod.rs b/crates/rskim/src/analytics/mod.rs index 5bc3127..8304718 100644 --- a/crates/rskim/src/analytics/mod.rs +++ b/crates/rskim/src/analytics/mod.rs @@ -39,6 +39,7 @@ pub(crate) enum CommandType { Test, Build, Git, + Pkg, } impl CommandType { @@ -48,6 +49,7 @@ impl CommandType { CommandType::Test => "test", CommandType::Build => "build", CommandType::Git => "git", + CommandType::Pkg => "pkg", } } } diff --git a/crates/rskim/src/cmd/mod.rs b/crates/rskim/src/cmd/mod.rs index ed5908b..8cb63ae 100644 --- a/crates/rskim/src/cmd/mod.rs +++ b/crates/rskim/src/cmd/mod.rs @@ -15,6 +15,7 @@ mod hooks; mod init; mod integrity; mod learn; +mod pkg; mod rewrite; mod session; mod stats; @@ -38,6 +39,7 @@ pub(crate) const KNOWN_SUBCOMMANDS: &[&str] = &[ "git", "init", "learn", + "pkg", "rewrite", "stats", "test", @@ -284,6 +286,7 @@ pub(crate) fn dispatch(subcommand: &str, args: &[String]) -> anyhow::Result git::run(args), "init" => init::run(args), "learn" => learn::run(args), + "pkg" => pkg::run(args), "rewrite" => rewrite::run(args), "stats" => stats::run(args), "test" => test::run(args), diff --git a/crates/rskim/src/cmd/pkg/cargo.rs b/crates/rskim/src/cmd/pkg/cargo.rs new file mode 100644 index 0000000..37f4c30 --- /dev/null +++ b/crates/rskim/src/cmd/pkg/cargo.rs @@ -0,0 +1,454 @@ +//! Cargo audit parser (#105) +//! +//! Parses `cargo audit` output using three-tier degradation: +//! JSON (`--json` flag) -> block-based text parsing -> passthrough. +//! +//! NOTE: This is a DIFFERENT module from `cmd/build/cargo.rs` which handles +//! `cargo build` and `cargo clippy`. No collision: different parent module paths. + +use std::process::ExitCode; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{PkgOperation, PkgResult}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +// ============================================================================ +// Public entry point +// ============================================================================ + +/// Run `skim pkg cargo [args...]`. +/// +/// Currently only `audit` is supported. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + if args.is_empty() || args.iter().any(|a| matches!(a.as_str(), "--help" | "-h")) { + print_help(); + return Ok(ExitCode::SUCCESS); + } + + // Safe: args.is_empty() is handled above. + let (subcmd, subcmd_args) = args.split_first().expect("already verified non-empty"); + + match subcmd.as_str() { + "audit" => run_audit(subcmd_args, show_stats, json_output), + other => { + let safe = super::sanitize_for_display(other); + eprintln!( + "skim pkg cargo: unknown subcommand '{safe}'\n\ + Available: audit\n\ + Run 'skim pkg cargo --help' for usage" + ); + Ok(ExitCode::FAILURE) + } + } +} + +fn print_help() { + println!("skim pkg cargo [args...]"); + println!(); + println!(" Parse cargo package manager output for AI context windows."); + println!(); + println!("Subcommands:"); + println!(" audit Parse cargo audit output"); + println!(); + println!("Examples:"); + println!(" skim pkg cargo audit"); + println!(" cargo audit --json | skim pkg cargo audit"); +} + +// ============================================================================ +// cargo audit +// ============================================================================ + +fn run_audit(args: &[String], show_stats: bool, json_output: bool) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "cargo", + subcommand: "audit", + env_overrides: &[("CARGO_TERM_COLOR", "never")], + install_hint: "Install cargo-audit via: cargo install cargo-audit", + }, + args, + show_stats, + |cmd_args| { + if json_output && !user_has_flag(cmd_args, &["--json"]) { + cmd_args.push("--json".to_string()); + } + }, + parse_audit, + ) +} + +fn parse_audit(output: &CommandOutput) -> ParseResult { + // Tier 1: JSON + if let Some(result) = try_parse_audit_json(&output.stdout) { + return ParseResult::Full(result); + } + + // Tier 2: Regex + let combined = super::combine_output(output); + if let Some(result) = try_parse_audit_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + // Tier 3: Passthrough + ParseResult::Passthrough(combined.into_owned()) +} + +fn try_parse_audit_json(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout).ok()?; + + let vulns = value.get("vulnerabilities")?; + let found = vulns + .get("found") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !found { + return Some(PkgResult::new( + "cargo".to_string(), + PkgOperation::Audit { + critical: 0, + high: 0, + moderate: 0, + low: 0, + total: 0, + }, + true, + vec![], + )); + } + + let list = vulns + .get("list") + .and_then(|v| v.as_array()) + .map(|v| v.as_slice()) + .unwrap_or_default(); + + let mut critical: usize = 0; + let mut high: usize = 0; + let mut moderate: usize = 0; + let mut low: usize = 0; + let mut details: Vec = Vec::new(); + + for vuln in list { + let (detail, severity) = extract_vuln_detail(vuln); + match severity { + "critical" => critical += 1, + "high" => high += 1, + "moderate" | "medium" => moderate += 1, + "low" => low += 1, + _ => {} + } + details.push(detail); + } + + // Use details.len() instead of summing severity buckets so entries with + // unknown/unrecognised severity are still counted. + let total = details.len(); + + Some(PkgResult::new( + "cargo".to_string(), + PkgOperation::Audit { + critical, + high, + moderate, + low, + total, + }, + true, + details, + )) +} + +/// Extract a single vulnerability entry from cargo audit JSON. +/// Returns `(detail_string, severity_str)`. Missing fields fall back to +/// `"unknown"` / `"?"` so every input produces a result. +fn extract_vuln_detail(vuln: &serde_json::Value) -> (String, &str) { + let advisory = vuln.get("advisory"); + let package = vuln.get("package"); + + let severity = advisory + .and_then(|a| a.get("severity")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let title = advisory + .and_then(|a| a.get("title")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let id = advisory + .and_then(|a| a.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let pkg_name = package + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let pkg_version = package + .and_then(|p| p.get("version")) + .and_then(|v| v.as_str()) + .unwrap_or("?"); + + let detail = format!("{id} {pkg_name}@{pkg_version}: {title} ({severity})"); + (detail, severity) +} + +fn try_parse_audit_regex(text: &str) -> Option { + // Check for clean output first + if text.contains("No vulnerabilities found") { + return Some(PkgResult::new( + "cargo".to_string(), + PkgOperation::Audit { + critical: 0, + high: 0, + moderate: 0, + low: 0, + total: 0, + }, + true, + vec![], + )); + } + + // Block-based parsing: split on blank lines to get individual advisory + // blocks, then extract fields from each block. This keeps fields + // associated with their block, avoiding the misalignment bug of the + // old triple-regex-zip approach (where missing fields in one block + // would shift IDs/titles from later blocks into earlier ones). + let blocks: Vec<&str> = text + .split("\n\n") + .filter(|b| b.contains("Crate:")) + .collect(); + if blocks.is_empty() { + return None; + } + + let mut details: Vec = Vec::new(); + for block in &blocks { + let crate_name = extract_field(block, "Crate:").unwrap_or("?"); + let id = extract_field(block, "ID:").unwrap_or("?"); + let title = extract_field(block, "Title:").unwrap_or("?"); + details.push(format!("{id} {crate_name}: {title}")); + } + + let total = details.len(); + + // cargo audit text doesn't reliably include severity in text mode, + // so count everything as moderate + Some(PkgResult::new( + "cargo".to_string(), + PkgOperation::Audit { + critical: 0, + high: 0, + moderate: total, + low: 0, + total, + }, + true, + details, + )) +} + +/// Extract a field value from a text block by line prefix. +fn extract_field<'a>(block: &'a str, prefix: &str) -> Option<&'a str> { + block + .lines() + .find_map(|line| line.trim().strip_prefix(prefix).map(|v| v.trim())) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_path(name: &str) -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/pkg"); + path.push(name); + path + } + + fn load_fixture(name: &str) -> String { + std::fs::read_to_string(fixture_path(name)) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + // ======================================================================== + // cargo audit: JSON + // ======================================================================== + + #[test] + fn test_audit_json_parse() { + let input = load_fixture("cargo_audit.json"); + let result = try_parse_audit_json(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG AUDIT | cargo")); + assert!(display.contains("critical: 1")); + assert!(display.contains("high: 1")); + assert!(display.contains("total: 2")); + assert!(display.contains("RUSTSEC-2024-0001")); + assert!(display.contains("buffer-utils")); + } + + #[test] + fn test_audit_json_clean() { + let input = load_fixture("cargo_audit_clean.json"); + let result = try_parse_audit_json(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("total: 0")); + } + + // ======================================================================== + // cargo audit: Regex + // ======================================================================== + + #[test] + fn test_audit_regex_no_vulns() { + let text = "No vulnerabilities found!\n250 dependencies checked"; + let result = try_parse_audit_regex(text); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("total: 0")); + } + + #[test] + fn test_audit_regex_with_blocks() { + let text = "\ +Crate: buffer-utils +Version: 0.3.1 +Title: Buffer overflow in buffer-utils +ID: RUSTSEC-2024-0001 + +Crate: unsafe-lib +Version: 1.0.0 +Title: Memory safety issue +ID: RUSTSEC-2024-0002 +"; + let result = try_parse_audit_regex(text); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("total: 2")); + assert!(display.contains("RUSTSEC-2024-0001")); + } + + #[test] + fn test_audit_regex_missing_id_field() { + // First block is MISSING its ID, second block has one. + // Triple-regex misaligns: the ID from block 2 gets assigned to block 1 + // because regex matches are zipped by index, not by block. + let text = "\ +Crate: first-crate +Version: 0.1.0 +Title: Some vulnerability + +Crate: second-crate +Version: 0.2.0 +Title: Another vulnerability +ID: RUSTSEC-2024-0099 +"; + let result = try_parse_audit_regex(text); + assert!( + result.is_some(), + "Should still parse blocks with missing fields" + ); + let result = result.unwrap(); + let display = format!("{result}"); + // Should have 2 vulnerabilities + assert!( + display.contains("total: 2"), + "Expected 2 vulns, got: {display}" + ); + // The ID must be associated with second-crate, NOT first-crate. + // Triple-regex would misalign: RUSTSEC-2024-0099 would appear next to first-crate. + assert!( + display.contains("RUSTSEC-2024-0099 second-crate"), + "ID should be on second-crate, not first-crate. Got: {display}" + ); + } + + #[test] + fn test_audit_regex_reordered_fields() { + // Fields appear in non-standard order (ID before Crate) + let text = "\ +ID: RUSTSEC-2024-0001 +Crate: buffer-utils +Title: Buffer overflow +Version: 0.3.1 +"; + let result = try_parse_audit_regex(text); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("total: 1")); + assert!(display.contains("RUSTSEC-2024-0001")); + assert!(display.contains("buffer-utils")); + } + + // ======================================================================== + // Three-tier integration + // ======================================================================== + + #[test] + fn test_audit_json_produces_full() { + let input = load_fixture("cargo_audit.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let result = parse_audit(&output); + assert!( + result.is_full(), + "Expected Full, got {}", + result.tier_name() + ); + } + + #[test] + fn test_audit_text_produces_degraded() { + let text = + "Crate: buffer-utils\nVersion: 0.3.1\nTitle: overflow\nID: RUSTSEC-2024-0001"; + let output = CommandOutput { + stdout: text.to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_audit(&output); + assert!( + result.is_degraded(), + "Expected Degraded, got {}", + result.tier_name() + ); + } + + #[test] + fn test_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "completely unparseable output".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_audit(&output); + assert!( + result.is_passthrough(), + "Expected Passthrough, got {}", + result.tier_name() + ); + } +} diff --git a/crates/rskim/src/cmd/pkg/mod.rs b/crates/rskim/src/cmd/pkg/mod.rs new file mode 100644 index 0000000..6c10caf --- /dev/null +++ b/crates/rskim/src/cmd/pkg/mod.rs @@ -0,0 +1,259 @@ +//! Package manager output compression (#105) +//! +//! Routes `skim pkg [subcmd] [args...]` to the appropriate package +//! manager parser. Currently supported tools: `npm`, `pnpm`, `pip`, `cargo`. + +mod cargo; +mod npm; +mod pip; +mod pnpm; + +use std::borrow::Cow; +use std::io::IsTerminal; +use std::process::ExitCode; + +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +/// Known package manager tools that `skim pkg` can dispatch to. +const KNOWN_TOOLS: &[&str] = &["npm", "pnpm", "pip", "cargo"]; + +/// Sanitize user input for safe display in error messages. +/// +/// Filters to printable ASCII characters to prevent terminal escape +/// injection attacks. Non-printable and non-ASCII bytes are replaced +/// with `?`, and the string is truncated to 64 characters. +pub(super) fn sanitize_for_display(input: &str) -> String { + input + .chars() + .take(64) + .map(|c| { + if c.is_ascii_graphic() || c == ' ' { + c + } else { + '?' + } + }) + .collect() +} + +/// Entry point for `skim pkg [subcmd] [args...]`. +/// +/// If no tool is specified or `--help` / `-h` is passed, prints usage +/// and exits. Otherwise dispatches to the tool-specific handler. +pub(crate) fn run(args: &[String]) -> anyhow::Result { + if args.is_empty() || args.iter().any(|a| matches!(a.as_str(), "--help" | "-h")) { + print_help(); + return Ok(ExitCode::SUCCESS); + } + + let (filtered_args, show_stats) = crate::cmd::extract_show_stats(args); + let (json_args, json_output) = extract_json_flag(&filtered_args); + + let Some((tool_name, tool_args)) = json_args.split_first() else { + print_help(); + return Ok(ExitCode::SUCCESS); + }; + + let tool = tool_name.as_str(); + + match tool { + "npm" => npm::run(tool_args, show_stats, json_output), + "pnpm" => pnpm::run(tool_args, show_stats, json_output), + "pip" => pip::run(tool_args, show_stats, json_output), + "cargo" => cargo::run(tool_args, show_stats, json_output), + _ => { + let safe_tool = sanitize_for_display(tool); + eprintln!( + "skim pkg: unknown tool '{safe_tool}'\n\ + Available tools: {}\n\ + Run 'skim pkg --help' for usage information", + KNOWN_TOOLS.join(", ") + ); + Ok(ExitCode::FAILURE) + } + } +} + +/// Extract the `--json` flag from args, returning filtered args and whether +/// the flag was present. +fn extract_json_flag(args: &[String]) -> (Vec, bool) { + let json_output = args.iter().any(|a| a == "--json"); + let filtered: Vec = args + .iter() + .filter(|a| a.as_str() != "--json") + .cloned() + .collect(); + (filtered, json_output) +} + +/// Merge stdout and stderr into a single string. +/// +/// Returns a `Cow::Borrowed` reference to stdout when stderr is empty +/// (zero-copy fast path), or a `Cow::Owned` concatenation otherwise. +pub(super) fn combine_output(output: &CommandOutput) -> Cow<'_, str> { + if output.stderr.is_empty() { + Cow::Borrowed(&output.stdout) + } else { + Cow::Owned(format!("{}\n{}", output.stdout, output.stderr)) + } +} + +/// Configuration for a package subcommand invocation. +/// +/// Groups the constant parts of a `run_parsed_command_with_mode` call so +/// that each `run_*` function only specifies what differs (flag injection +/// and parse function). +pub(super) struct PkgSubcommandConfig<'a> { + pub program: &'a str, + pub subcommand: &'a str, + pub env_overrides: &'a [(&'a str, &'a str)], + pub install_hint: &'a str, +} + +/// Shared helper that eliminates the repetitive `run_*` boilerplate +/// across all package manager subcommand parsers. +/// +/// Builds the argument list, applies flag injection, detects stdin, +/// and delegates to `run_parsed_command_with_mode`. +pub(super) fn run_pkg_subcommand( + config: PkgSubcommandConfig<'_>, + user_args: &[String], + show_stats: bool, + inject_flags: impl FnOnce(&mut Vec), + parse_fn: impl FnOnce(&CommandOutput) -> ParseResult, +) -> anyhow::Result +where + T: AsRef, +{ + let mut cmd_args: Vec = vec![config.subcommand.to_string()]; + cmd_args.extend(user_args.iter().cloned()); + inject_flags(&mut cmd_args); + + let use_stdin = !std::io::stdin().is_terminal() && user_args.is_empty(); + + crate::cmd::run_parsed_command_with_mode( + crate::cmd::ParsedCommandConfig { + program: config.program, + args: &cmd_args, + env_overrides: config.env_overrides, + install_hint: config.install_hint, + use_stdin, + show_stats, + command_type: crate::analytics::CommandType::Pkg, + }, + |output, _args| parse_fn(output), + ) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // ======================================================================== + // sanitize_for_display + // ======================================================================== + + #[test] + fn test_sanitize_ascii_passthrough() { + assert_eq!(sanitize_for_display("hello-world"), "hello-world"); + } + + #[test] + fn test_sanitize_strips_escape_sequences() { + // ANSI escape: \x1b[31m (set red color) + let malicious = "\x1b[31mevil\x1b[0m"; + let sanitized = sanitize_for_display(malicious); + assert!(!sanitized.contains('\x1b')); + assert_eq!(sanitized, "?[31mevil?[0m"); + } + + #[test] + fn test_sanitize_truncates_long_input() { + let long_input = "a".repeat(200); + let sanitized = sanitize_for_display(&long_input); + assert_eq!(sanitized.len(), 64); + } + + #[test] + fn test_sanitize_replaces_control_chars() { + let input = "hello\0world\ttab\nnewline"; + let sanitized = sanitize_for_display(input); + assert_eq!(sanitized, "hello?world?tab?newline"); + } + + // ======================================================================== + // extract_json_flag + // ======================================================================== + + #[test] + fn test_extract_json_flag_present() { + let args: Vec = vec!["npm".into(), "--json".into(), "install".into()]; + let (filtered, found) = extract_json_flag(&args); + assert!(found); + assert_eq!(filtered, vec!["npm".to_string(), "install".to_string()]); + } + + #[test] + fn test_extract_json_flag_absent() { + let args: Vec = vec!["npm".into(), "install".into()]; + let (filtered, found) = extract_json_flag(&args); + assert!(!found); + assert_eq!(filtered, vec!["npm".to_string(), "install".to_string()]); + } + + // ======================================================================== + // combine_output + // ======================================================================== + + #[test] + fn test_combine_output_empty_stderr() { + let output = crate::runner::CommandOutput { + stdout: "hello".to_string(), + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let combined = combine_output(&output); + assert!(matches!(combined, Cow::Borrowed(_))); + assert_eq!(combined.as_ref(), "hello"); + } + + #[test] + fn test_combine_output_with_stderr() { + let output = crate::runner::CommandOutput { + stdout: "hello".to_string(), + stderr: "warning".to_string(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let combined = combine_output(&output); + assert!(matches!(combined, Cow::Owned(_))); + assert_eq!(combined.as_ref(), "hello\nwarning"); + } +} + +fn print_help() { + println!("skim pkg [subcmd] [args...]"); + println!(); + println!(" Parse package manager output for AI context windows."); + println!(); + println!("Available tools:"); + for tool in KNOWN_TOOLS { + println!(" {tool}"); + } + println!(); + println!("Examples:"); + println!(" skim pkg npm install Run npm install"); + println!(" skim pkg npm audit Run npm audit"); + println!(" skim pkg npm outdated Run npm outdated"); + println!(" skim pkg pip install flask Run pip install flask"); + println!(" skim pkg pip check Run pip check"); + println!(" skim pkg cargo audit Run cargo audit"); + println!(" skim pkg pnpm install Run pnpm install"); + println!(" npm install 2>&1 | skim pkg npm install Pipe npm output"); +} diff --git a/crates/rskim/src/cmd/pkg/npm/audit.rs b/crates/rskim/src/cmd/pkg/npm/audit.rs new file mode 100644 index 0000000..08832e7 --- /dev/null +++ b/crates/rskim/src/cmd/pkg/npm/audit.rs @@ -0,0 +1,260 @@ +use std::process::ExitCode; +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{PkgOperation, PkgResult}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::combine_output; + +// ============================================================================ +// Static regex patterns +// ============================================================================ + +static RE_NPM_VULNS: LazyLock = + LazyLock::new(|| Regex::new(r"(\d+)\s+vulnerabilit(?:y|ies)\s*\(([^)]+)\)").unwrap()); +static RE_NPM_VULN_COUNT: LazyLock = + LazyLock::new(|| Regex::new(r"(\d+)\s+(critical|high|moderate|low|info)").unwrap()); + +pub(super) fn run_audit( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "npm", + subcommand: "audit", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install Node.js from https://nodejs.org", + }, + args, + show_stats, + |cmd_args| { + if json_output && !user_has_flag(cmd_args, &["--json"]) { + cmd_args.push("--json".to_string()); + } + }, + parse_audit, + ) +} + +fn parse_audit(output: &CommandOutput) -> ParseResult { + // Tier 1: JSON + if let Some(result) = try_parse_audit_json(&output.stdout) { + return ParseResult::Full(result); + } + + // Tier 2: Regex + let combined = combine_output(output); + if let Some(result) = try_parse_audit_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + // Tier 3: Passthrough + ParseResult::Passthrough(combined.into_owned()) +} + +fn try_parse_audit_json(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout).ok()?; + + // npm 7+ audit format + let vulns = value.get("vulnerabilities")?.as_object()?; + + let mut critical: usize = 0; + let mut high: usize = 0; + let mut moderate: usize = 0; + let mut low: usize = 0; + let mut details: Vec = Vec::new(); + + for (name, vuln) in vulns { + let severity = vuln + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + match severity { + "critical" => critical += 1, + "high" => high += 1, + "moderate" => moderate += 1, + "low" => low += 1, + _ => {} + } + + // Extract advisory title from via array. Entries can be either + // objects (advisories with a `title` field) or plain strings + // (transitive dependency names). + let title = vuln + .get("via") + .and_then(|v| v.as_array()) + .and_then(|arr| { + arr.iter() + .find_map(|entry| { + // Object entry: { "title": "...", ... } + entry + .get("title") + .and_then(|t| t.as_str()) + .map(String::from) + }) + .or_else(|| { + // String entry: transitive dep name (e.g. "lodash") + arr.first() + .and_then(|v| v.as_str()) + .map(|s| format!("via {s}")) + }) + }) + .unwrap_or_else(|| "unknown".to_string()); + + details.push(format!("{name}: {title} ({severity})")); + } + + // Use details.len() instead of summing severity buckets so entries with + // unknown/unrecognised severity are still counted. + let total = details.len(); + + Some(PkgResult::new( + "npm".to_string(), + PkgOperation::Audit { + critical, + high, + moderate, + low, + total, + }, + true, + details, + )) +} + +fn try_parse_audit_regex(text: &str) -> Option { + // Match "N vulnerabilities (N critical, N high, N moderate, N low)" + if let Some(caps) = RE_NPM_VULNS.captures(text) { + let total = caps[1].parse::().unwrap_or(0); + let breakdown = &caps[2]; + + let mut critical: usize = 0; + let mut high: usize = 0; + let mut moderate: usize = 0; + let mut low: usize = 0; + + for cap in RE_NPM_VULN_COUNT.captures_iter(breakdown) { + let count = cap[1].parse::().unwrap_or(0); + match &cap[2] { + "critical" => critical = count, + "high" => high = count, + "moderate" => moderate = count, + "low" => low = count, + _ => {} + } + } + + return Some(PkgResult::new( + "npm".to_string(), + PkgOperation::Audit { + critical, + high, + moderate, + low, + total, + }, + true, + vec![], + )); + } + + // Match "found 0 vulnerabilities" + if text.contains("found 0 vulnerabilities") || text.contains("0 vulnerabilities") { + return Some(PkgResult::new( + "npm".to_string(), + PkgOperation::Audit { + critical: 0, + high: 0, + moderate: 0, + low: 0, + total: 0, + }, + true, + vec![], + )); + } + + None +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_path(name: &str) -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/pkg"); + path.push(name); + path + } + + fn load_fixture(name: &str) -> String { + std::fs::read_to_string(fixture_path(name)) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + // ======================================================================== + // npm audit: JSON + // ======================================================================== + + #[test] + fn test_audit_json_parse() { + let input = load_fixture("npm_audit.json"); + let result = try_parse_audit_json(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG AUDIT | npm")); + assert!(display.contains("critical: 1")); + assert!(display.contains("high: 1")); + assert!(display.contains("moderate: 1")); + assert!(display.contains("total: 3")); + assert!(display.contains("lodash: Prototype Pollution (high)")); + } + + #[test] + fn test_audit_json_clean() { + let input = load_fixture("npm_audit_clean.json"); + let result = try_parse_audit_json(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("total: 0")); + } + + // ======================================================================== + // npm audit: Regex + // ======================================================================== + + #[test] + fn test_audit_regex_vulns() { + let text = "3 vulnerabilities (1 critical, 1 high, 1 moderate)"; + let result = try_parse_audit_regex(text); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("total: 3")); + assert!(display.contains("critical: 1")); + } + + #[test] + fn test_audit_regex_clean() { + let text = "found 0 vulnerabilities"; + let result = try_parse_audit_regex(text); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("total: 0")); + } +} diff --git a/crates/rskim/src/cmd/pkg/npm/install.rs b/crates/rskim/src/cmd/pkg/npm/install.rs new file mode 100644 index 0000000..7911e3a --- /dev/null +++ b/crates/rskim/src/cmd/pkg/npm/install.rs @@ -0,0 +1,248 @@ +use std::process::ExitCode; +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{PkgOperation, PkgResult}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::combine_output; + +// ============================================================================ +// Static regex patterns +// ============================================================================ + +static RE_NPM_ADDED: LazyLock = + LazyLock::new(|| Regex::new(r"added\s+(\d+)\s+packages?").unwrap()); +static RE_NPM_REMOVED: LazyLock = + LazyLock::new(|| Regex::new(r"removed\s+(\d+)\s+packages?").unwrap()); +static RE_NPM_CHANGED: LazyLock = + LazyLock::new(|| Regex::new(r"changed\s+(\d+)\s+packages?").unwrap()); +static RE_NPM_FOUND_VULNS: LazyLock = + LazyLock::new(|| Regex::new(r"found\s+(\d+)\s+vulnerabilit").unwrap()); + +pub(super) fn run_install( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "npm", + subcommand: "install", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install Node.js from https://nodejs.org", + }, + args, + show_stats, + |cmd_args| { + if json_output && !user_has_flag(cmd_args, &["--json"]) { + cmd_args.push("--json".to_string()); + } + }, + parse_install, + ) +} + +fn parse_install(output: &CommandOutput) -> ParseResult { + // Tier 1: JSON + if let Some(result) = try_parse_install_json(&output.stdout) { + return ParseResult::Full(result); + } + + // Tier 2: Regex + let combined = combine_output(output); + if let Some(result) = try_parse_install_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + // Tier 3: Passthrough + ParseResult::Passthrough(combined.into_owned()) +} + +/// Extract a `usize` from a JSON object field, returning 0 on missing/invalid. +fn json_usize(value: &serde_json::Value, key: &str) -> usize { + value + .get(key) + .and_then(|v| v.as_u64()) + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(0) +} + +fn try_parse_install_json(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout).ok()?; + + let added = json_usize(&value, "added"); + let removed = json_usize(&value, "removed"); + let changed = json_usize(&value, "changed"); + + // Count audit warnings from the embedded audit report + let warnings = value + .get("audit") + .and_then(|a| a.get("vulnerabilities")) + .and_then(|v| v.as_object()) + .map(|obj| obj.len()) + .unwrap_or(0); + + Some(PkgResult::new( + "npm".to_string(), + PkgOperation::Install { + added, + removed, + changed, + warnings, + }, + true, + vec![], + )) +} + +fn try_parse_install_regex(text: &str) -> Option { + let added = RE_NPM_ADDED + .captures(text) + .and_then(|c| c[1].parse::().ok()) + .unwrap_or(0); + let removed = RE_NPM_REMOVED + .captures(text) + .and_then(|c| c[1].parse::().ok()) + .unwrap_or(0); + let changed = RE_NPM_CHANGED + .captures(text) + .and_then(|c| c[1].parse::().ok()) + .unwrap_or(0); + + // Only succeed if we found at least one count + if added == 0 && removed == 0 && changed == 0 { + // Check for "found 0 vulnerabilities" as a sign this is npm output + if !RE_NPM_FOUND_VULNS.is_match(text) && !text.contains("up to date") { + return None; + } + } + + let warnings = RE_NPM_FOUND_VULNS + .captures(text) + .and_then(|c| c[1].parse::().ok()) + .unwrap_or(0); + + Some(PkgResult::new( + "npm".to_string(), + PkgOperation::Install { + added, + removed, + changed, + warnings, + }, + true, + vec![], + )) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_path(name: &str) -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/pkg"); + path.push(name); + path + } + + fn load_fixture(name: &str) -> String { + std::fs::read_to_string(fixture_path(name)) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + // ======================================================================== + // npm install: JSON + // ======================================================================== + + #[test] + fn test_install_json_parse() { + let input = load_fixture("npm_install.json"); + let result = try_parse_install_json(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG INSTALL | npm")); + assert!(display.contains("added: 127")); + assert!(display.contains("removed: 3")); + assert!(display.contains("changed: 14")); + } + + // ======================================================================== + // npm install: Regex + // ======================================================================== + + #[test] + fn test_install_regex_parse() { + let input = load_fixture("npm_install_text.txt"); + let result = try_parse_install_regex(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("added: 127")); + assert!(display.contains("removed: 3")); + assert!(display.contains("changed: 14")); + } + + // ======================================================================== + // Three-tier integration + // ======================================================================== + + #[test] + fn test_install_json_produces_full() { + let input = load_fixture("npm_install.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let result = parse_install(&output); + assert!( + result.is_full(), + "Expected Full, got {}", + result.tier_name() + ); + } + + #[test] + fn test_install_text_produces_degraded() { + let input = load_fixture("npm_install_text.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let result = parse_install(&output); + assert!( + result.is_degraded(), + "Expected Degraded, got {}", + result.tier_name() + ); + } + + #[test] + fn test_install_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "completely unparseable output".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_install(&output); + assert!( + result.is_passthrough(), + "Expected Passthrough, got {}", + result.tier_name() + ); + } +} diff --git a/crates/rskim/src/cmd/pkg/npm/ls.rs b/crates/rskim/src/cmd/pkg/npm/ls.rs new file mode 100644 index 0000000..a452f5f --- /dev/null +++ b/crates/rskim/src/cmd/pkg/npm/ls.rs @@ -0,0 +1,185 @@ +use std::process::ExitCode; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{PkgOperation, PkgResult}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::combine_output; + +pub(super) fn run_ls( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "npm", + subcommand: "ls", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install Node.js from https://nodejs.org", + }, + args, + show_stats, + |cmd_args| { + if json_output { + if !user_has_flag(cmd_args, &["--json"]) { + cmd_args.push("--json".to_string()); + } + if !user_has_flag(cmd_args, &["--depth"]) { + cmd_args.push("--depth=0".to_string()); + } + } + }, + parse_ls, + ) +} + +fn parse_ls(output: &CommandOutput) -> ParseResult { + // Tier 1: JSON + if let Some(result) = try_parse_ls_json(&output.stdout) { + return ParseResult::Full(result); + } + + // Tier 2: Regex (count package lines) + let combined = combine_output(output); + if let Some(result) = try_parse_ls_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + // Tier 3: Passthrough + ParseResult::Passthrough(combined.into_owned()) +} + +fn try_parse_ls_json(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout).ok()?; + let deps = value.get("dependencies")?.as_object()?; + + let total = deps.len(); + let mut flagged: usize = 0; + let mut details: Vec = Vec::new(); + + for (name, dep) in deps { + let version = dep.get("version").and_then(|v| v.as_str()).unwrap_or("?"); + + if let Some(problems) = dep.get("problems").and_then(|v| v.as_array()) { + if !problems.is_empty() { + flagged += 1; + for problem in problems { + if let Some(msg) = problem.as_str() { + details.push(format!("{name}@{version}: {msg}")); + } + } + } + } + } + + Some(PkgResult::new( + "npm".to_string(), + PkgOperation::List { total, flagged }, + true, + details, + )) +} + +fn try_parse_ls_regex(text: &str) -> Option { + // npm ls text output is a tree: lines starting with non-empty package refs + let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.is_empty() { + return None; + } + + // First line is project name, rest are dependencies + let total = lines.len().saturating_sub(1); + if total == 0 { + return None; + } + + // Count lines with "invalid" or "UNMET" markers + let flagged = lines + .iter() + .filter(|l| l.contains("invalid") || l.contains("UNMET")) + .count(); + + Some(PkgResult::new( + "npm".to_string(), + PkgOperation::List { total, flagged }, + true, + vec![], + )) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_path(name: &str) -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/pkg"); + path.push(name); + path + } + + fn load_fixture(name: &str) -> String { + std::fs::read_to_string(fixture_path(name)) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + // ======================================================================== + // npm ls: JSON + // ======================================================================== + + #[test] + fn test_ls_json_parse() { + let input = load_fixture("npm_ls.json"); + let result = try_parse_ls_json(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG LIST | npm")); + assert!(display.contains("4 total")); + assert!(display.contains("1 flagged")); + assert!(display.contains("debug@4.3.4")); + } + + // ======================================================================== + // Three-tier integration + // ======================================================================== + + #[test] + fn test_ls_json_produces_full() { + let input = load_fixture("npm_ls.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let result = parse_ls(&output); + assert!( + result.is_full(), + "Expected Full, got {}", + result.tier_name() + ); + } + + #[test] + fn test_ls_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "completely unparseable output".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_ls(&output); + assert!( + result.is_passthrough(), + "Expected Passthrough, got {}", + result.tier_name() + ); + } +} diff --git a/crates/rskim/src/cmd/pkg/npm/mod.rs b/crates/rskim/src/cmd/pkg/npm/mod.rs new file mode 100644 index 0000000..697ed62 --- /dev/null +++ b/crates/rskim/src/cmd/pkg/npm/mod.rs @@ -0,0 +1,69 @@ +//! npm package manager parser (#105) +//! +//! Parses `npm install`, `npm audit`, `npm outdated`, and `npm ls` output +//! using three-tier degradation: JSON -> regex -> passthrough. +//! +//! npm 7+ JSON schemas are supported. If JSON fails to parse, tier 2 regex +//! is attempted on plain text output. + +mod audit; +mod install; +mod ls; +mod outdated; + +use std::process::ExitCode; + +// Re-exported from parent so submodules can access via `super::`. +use super::{combine_output, run_pkg_subcommand, PkgSubcommandConfig}; + +/// Run `skim pkg npm [args...]`. +/// +/// Sub-dispatches to install, audit, outdated, or ls based on the first arg. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + if args.is_empty() || args.iter().any(|a| matches!(a.as_str(), "--help" | "-h")) { + print_help(); + return Ok(ExitCode::SUCCESS); + } + + // Safe: args.is_empty() is handled above. + let (subcmd, subcmd_args) = args.split_first().expect("already verified non-empty"); + + match subcmd.as_str() { + "install" | "i" | "ci" => install::run_install(subcmd_args, show_stats, json_output), + "audit" => audit::run_audit(subcmd_args, show_stats, json_output), + "outdated" => outdated::run_outdated(subcmd_args, show_stats, json_output), + "ls" | "list" => ls::run_ls(subcmd_args, show_stats, json_output), + other => { + let safe = super::sanitize_for_display(other); + eprintln!( + "skim pkg npm: unknown subcommand '{safe}'\n\ + Available: install, audit, outdated, ls\n\ + Run 'skim pkg npm --help' for usage" + ); + Ok(ExitCode::FAILURE) + } + } +} + +fn print_help() { + println!("skim pkg npm [args...]"); + println!(); + println!(" Parse npm output for AI context windows."); + println!(); + println!("Subcommands:"); + println!(" install Parse npm install output"); + println!(" audit Parse npm audit output"); + println!(" outdated Parse npm outdated output"); + println!(" ls Parse npm ls output"); + println!(); + println!("Examples:"); + println!(" skim pkg npm install"); + println!(" skim pkg npm audit"); + println!(" skim pkg npm outdated"); + println!(" skim pkg npm ls"); + println!(" npm install 2>&1 | skim pkg npm install"); +} diff --git a/crates/rskim/src/cmd/pkg/npm/outdated.rs b/crates/rskim/src/cmd/pkg/npm/outdated.rs new file mode 100644 index 0000000..cd6bb93 --- /dev/null +++ b/crates/rskim/src/cmd/pkg/npm/outdated.rs @@ -0,0 +1,192 @@ +use std::process::ExitCode; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{PkgOperation, PkgResult}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::combine_output; + +pub(super) fn run_outdated( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "npm", + subcommand: "outdated", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install Node.js from https://nodejs.org", + }, + args, + show_stats, + |cmd_args| { + if json_output && !user_has_flag(cmd_args, &["--json"]) { + cmd_args.push("--json".to_string()); + } + }, + parse_outdated, + ) +} + +fn parse_outdated(output: &CommandOutput) -> ParseResult { + // Tier 1: JSON + if let Some(result) = try_parse_outdated_json(&output.stdout) { + return ParseResult::Full(result); + } + + // Tier 2: Regex (count non-header table lines) + let combined = combine_output(output); + if let Some(result) = try_parse_outdated_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + // Tier 3: Passthrough + ParseResult::Passthrough(combined.into_owned()) +} + +fn try_parse_outdated_json(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout).ok()?; + let obj = value.as_object()?; + + // Empty object = nothing outdated + if obj.is_empty() { + return Some(PkgResult::new( + "npm".to_string(), + PkgOperation::Outdated { count: 0 }, + true, + vec![], + )); + } + + let mut details: Vec = Vec::new(); + + for (name, pkg) in obj { + let current = pkg.get("current").and_then(|v| v.as_str()).unwrap_or("?"); + let latest = pkg.get("latest").and_then(|v| v.as_str()).unwrap_or("?"); + details.push(format!("{name} {current} -> {latest}")); + } + + let count = details.len(); + Some(PkgResult::new( + "npm".to_string(), + PkgOperation::Outdated { count }, + true, + details, + )) +} + +fn try_parse_outdated_regex(text: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return None; + } + + // npm outdated text output has a header line: "Package Current Wanted Latest Location" + let has_header = lines + .first() + .is_some_and(|l| l.contains("Package") && l.contains("Current")); + + if !has_header { + return None; + } + + let details: Vec = lines + .iter() + .skip(1) + .filter(|l| !l.trim().is_empty()) + .map(|l| l.trim().to_string()) + .collect(); + + Some(PkgResult::new( + "npm".to_string(), + PkgOperation::Outdated { + count: details.len(), + }, + true, + details, + )) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_path(name: &str) -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/pkg"); + path.push(name); + path + } + + fn load_fixture(name: &str) -> String { + std::fs::read_to_string(fixture_path(name)) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + // ======================================================================== + // npm outdated: JSON + // ======================================================================== + + #[test] + fn test_outdated_json_parse() { + let input = load_fixture("npm_outdated.json"); + let result = try_parse_outdated_json(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG OUTDATED | npm | 3 packages")); + assert!(display.contains("lodash 4.17.20 -> 4.17.21")); + } + + #[test] + fn test_outdated_json_empty() { + let result = try_parse_outdated_json("{}"); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("0 packages")); + } + + // ======================================================================== + // Three-tier integration + // ======================================================================== + + #[test] + fn test_outdated_json_produces_full() { + let input = load_fixture("npm_outdated.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let result = parse_outdated(&output); + assert!( + result.is_full(), + "Expected Full, got {}", + result.tier_name() + ); + } + + #[test] + fn test_outdated_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "completely unparseable output".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_outdated(&output); + assert!( + result.is_passthrough(), + "Expected Passthrough, got {}", + result.tier_name() + ); + } +} diff --git a/crates/rskim/src/cmd/pkg/pip.rs b/crates/rskim/src/cmd/pkg/pip.rs new file mode 100644 index 0000000..5826e9d --- /dev/null +++ b/crates/rskim/src/cmd/pkg/pip.rs @@ -0,0 +1,456 @@ +//! pip package manager parser (#105) +//! +//! Parses `pip install`, `pip check`, and `pip list --outdated` output. +//! pip install and check are text-only (no JSON mode), so they start at +//! regex tier. pip list supports `--outdated --format=json`. + +use std::process::ExitCode; +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{PkgOperation, PkgResult}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +// ============================================================================ +// Static regex patterns +// ============================================================================ + +static RE_PIP_INSTALLED: LazyLock = + LazyLock::new(|| Regex::new(r"Successfully installed\s+(.+)").unwrap()); +static RE_PIP_WARNING: LazyLock = LazyLock::new(|| Regex::new(r"(?m)^WARNING:").unwrap()); +static RE_PIP_REQUIREMENT: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^\S+\s+\S+\s+has\s+requirement\s+").unwrap()); + +// ============================================================================ +// Public entry point +// ============================================================================ + +/// Run `skim pkg pip [args...]`. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + if args.is_empty() || args.iter().any(|a| matches!(a.as_str(), "--help" | "-h")) { + print_help(); + return Ok(ExitCode::SUCCESS); + } + + // Safe: args.is_empty() is handled above. + let (subcmd, subcmd_args) = args.split_first().expect("already verified non-empty"); + + match subcmd.as_str() { + "install" => run_install(subcmd_args, show_stats, json_output), + "check" => run_check(subcmd_args, show_stats, json_output), + "list" => run_list(subcmd_args, show_stats, json_output), + other => { + let safe = super::sanitize_for_display(other); + eprintln!( + "skim pkg pip: unknown subcommand '{safe}'\n\ + Available: install, check, list\n\ + Run 'skim pkg pip --help' for usage" + ); + Ok(ExitCode::FAILURE) + } + } +} + +fn print_help() { + println!("skim pkg pip [args...]"); + println!(); + println!(" Parse pip output for AI context windows."); + println!(); + println!("Subcommands:"); + println!(" install Parse pip install output"); + println!(" check Parse pip check output"); + println!(" list Parse pip list --outdated output"); + println!(); + println!("Examples:"); + println!(" skim pkg pip install flask"); + println!(" skim pkg pip check"); + println!(" skim pkg pip list"); + println!(" pip install flask 2>&1 | skim pkg pip install"); +} + +// ============================================================================ +// pip install (text-only, regex tier 1) +// ============================================================================ + +fn run_install(args: &[String], show_stats: bool, _json_output: bool) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "pip", + subcommand: "install", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install Python from https://python.org", + }, + args, + show_stats, + |_cmd_args| {}, + parse_install, + ) +} + +fn parse_install(output: &CommandOutput) -> ParseResult { + let combined = super::combine_output(output); + + // Tier 1: Regex (pip install has no JSON mode) + if let Some(result) = try_parse_install_regex(&combined) { + return ParseResult::Full(result); + } + + // Tier 2: Passthrough + ParseResult::Passthrough(combined.into_owned()) +} + +fn try_parse_install_regex(text: &str) -> Option { + // Match "Successfully installed pkg1-1.0 pkg2-2.0" + let added = if let Some(caps) = RE_PIP_INSTALLED.captures(text) { + caps[1].split_whitespace().count() + } else if text.contains("already satisfied") { + 0 + } else { + return None; + }; + + let warnings = RE_PIP_WARNING.find_iter(text).count(); + + Some(PkgResult::new( + "pip".to_string(), + PkgOperation::Install { + added, + removed: 0, + changed: 0, + warnings, + }, + true, + vec![], + )) +} + +// ============================================================================ +// pip check (text-only, regex) +// ============================================================================ + +fn run_check(args: &[String], show_stats: bool, _json_output: bool) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "pip", + subcommand: "check", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install Python from https://python.org", + }, + args, + show_stats, + |_cmd_args| {}, + parse_check, + ) +} + +fn parse_check(output: &CommandOutput) -> ParseResult { + let combined = super::combine_output(output); + + // Tier 1: Regex + if let Some(result) = try_parse_check_regex(&combined) { + return ParseResult::Full(result); + } + + // Tier 2: Passthrough + ParseResult::Passthrough(combined.into_owned()) +} + +fn try_parse_check_regex(text: &str) -> Option { + if text.contains("No broken requirements found") { + return Some(PkgResult::new( + "pip".to_string(), + PkgOperation::Check { issues: 0 }, + true, + vec![], + )); + } + + // Count lines matching the structured requirement pattern first, + // then fall back to looser "has requirement" / "which is not installed" matching. + let issues = RE_PIP_REQUIREMENT.find_iter(text).count(); + let issues = if issues > 0 { + issues + } else { + let fallback = text + .lines() + .filter(|l| l.contains("has requirement") || l.contains("which is not installed")) + .count(); + if fallback == 0 { + return None; + } + fallback + }; + + let details: Vec = text + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.trim().to_string()) + .collect(); + + Some(PkgResult::new( + "pip".to_string(), + PkgOperation::Check { issues }, + false, + details, + )) +} + +// ============================================================================ +// pip list --outdated +// ============================================================================ + +fn run_list(args: &[String], show_stats: bool, json_output: bool) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "pip", + subcommand: "list", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install Python from https://python.org", + }, + args, + show_stats, + |cmd_args| { + if json_output { + if !user_has_flag(cmd_args, &["--outdated"]) { + cmd_args.push("--outdated".to_string()); + } + if !user_has_flag(cmd_args, &["--format"]) { + cmd_args.push("--format=json".to_string()); + } + } + }, + parse_list, + ) +} + +fn parse_list(output: &CommandOutput) -> ParseResult { + // Tier 1: JSON + if let Some(result) = try_parse_list_json(&output.stdout) { + return ParseResult::Full(result); + } + + // Tier 2: Regex + let combined = super::combine_output(output); + if let Some(result) = try_parse_list_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + // Tier 3: Passthrough + ParseResult::Passthrough(combined.into_owned()) +} + +fn try_parse_list_json(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout).ok()?; + let arr = value.as_array()?; + + let mut details: Vec = Vec::new(); + + for pkg in arr { + let name = pkg.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let version = pkg.get("version").and_then(|v| v.as_str()).unwrap_or("?"); + let latest = pkg + .get("latest_version") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + details.push(format!("{name} {version} -> {latest}")); + } + + let count = details.len(); + Some(PkgResult::new( + "pip".to_string(), + PkgOperation::Outdated { count }, + true, + details, + )) +} + +fn try_parse_list_regex(text: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + if lines.len() < 3 { + return None; + } + + // pip list --outdated text format: + // Package Version Latest Type + // ---------- ------- ------ ----- + // flask 2.3.0 3.0.0 wheel + let has_header = lines.first().is_some_and(|l| l.contains("Package")); + let has_separator = lines.get(1).is_some_and(|l| l.starts_with("---")); + + if !has_header || !has_separator { + return None; + } + + let details: Vec = lines + .iter() + .skip(2) + .filter(|l| !l.trim().is_empty()) + .map(|l| l.trim().to_string()) + .collect(); + + Some(PkgResult::new( + "pip".to_string(), + PkgOperation::Outdated { + count: details.len(), + }, + true, + details, + )) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_path(name: &str) -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/pkg"); + path.push(name); + path + } + + fn load_fixture(name: &str) -> String { + std::fs::read_to_string(fixture_path(name)) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + // ======================================================================== + // pip install: Regex + // ======================================================================== + + #[test] + fn test_install_regex_parse() { + let input = load_fixture("pip_install.txt"); + let result = try_parse_install_regex(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG INSTALL | pip")); + assert!(display.contains("added: 3")); + } + + #[test] + fn test_install_already_satisfied() { + let text = + "Requirement already satisfied: flask in ./venv/lib/python3.11/site-packages (3.0.0)"; + let result = try_parse_install_regex(text); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("added: 0")); + } + + // ======================================================================== + // pip check: Regex + // ======================================================================== + + #[test] + fn test_check_clean() { + let input = load_fixture("pip_check_clean.txt"); + let result = try_parse_check_regex(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG CHECK | pip | 0 issues")); + } + + #[test] + fn test_check_issues() { + let input = load_fixture("pip_check_issues.txt"); + let result = try_parse_check_regex(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG CHECK | pip")); + assert!(display.contains("2 issues")); + } + + // ======================================================================== + // pip list --outdated: JSON + // ======================================================================== + + #[test] + fn test_list_json_parse() { + let input = load_fixture("pip_outdated.json"); + let result = try_parse_list_json(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG OUTDATED | pip | 2 packages")); + assert!(display.contains("flask 2.3.0 -> 3.0.0")); + } + + #[test] + fn test_list_json_empty() { + let result = try_parse_list_json("[]"); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("0 packages")); + } + + // ======================================================================== + // Three-tier integration + // ======================================================================== + + #[test] + fn test_install_produces_full() { + let input = load_fixture("pip_install.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let result = parse_install(&output); + assert!( + result.is_full(), + "Expected Full, got {}", + result.tier_name() + ); + } + + #[test] + fn test_check_produces_full() { + let input = load_fixture("pip_check_clean.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let result = parse_check(&output); + assert!( + result.is_full(), + "Expected Full, got {}", + result.tier_name() + ); + } + + #[test] + fn test_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "completely unparseable output".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_install(&output); + assert!( + result.is_passthrough(), + "Expected Passthrough, got {}", + result.tier_name() + ); + } +} diff --git a/crates/rskim/src/cmd/pkg/pnpm.rs b/crates/rskim/src/cmd/pkg/pnpm.rs new file mode 100644 index 0000000..8e0f108 --- /dev/null +++ b/crates/rskim/src/cmd/pkg/pnpm.rs @@ -0,0 +1,412 @@ +//! pnpm package manager parser (#105) +//! +//! Parses `pnpm install`, `pnpm audit`, and `pnpm outdated` output. +//! pnpm install is text-only (no JSON mode for install output). +//! pnpm audit supports `--json`, pnpm outdated supports `--format json`. + +use std::process::ExitCode; +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{PkgOperation, PkgResult}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +// ============================================================================ +// Static regex patterns +// ============================================================================ + +static RE_PNPM_PACKAGES: LazyLock = + LazyLock::new(|| Regex::new(r"Packages:\s*\+(\d+)(?:\s+-(\d+))?").unwrap()); + +// ============================================================================ +// Public entry point +// ============================================================================ + +/// Run `skim pkg pnpm [args...]`. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + if args.is_empty() || args.iter().any(|a| matches!(a.as_str(), "--help" | "-h")) { + print_help(); + return Ok(ExitCode::SUCCESS); + } + + // Safe: args.is_empty() is handled above. + let (subcmd, subcmd_args) = args.split_first().expect("already verified non-empty"); + + match subcmd.as_str() { + "install" | "i" => run_install(subcmd_args, show_stats, json_output), + "audit" => run_audit(subcmd_args, show_stats, json_output), + "outdated" => run_outdated(subcmd_args, show_stats, json_output), + other => { + let safe = super::sanitize_for_display(other); + eprintln!( + "skim pkg pnpm: unknown subcommand '{safe}'\n\ + Available: install, audit, outdated\n\ + Run 'skim pkg pnpm --help' for usage" + ); + Ok(ExitCode::FAILURE) + } + } +} + +fn print_help() { + println!("skim pkg pnpm [args...]"); + println!(); + println!(" Parse pnpm output for AI context windows."); + println!(); + println!("Subcommands:"); + println!(" install Parse pnpm install output"); + println!(" audit Parse pnpm audit output"); + println!(" outdated Parse pnpm outdated output"); + println!(); + println!("Examples:"); + println!(" skim pkg pnpm install"); + println!(" skim pkg pnpm audit"); + println!(" skim pkg pnpm outdated"); + println!(" pnpm install 2>&1 | skim pkg pnpm install"); +} + +// ============================================================================ +// pnpm install (text-only, regex tier) +// ============================================================================ + +fn run_install(args: &[String], show_stats: bool, _json_output: bool) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "pnpm", + subcommand: "install", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install pnpm via https://pnpm.io/installation", + }, + args, + show_stats, + |_cmd_args| {}, + parse_install, + ) +} + +fn parse_install(output: &CommandOutput) -> ParseResult { + let combined = super::combine_output(output); + + // Tier 1: Regex (pnpm install has no JSON mode) + if let Some(result) = try_parse_install_regex(&combined) { + return ParseResult::Full(result); + } + + // Tier 2: Passthrough + ParseResult::Passthrough(combined.into_owned()) +} + +fn try_parse_install_regex(text: &str) -> Option { + // Match "Packages: +127 -3" + if let Some(caps) = RE_PNPM_PACKAGES.captures(text) { + let added = caps[1].parse::().unwrap_or(0); + let removed = caps + .get(2) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0); + + return Some(PkgResult::new( + "pnpm".to_string(), + PkgOperation::Install { + added, + removed, + changed: 0, + warnings: 0, + }, + true, + vec![], + )); + } + + // Fallback: "Already up to date" or "Done in" + if text.contains("Already up to date") || text.contains("Nothing to do") { + return Some(PkgResult::new( + "pnpm".to_string(), + PkgOperation::Install { + added: 0, + removed: 0, + changed: 0, + warnings: 0, + }, + true, + vec![], + )); + } + + None +} + +// ============================================================================ +// pnpm audit +// ============================================================================ + +fn run_audit(args: &[String], show_stats: bool, json_output: bool) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "pnpm", + subcommand: "audit", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install pnpm via https://pnpm.io/installation", + }, + args, + show_stats, + |cmd_args| { + if json_output && !user_has_flag(cmd_args, &["--json"]) { + cmd_args.push("--json".to_string()); + } + }, + parse_audit, + ) +} + +fn parse_audit(output: &CommandOutput) -> ParseResult { + // Tier 1: JSON + if let Some(result) = try_parse_audit_json(&output.stdout) { + return ParseResult::Full(result); + } + + // Tier 2: Passthrough (pnpm audit text is harder to regex reliably) + let combined = super::combine_output(output); + ParseResult::Passthrough(combined.into_owned()) +} + +fn try_parse_audit_json(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout).ok()?; + + // pnpm audit JSON format: { advisories: {...}, metadata: { vulnerabilities: {...} } } + let advisories = value.get("advisories")?.as_object()?; + + let mut critical: usize = 0; + let mut high: usize = 0; + let mut moderate: usize = 0; + let mut low: usize = 0; + let mut details: Vec = Vec::new(); + + for (_id, advisory) in advisories { + let severity = advisory + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let module_name = advisory + .get("module_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let title = advisory + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + match severity { + "critical" => critical += 1, + "high" => high += 1, + "moderate" => moderate += 1, + "low" => low += 1, + _ => {} + } + + details.push(format!("{module_name}: {title} ({severity})")); + } + + // Use details.len() instead of summing severity buckets so entries with + // unknown/unrecognised severity are still counted (consistent with npm/cargo). + let total = details.len(); + + Some(PkgResult::new( + "pnpm".to_string(), + PkgOperation::Audit { + critical, + high, + moderate, + low, + total, + }, + true, + details, + )) +} + +// ============================================================================ +// pnpm outdated +// ============================================================================ + +fn run_outdated(args: &[String], show_stats: bool, json_output: bool) -> anyhow::Result { + super::run_pkg_subcommand( + super::PkgSubcommandConfig { + program: "pnpm", + subcommand: "outdated", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install pnpm via https://pnpm.io/installation", + }, + args, + show_stats, + |cmd_args| { + if json_output && !user_has_flag(cmd_args, &["--format"]) { + cmd_args.push("--format".to_string()); + cmd_args.push("json".to_string()); + } + }, + parse_outdated, + ) +} + +fn parse_outdated(output: &CommandOutput) -> ParseResult { + // Tier 1: JSON + if let Some(result) = try_parse_outdated_json(&output.stdout) { + return ParseResult::Full(result); + } + + // Tier 2: Passthrough + let combined = super::combine_output(output); + ParseResult::Passthrough(combined.into_owned()) +} + +fn try_parse_outdated_json(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout).ok()?; + let obj = value.as_object()?; + + if obj.is_empty() { + return Some(PkgResult::new( + "pnpm".to_string(), + PkgOperation::Outdated { count: 0 }, + true, + vec![], + )); + } + + let mut details: Vec = Vec::new(); + + for (name, pkg) in obj { + let current = pkg.get("current").and_then(|v| v.as_str()).unwrap_or("?"); + let latest = pkg.get("latest").and_then(|v| v.as_str()).unwrap_or("?"); + details.push(format!("{name} {current} -> {latest}")); + } + + let count = details.len(); + Some(PkgResult::new( + "pnpm".to_string(), + PkgOperation::Outdated { count }, + true, + details, + )) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_path(name: &str) -> std::path::PathBuf { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/pkg"); + path.push(name); + path + } + + fn load_fixture(name: &str) -> String { + std::fs::read_to_string(fixture_path(name)) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + // ======================================================================== + // pnpm install: Regex + // ======================================================================== + + #[test] + fn test_install_regex_parse() { + let input = load_fixture("pnpm_install.txt"); + let result = try_parse_install_regex(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG INSTALL | pnpm")); + assert!(display.contains("added: 127")); + assert!(display.contains("removed: 3")); + } + + // ======================================================================== + // pnpm audit: JSON + // ======================================================================== + + #[test] + fn test_audit_json_parse() { + let input = load_fixture("pnpm_audit.json"); + let result = try_parse_audit_json(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG AUDIT | pnpm")); + assert!(display.contains("critical: 1")); + assert!(display.contains("high: 1")); + assert!(display.contains("total: 2")); + } + + // ======================================================================== + // pnpm outdated: JSON + // ======================================================================== + + #[test] + fn test_outdated_json_parse() { + let input = load_fixture("pnpm_outdated.json"); + let result = try_parse_outdated_json(&input); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("PKG OUTDATED | pnpm | 2 packages")); + } + + #[test] + fn test_outdated_json_empty() { + let result = try_parse_outdated_json("{}"); + assert!(result.is_some()); + let result = result.unwrap(); + let display = format!("{result}"); + assert!(display.contains("0 packages")); + } + + // ======================================================================== + // Three-tier integration + // ======================================================================== + + #[test] + fn test_install_produces_full() { + let input = load_fixture("pnpm_install.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let result = parse_install(&output); + assert!( + result.is_full(), + "Expected Full, got {}", + result.tier_name() + ); + } + + #[test] + fn test_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "completely unparseable output".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_install(&output); + assert!( + result.is_passthrough(), + "Expected Passthrough, got {}", + result.tier_name() + ); + } +} diff --git a/crates/rskim/src/cmd/rewrite.rs b/crates/rskim/src/cmd/rewrite.rs index 03f05e0..9d2ac9d 100644 --- a/crates/rskim/src/cmd/rewrite.rs +++ b/crates/rskim/src/cmd/rewrite.rs @@ -34,6 +34,7 @@ enum RewriteCategory { Build, Git, Read, + Pkg, } struct RewriteRule { @@ -122,7 +123,7 @@ fn serialize_category( } // ============================================================================ -// Rule table (15 rules, ordered longest-prefix-first within same leading token) +// Rule table (34 rules, ordered longest-prefix-first within same leading token) // ============================================================================ const REWRITE_RULES: &[RewriteRule] = &[ @@ -139,6 +140,12 @@ const REWRITE_RULES: &[RewriteRule] = &[ skip_if_flag_prefix: &[], category: RewriteCategory::Test, }, + RewriteRule { + prefix: &["cargo", "audit"], + rewrite_to: &["skim", "pkg", "cargo", "audit"], + skip_if_flag_prefix: &["--json"], + category: RewriteCategory::Pkg, + }, RewriteRule { prefix: &["cargo", "clippy"], rewrite_to: &["skim", "build", "clippy"], @@ -222,6 +229,111 @@ const REWRITE_RULES: &[RewriteRule] = &[ skip_if_flag_prefix: &[], category: RewriteCategory::Build, }, + // pkg — npm (canonical + aliases) + RewriteRule { + prefix: &["npm", "audit"], + rewrite_to: &["skim", "pkg", "npm", "audit"], + skip_if_flag_prefix: &["--json"], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["npm", "install"], + rewrite_to: &["skim", "pkg", "npm", "install"], + skip_if_flag_prefix: &["--json"], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["npm", "i"], + rewrite_to: &["skim", "pkg", "npm", "install"], + skip_if_flag_prefix: &["--json"], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["npm", "ci"], + rewrite_to: &["skim", "pkg", "npm", "install"], + skip_if_flag_prefix: &["--json"], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["npm", "outdated"], + rewrite_to: &["skim", "pkg", "npm", "outdated"], + skip_if_flag_prefix: &["--json"], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["npm", "list"], + rewrite_to: &["skim", "pkg", "npm", "ls"], + skip_if_flag_prefix: &["--json"], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["npm", "ls"], + rewrite_to: &["skim", "pkg", "npm", "ls"], + skip_if_flag_prefix: &["--json"], + category: RewriteCategory::Pkg, + }, + // pkg — pnpm + RewriteRule { + prefix: &["pnpm", "audit"], + rewrite_to: &["skim", "pkg", "pnpm", "audit"], + skip_if_flag_prefix: &["--json"], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["pnpm", "install"], + rewrite_to: &["skim", "pkg", "pnpm", "install"], + skip_if_flag_prefix: &[], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["pnpm", "i"], + rewrite_to: &["skim", "pkg", "pnpm", "install"], + skip_if_flag_prefix: &[], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["pnpm", "outdated"], + rewrite_to: &["skim", "pkg", "pnpm", "outdated"], + skip_if_flag_prefix: &["--format"], + category: RewriteCategory::Pkg, + }, + // pkg — pip (canonical + pip3 aliases) + RewriteRule { + prefix: &["pip", "install"], + rewrite_to: &["skim", "pkg", "pip", "install"], + skip_if_flag_prefix: &[], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["pip", "check"], + rewrite_to: &["skim", "pkg", "pip", "check"], + skip_if_flag_prefix: &[], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["pip", "list"], + rewrite_to: &["skim", "pkg", "pip", "list"], + skip_if_flag_prefix: &["--format"], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["pip3", "install"], + rewrite_to: &["skim", "pkg", "pip", "install"], + skip_if_flag_prefix: &[], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["pip3", "check"], + rewrite_to: &["skim", "pkg", "pip", "check"], + skip_if_flag_prefix: &[], + category: RewriteCategory::Pkg, + }, + RewriteRule { + prefix: &["pip3", "list"], + rewrite_to: &["skim", "pkg", "pip", "list"], + skip_if_flag_prefix: &["--format"], + category: RewriteCategory::Pkg, + }, ]; // ============================================================================ @@ -1451,7 +1563,7 @@ mod tests { use super::*; // ======================================================================== - // Prefix rule matches (all 15 rules) + // Prefix rule matches (all 34 rules) // ======================================================================== #[test] @@ -1565,6 +1677,187 @@ mod tests { assert_eq!(result.tokens, vec!["skim", "build", "tsc"]); } + // ======================================================================== + // Pkg rewrite rules (18 rules) + // ======================================================================== + + #[test] + fn test_cargo_audit() { + let result = try_rewrite(&["cargo", "audit"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "cargo", "audit"]); + } + + #[test] + fn test_cargo_audit_skip_json() { + assert!(try_rewrite(&["cargo", "audit", "--json"]).is_none()); + } + + #[test] + fn test_npm_audit() { + let result = try_rewrite(&["npm", "audit"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "npm", "audit"]); + } + + #[test] + fn test_npm_audit_skip_json() { + assert!(try_rewrite(&["npm", "audit", "--json"]).is_none()); + } + + #[test] + fn test_npm_install() { + let result = try_rewrite(&["npm", "install"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "npm", "install"]); + } + + #[test] + fn test_npm_install_skip_json() { + assert!(try_rewrite(&["npm", "install", "--json"]).is_none()); + } + + #[test] + fn test_npm_i_alias() { + let result = try_rewrite(&["npm", "i"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "npm", "install"]); + } + + #[test] + fn test_npm_ci() { + let result = try_rewrite(&["npm", "ci"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "npm", "install"]); + } + + #[test] + fn test_npm_outdated() { + let result = try_rewrite(&["npm", "outdated"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "npm", "outdated"]); + } + + #[test] + fn test_npm_outdated_skip_json() { + assert!(try_rewrite(&["npm", "outdated", "--json"]).is_none()); + } + + #[test] + fn test_npm_list_alias() { + let result = try_rewrite(&["npm", "list"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "npm", "ls"]); + } + + #[test] + fn test_npm_ls() { + let result = try_rewrite(&["npm", "ls"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "npm", "ls"]); + } + + #[test] + fn test_npm_ls_skip_json() { + assert!(try_rewrite(&["npm", "ls", "--json"]).is_none()); + } + + #[test] + fn test_pnpm_audit() { + let result = try_rewrite(&["pnpm", "audit"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "pnpm", "audit"]); + } + + #[test] + fn test_pnpm_audit_skip_json() { + assert!(try_rewrite(&["pnpm", "audit", "--json"]).is_none()); + } + + #[test] + fn test_pnpm_install() { + let result = try_rewrite(&["pnpm", "install"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "pnpm", "install"]); + } + + #[test] + fn test_pnpm_i_alias() { + let result = try_rewrite(&["pnpm", "i"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "pnpm", "install"]); + } + + #[test] + fn test_pnpm_outdated() { + let result = try_rewrite(&["pnpm", "outdated"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "pnpm", "outdated"]); + } + + #[test] + fn test_pnpm_outdated_skip_format() { + assert!(try_rewrite(&["pnpm", "outdated", "--format=json"]).is_none()); + } + + #[test] + fn test_pip_install() { + let result = try_rewrite(&["pip", "install"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "pip", "install"]); + } + + #[test] + fn test_pip_check() { + let result = try_rewrite(&["pip", "check"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "pip", "check"]); + } + + #[test] + fn test_pip_list() { + let result = try_rewrite(&["pip", "list"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "pip", "list"]); + } + + #[test] + fn test_pip_list_skip_format() { + assert!(try_rewrite(&["pip", "list", "--format=columns"]).is_none()); + } + + #[test] + fn test_pip3_install() { + let result = try_rewrite(&["pip3", "install"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "pip", "install"]); + } + + #[test] + fn test_pip3_check() { + let result = try_rewrite(&["pip3", "check"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "pip", "check"]); + } + + #[test] + fn test_pip3_list() { + let result = try_rewrite(&["pip3", "list"]).unwrap(); + assert_eq!(result.tokens, vec!["skim", "pkg", "pip", "list"]); + } + + #[test] + fn test_pip3_list_skip_format() { + assert!(try_rewrite(&["pip3", "list", "--format=json"]).is_none()); + } + + #[test] + fn test_pkg_category_for_cargo_audit() { + let result = try_rewrite(&["cargo", "audit"]).unwrap(); + assert!(matches!(result.category, RewriteCategory::Pkg)); + } + + #[test] + fn test_pkg_category_for_npm_install() { + let result = try_rewrite(&["npm", "install"]).unwrap(); + assert!(matches!(result.category, RewriteCategory::Pkg)); + } + + #[test] + fn test_pkg_category_for_pnpm_audit() { + let result = try_rewrite(&["pnpm", "audit"]).unwrap(); + assert!(matches!(result.category, RewriteCategory::Pkg)); + } + + #[test] + fn test_pkg_category_for_pip_install() { + let result = try_rewrite(&["pip", "install"]).unwrap(); + assert!(matches!(result.category, RewriteCategory::Pkg)); + } + // ======================================================================== // Skip-flag behavior (git rules) // ======================================================================== diff --git a/crates/rskim/src/output/canonical.rs b/crates/rskim/src/output/canonical.rs index 91801b1..1f4d560 100644 --- a/crates/rskim/src/output/canonical.rs +++ b/crates/rskim/src/output/canonical.rs @@ -265,6 +265,130 @@ impl fmt::Display for BuildResult { } } +// ============================================================================ +// PkgResult types +// ============================================================================ + +/// Package operation type with operation-specific data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) enum PkgOperation { + Install { + added: usize, + removed: usize, + changed: usize, + warnings: usize, + }, + Audit { + critical: usize, + high: usize, + moderate: usize, + low: usize, + total: usize, + }, + Outdated { + count: usize, + }, + Check { + issues: usize, + }, + List { + total: usize, + flagged: usize, + }, +} + +/// Complete package manager result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PkgResult { + pub(crate) tool: String, + pub(crate) operation: PkgOperation, + pub(crate) success: bool, + pub(crate) details: Vec, + #[serde(default)] + rendered: String, +} + +impl PkgResult { + /// Create a new PkgResult with pre-computed rendered output + pub(crate) fn new( + tool: String, + operation: PkgOperation, + success: bool, + details: Vec, + ) -> Self { + let rendered = Self::render(&tool, &operation, &details); + Self { + tool, + operation, + success, + details, + rendered, + } + } + + /// Recompute rendered field if empty (e.g., after deserialization) + pub(crate) fn ensure_rendered(&mut self) { + if self.rendered.is_empty() { + self.rendered = Self::render(&self.tool, &self.operation, &self.details); + } + } + + fn render(tool: &str, operation: &PkgOperation, details: &[String]) -> String { + use std::fmt::Write; + + let mut output = match operation { + PkgOperation::Install { + added, + removed, + changed, + warnings, + } => { + format!( + "PKG INSTALL | {tool} | added: {added} | removed: {removed} | changed: {changed} | warnings: {warnings}" + ) + } + PkgOperation::Audit { + critical, + high, + moderate, + low, + total, + } => { + format!( + "PKG AUDIT | {tool} | critical: {critical} | high: {high} | moderate: {moderate} | low: {low} | total: {total}" + ) + } + PkgOperation::Outdated { count } => { + format!("PKG OUTDATED | {tool} | {count} packages") + } + PkgOperation::Check { issues } => { + format!("PKG CHECK | {tool} | {issues} issues") + } + PkgOperation::List { total, flagged } => { + format!("PKG LIST | {tool} | {total} total | {flagged} flagged") + } + }; + + for detail in details { + let _ = write!(output, "\n {detail}"); + } + + output + } +} + +impl AsRef for PkgResult { + fn as_ref(&self) -> &str { + &self.rendered + } +} + +impl fmt::Display for PkgResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.rendered) + } +} + // ============================================================================ // Helpers // ============================================================================ @@ -531,4 +655,127 @@ mod tests { assert_eq!(format!("{}", TestOutcome::Fail), "FAIL"); assert_eq!(format!("{}", TestOutcome::Skip), "SKIP"); } + + // ======================================================================== + // PkgResult Display tests + // ======================================================================== + + #[test] + fn test_pkg_install_display() { + let result = PkgResult::new( + "npm".to_string(), + PkgOperation::Install { + added: 5, + removed: 1, + changed: 2, + warnings: 3, + }, + true, + vec![], + ); + let display = format!("{result}"); + assert!(display.contains("PKG INSTALL | npm")); + assert!(display.contains("added: 5")); + assert!(display.contains("removed: 1")); + assert!(display.contains("changed: 2")); + assert!(display.contains("warnings: 3")); + } + + #[test] + fn test_pkg_audit_display() { + let result = PkgResult::new( + "npm".to_string(), + PkgOperation::Audit { + critical: 0, + high: 2, + moderate: 3, + low: 1, + total: 6, + }, + true, + vec!["lodash: Prototype Pollution (high)".to_string()], + ); + let display = format!("{result}"); + assert!(display.contains("PKG AUDIT | npm")); + assert!(display.contains("critical: 0")); + assert!(display.contains("high: 2")); + assert!(display.contains("total: 6")); + assert!(display.contains("lodash: Prototype Pollution (high)")); + } + + #[test] + fn test_pkg_outdated_display() { + let result = PkgResult::new( + "npm".to_string(), + PkgOperation::Outdated { count: 4 }, + true, + vec!["lodash 4.17.20 -> 4.17.21".to_string()], + ); + let display = format!("{result}"); + assert!(display.contains("PKG OUTDATED | npm | 4 packages")); + assert!(display.contains("lodash 4.17.20 -> 4.17.21")); + } + + #[test] + fn test_pkg_check_display() { + let result = PkgResult::new( + "pip".to_string(), + PkgOperation::Check { issues: 3 }, + false, + vec!["flask requires werkzeug>=3.0.1".to_string()], + ); + let display = format!("{result}"); + assert!(display.contains("PKG CHECK | pip | 3 issues")); + assert!(display.contains("flask requires werkzeug>=3.0.1")); + } + + #[test] + fn test_pkg_list_display() { + let result = PkgResult::new( + "npm".to_string(), + PkgOperation::List { + total: 42, + flagged: 2, + }, + true, + vec!["debug@4.3.4: invalid".to_string()], + ); + let display = format!("{result}"); + assert!(display.contains("PKG LIST | npm | 42 total | 2 flagged")); + assert!(display.contains("debug@4.3.4: invalid")); + } + + #[test] + fn test_pkg_result_serde_roundtrip() { + let original = PkgResult::new( + "npm".to_string(), + PkgOperation::Audit { + critical: 1, + high: 2, + moderate: 0, + low: 0, + total: 3, + }, + true, + vec!["advisory detail".to_string()], + ); + let json = serde_json::to_string(&original).unwrap(); + let mut deserialized: PkgResult = serde_json::from_str(&json).unwrap(); + deserialized.ensure_rendered(); + assert_eq!(format!("{original}"), format!("{deserialized}")); + } + + #[test] + fn test_pkg_ensure_rendered_recomputes_when_empty() { + let mut result = PkgResult { + tool: "pip".to_string(), + operation: PkgOperation::Check { issues: 0 }, + success: true, + details: vec![], + rendered: String::new(), + }; + assert_eq!(result.as_ref(), ""); + result.ensure_rendered(); + assert_eq!(result.as_ref(), "PKG CHECK | pip | 0 issues"); + } } diff --git a/crates/rskim/tests/cli_e2e_pkg_parsers.rs b/crates/rskim/tests/cli_e2e_pkg_parsers.rs new file mode 100644 index 0000000..1347da5 --- /dev/null +++ b/crates/rskim/tests/cli_e2e_pkg_parsers.rs @@ -0,0 +1,346 @@ +//! E2E tests for pkg parser degradation tiers (#105). +//! +//! Tests each pkg tool/subcommand at different degradation tiers via stdin piping, +//! verifying structured output markers and stderr diagnostics. +//! +//! Tier behavior reference: +//! - Full: no stderr markers +//! - Degraded: "[warning] ..." on stderr +//! - Passthrough: "[notice] output passed through without parsing" on stderr + +use assert_cmd::Command; +use predicates::prelude::*; + +fn skim_cmd() -> Command { + Command::cargo_bin("skim").unwrap() +} + +// ============================================================================ +// skim pkg --help +// ============================================================================ + +#[test] +fn test_pkg_help() { + skim_cmd() + .args(["pkg", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Available tools:")) + .stdout(predicate::str::contains("npm")) + .stdout(predicate::str::contains("pip")) + .stdout(predicate::str::contains("cargo")); +} + +#[test] +fn test_pkg_no_args_shows_help() { + skim_cmd() + .arg("pkg") + .assert() + .success() + .stdout(predicate::str::contains("Available tools:")); +} + +#[test] +fn test_pkg_unknown_tool() { + skim_cmd() + .args(["pkg", "yarn"]) + .assert() + .failure() + .stderr(predicate::str::contains("unknown tool")); +} + +// ============================================================================ +// npm install: Tier 1 (JSON) — Full +// ============================================================================ + +#[test] +fn test_npm_install_tier1_json() { + let fixture = include_str!("fixtures/cmd/pkg/npm_install.json"); + skim_cmd() + .args(["pkg", "npm", "install"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG INSTALL | npm")) + .stdout(predicate::str::contains("added: 127")) + .stdout(predicate::str::contains("removed: 3")); +} + +// ============================================================================ +// npm install: Tier 2 (regex) — Degraded +// ============================================================================ + +#[test] +fn test_npm_install_tier2_regex() { + let fixture = include_str!("fixtures/cmd/pkg/npm_install_text.txt"); + skim_cmd() + .args(["pkg", "npm", "install"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG INSTALL | npm")) + .stdout(predicate::str::contains("added: 127")) + .stderr(predicate::str::contains("[warning]")); +} + +// ============================================================================ +// npm install: Tier 3 — Passthrough +// ============================================================================ + +#[test] +fn test_npm_install_passthrough() { + skim_cmd() + .args(["pkg", "npm", "install"]) + .write_stdin("completely unparseable output") + .assert() + .success() + .stdout(predicate::str::contains("completely unparseable")) + .stderr(predicate::str::contains("[notice]")); +} + +// ============================================================================ +// npm audit: Tier 1 (JSON) — Full +// ============================================================================ + +#[test] +fn test_npm_audit_tier1_json() { + let fixture = include_str!("fixtures/cmd/pkg/npm_audit.json"); + skim_cmd() + .args(["pkg", "npm", "audit"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG AUDIT | npm")) + .stdout(predicate::str::contains("critical: 1")) + .stdout(predicate::str::contains("total: 3")); +} + +#[test] +fn test_npm_audit_clean_tier1() { + let fixture = include_str!("fixtures/cmd/pkg/npm_audit_clean.json"); + skim_cmd() + .args(["pkg", "npm", "audit"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("total: 0")); +} + +// ============================================================================ +// npm outdated: Tier 1 (JSON) — Full +// ============================================================================ + +#[test] +fn test_npm_outdated_tier1_json() { + let fixture = include_str!("fixtures/cmd/pkg/npm_outdated.json"); + skim_cmd() + .args(["pkg", "npm", "outdated"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG OUTDATED | npm")) + .stdout(predicate::str::contains("3 packages")); +} + +// ============================================================================ +// npm ls: Tier 1 (JSON) — Full +// ============================================================================ + +#[test] +fn test_npm_ls_tier1_json() { + let fixture = include_str!("fixtures/cmd/pkg/npm_ls.json"); + skim_cmd() + .args(["pkg", "npm", "ls"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG LIST | npm")) + .stdout(predicate::str::contains("4 total")) + .stdout(predicate::str::contains("1 flagged")); +} + +// ============================================================================ +// npm: no subcommand shows help +// ============================================================================ + +#[test] +fn test_npm_no_subcmd_shows_help() { + skim_cmd() + .args(["pkg", "npm"]) + .assert() + .success() + .stdout(predicate::str::contains("Subcommands:")) + .stdout(predicate::str::contains("install")) + .stdout(predicate::str::contains("audit")); +} + +// ============================================================================ +// pip install: Tier 1 (regex) — Full +// ============================================================================ + +#[test] +fn test_pip_install_tier1_regex() { + let fixture = include_str!("fixtures/cmd/pkg/pip_install.txt"); + skim_cmd() + .args(["pkg", "pip", "install"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG INSTALL | pip")) + .stdout(predicate::str::contains("added: 3")); +} + +// ============================================================================ +// pip check: clean +// ============================================================================ + +#[test] +fn test_pip_check_clean() { + let fixture = include_str!("fixtures/cmd/pkg/pip_check_clean.txt"); + skim_cmd() + .args(["pkg", "pip", "check"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG CHECK | pip")) + .stdout(predicate::str::contains("0 issues")); +} + +// ============================================================================ +// pip check: issues +// ============================================================================ + +#[test] +fn test_pip_check_issues() { + let fixture = include_str!("fixtures/cmd/pkg/pip_check_issues.txt"); + skim_cmd() + .args(["pkg", "pip", "check"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG CHECK | pip")) + .stdout(predicate::str::contains("2 issues")); +} + +// ============================================================================ +// pip list --outdated: JSON +// ============================================================================ + +#[test] +fn test_pip_list_json() { + let fixture = include_str!("fixtures/cmd/pkg/pip_outdated.json"); + skim_cmd() + .args(["pkg", "pip", "list"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG OUTDATED | pip")) + .stdout(predicate::str::contains("2 packages")); +} + +// ============================================================================ +// pnpm install: regex +// ============================================================================ + +#[test] +fn test_pnpm_install_regex() { + let fixture = include_str!("fixtures/cmd/pkg/pnpm_install.txt"); + skim_cmd() + .args(["pkg", "pnpm", "install"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG INSTALL | pnpm")) + .stdout(predicate::str::contains("added: 127")); +} + +// ============================================================================ +// pnpm audit: JSON +// ============================================================================ + +#[test] +fn test_pnpm_audit_json() { + let fixture = include_str!("fixtures/cmd/pkg/pnpm_audit.json"); + skim_cmd() + .args(["pkg", "pnpm", "audit"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG AUDIT | pnpm")) + .stdout(predicate::str::contains("total: 2")); +} + +// ============================================================================ +// pnpm outdated: JSON +// ============================================================================ + +#[test] +fn test_pnpm_outdated_json() { + let fixture = include_str!("fixtures/cmd/pkg/pnpm_outdated.json"); + skim_cmd() + .args(["pkg", "pnpm", "outdated"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG OUTDATED | pnpm")) + .stdout(predicate::str::contains("2 packages")); +} + +// ============================================================================ +// cargo audit: JSON +// ============================================================================ + +#[test] +fn test_cargo_audit_json() { + let fixture = include_str!("fixtures/cmd/pkg/cargo_audit.json"); + skim_cmd() + .args(["pkg", "cargo", "audit"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("PKG AUDIT | cargo")) + .stdout(predicate::str::contains("critical: 1")) + .stdout(predicate::str::contains("total: 2")); +} + +#[test] +fn test_cargo_audit_clean_json() { + let fixture = include_str!("fixtures/cmd/pkg/cargo_audit_clean.json"); + skim_cmd() + .args(["pkg", "cargo", "audit"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("total: 0")); +} + +// ============================================================================ +// cargo audit: Tier 2 (regex) — Degraded +// ============================================================================ + +#[test] +fn test_cargo_audit_tier2_regex() { + let text = "Crate: buffer-utils\nVersion: 0.3.1\nTitle: Buffer overflow\nID: RUSTSEC-2024-0001"; + skim_cmd() + .args(["pkg", "cargo", "audit"]) + .write_stdin(text) + .assert() + .success() + .stdout(predicate::str::contains("PKG AUDIT | cargo")) + .stderr(predicate::str::contains("[warning]")); +} + +// ============================================================================ +// cargo audit: Tier 3 — Passthrough +// ============================================================================ + +#[test] +fn test_cargo_audit_passthrough() { + skim_cmd() + .args(["pkg", "cargo", "audit"]) + .write_stdin("completely unparseable output") + .assert() + .success() + .stdout(predicate::str::contains("completely unparseable")) + .stderr(predicate::str::contains("[notice]")); +} diff --git a/crates/rskim/tests/cli_e2e_rewrite.rs b/crates/rskim/tests/cli_e2e_rewrite.rs index 3aa7ebc..417ba3e 100644 --- a/crates/rskim/tests/cli_e2e_rewrite.rs +++ b/crates/rskim/tests/cli_e2e_rewrite.rs @@ -664,3 +664,162 @@ fn test_rewrite_hook_passthrough_zero_stderr() { "Passthrough hook mode should produce zero stderr, got: {stderr}" ); } + +// ============================================================================ +// Phase 7: Pkg rewrite rules (#105) +// ============================================================================ + +#[test] +fn test_rewrite_npm_audit() { + skim_cmd() + .args(["rewrite", "npm", "audit"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg npm audit")); +} + +#[test] +fn test_rewrite_npm_i_express() { + skim_cmd() + .args(["rewrite", "npm", "i", "express"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg npm install express")); +} + +#[test] +fn test_rewrite_npm_ci() { + skim_cmd() + .args(["rewrite", "npm", "ci"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg npm install")); +} + +#[test] +fn test_rewrite_pip_install_flask() { + skim_cmd() + .args(["rewrite", "pip", "install", "flask"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg pip install flask")); +} + +#[test] +fn test_rewrite_pip3_check() { + skim_cmd() + .args(["rewrite", "pip3", "check"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg pip check")); +} + +#[test] +fn test_rewrite_cargo_audit() { + skim_cmd() + .args(["rewrite", "cargo", "audit"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg cargo audit")); +} + +#[test] +fn test_rewrite_pnpm_install() { + skim_cmd() + .args(["rewrite", "pnpm", "install"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg pnpm install")); +} + +#[test] +fn test_rewrite_npm_audit_json_skip() { + // --json flag should prevent rewrite + skim_cmd() + .args(["rewrite", "npm", "audit", "--json"]) + .assert() + .failure(); +} + +#[test] +fn test_rewrite_pip_list_format_skip() { + // --format=json should prevent rewrite + skim_cmd() + .args(["rewrite", "pip", "list", "--format=json"]) + .assert() + .failure(); +} + +#[test] +fn test_rewrite_npm_install_with_args() { + skim_cmd() + .args(["rewrite", "npm", "install", "express", "lodash"]) + .assert() + .success() + .stdout(predicate::str::contains( + "skim pkg npm install express lodash", + )); +} + +#[test] +fn test_rewrite_npm_outdated() { + skim_cmd() + .args(["rewrite", "npm", "outdated"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg npm outdated")); +} + +#[test] +fn test_rewrite_npm_ls() { + skim_cmd() + .args(["rewrite", "npm", "ls"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg npm ls")); +} + +#[test] +fn test_rewrite_pnpm_audit() { + skim_cmd() + .args(["rewrite", "pnpm", "audit"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg pnpm audit")); +} + +#[test] +fn test_rewrite_pnpm_outdated() { + skim_cmd() + .args(["rewrite", "pnpm", "outdated"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg pnpm outdated")); +} + +#[test] +fn test_rewrite_pip_list() { + skim_cmd() + .args(["rewrite", "pip", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg pip list")); +} + +#[test] +fn test_rewrite_pip3_install() { + skim_cmd() + .args(["rewrite", "pip3", "install", "flask"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg pip install flask")); +} + +#[test] +fn test_rewrite_pip3_list() { + skim_cmd() + .args(["rewrite", "pip3", "list"]) + .assert() + .success() + .stdout(predicate::str::contains("skim pkg pip list")); +} diff --git a/crates/rskim/tests/fixtures/cmd/pkg/cargo_audit.json b/crates/rskim/tests/fixtures/cmd/pkg/cargo_audit.json new file mode 100644 index 0000000..e78c2db --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/cargo_audit.json @@ -0,0 +1,20 @@ +{ + "database": {"advisory-count": 650, "last-commit": "2024-01-15T00:00:00Z", "last-updated": "2024-01-15T00:00:00Z"}, + "lockfile": {"dependency-count": 250}, + "vulnerabilities": { + "found": true, + "count": 2, + "list": [ + { + "advisory": {"id": "RUSTSEC-2024-0001", "title": "Buffer overflow in buffer-utils", "description": "A buffer overflow exists...", "severity": "high", "url": "https://rustsec.org/advisories/RUSTSEC-2024-0001"}, + "package": {"name": "buffer-utils", "version": "0.3.1"}, + "versions": {"patched": [">=0.3.2"], "unaffected": []} + }, + { + "advisory": {"id": "RUSTSEC-2024-0002", "title": "Memory safety issue in unsafe-lib", "description": "An unsafe block...", "severity": "critical", "url": "https://rustsec.org/advisories/RUSTSEC-2024-0002"}, + "package": {"name": "unsafe-lib", "version": "1.0.0"}, + "versions": {"patched": [">=1.0.1"], "unaffected": []} + } + ] + } +} diff --git a/crates/rskim/tests/fixtures/cmd/pkg/cargo_audit_clean.json b/crates/rskim/tests/fixtures/cmd/pkg/cargo_audit_clean.json new file mode 100644 index 0000000..ed7101b --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/cargo_audit_clean.json @@ -0,0 +1,9 @@ +{ + "database": {"advisory-count": 650, "last-commit": "2024-01-15T00:00:00Z", "last-updated": "2024-01-15T00:00:00Z"}, + "lockfile": {"dependency-count": 250}, + "vulnerabilities": { + "found": false, + "count": 0, + "list": [] + } +} diff --git a/crates/rskim/tests/fixtures/cmd/pkg/npm_audit.json b/crates/rskim/tests/fixtures/cmd/pkg/npm_audit.json new file mode 100644 index 0000000..3a49a84 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/npm_audit.json @@ -0,0 +1,36 @@ +{ + "auditReportVersion": 2, + "vulnerabilities": { + "lodash": { + "name": "lodash", + "severity": "high", + "isDirect": true, + "via": [{"title": "Prototype Pollution", "severity": "high", "url": "https://github.com/advisories/GHSA-1234"}], + "effects": [], + "range": "<4.17.21", + "fixAvailable": true + }, + "minimist": { + "name": "minimist", + "severity": "critical", + "isDirect": false, + "via": [{"title": "Prototype Pollution in minimist", "severity": "critical", "url": "https://github.com/advisories/GHSA-5678"}], + "effects": ["mkdirp"], + "range": "<1.2.6", + "fixAvailable": true + }, + "glob-parent": { + "name": "glob-parent", + "severity": "moderate", + "isDirect": false, + "via": [{"title": "Regular Expression Denial of Service", "severity": "moderate", "url": "https://github.com/advisories/GHSA-9012"}], + "effects": ["chokidar"], + "range": "<5.1.2", + "fixAvailable": false + } + }, + "metadata": { + "vulnerabilities": {"info": 0, "low": 0, "moderate": 1, "high": 1, "critical": 1, "total": 3}, + "dependencies": {"prod": 150, "dev": 45, "optional": 5, "peer": 0, "peerOptional": 0, "total": 200} + } +} diff --git a/crates/rskim/tests/fixtures/cmd/pkg/npm_audit_clean.json b/crates/rskim/tests/fixtures/cmd/pkg/npm_audit_clean.json new file mode 100644 index 0000000..5358d65 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/npm_audit_clean.json @@ -0,0 +1,8 @@ +{ + "auditReportVersion": 2, + "vulnerabilities": {}, + "metadata": { + "vulnerabilities": {"info": 0, "low": 0, "moderate": 0, "high": 0, "critical": 0, "total": 0}, + "dependencies": {"prod": 150, "dev": 45, "optional": 5, "peer": 0, "peerOptional": 0, "total": 200} + } +} diff --git a/crates/rskim/tests/fixtures/cmd/pkg/npm_install.json b/crates/rskim/tests/fixtures/cmd/pkg/npm_install.json new file mode 100644 index 0000000..118c33a --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/npm_install.json @@ -0,0 +1,10 @@ +{ + "added": 127, + "removed": 3, + "changed": 14, + "funding": 42, + "audit": { + "auditReportVersion": 2, + "vulnerabilities": {} + } +} diff --git a/crates/rskim/tests/fixtures/cmd/pkg/npm_install_text.txt b/crates/rskim/tests/fixtures/cmd/pkg/npm_install_text.txt new file mode 100644 index 0000000..90abd69 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/npm_install_text.txt @@ -0,0 +1,6 @@ +added 127 packages, removed 3 packages, and changed 14 packages in 8s + +42 packages are looking for funding + run `npm fund` for details + +found 0 vulnerabilities diff --git a/crates/rskim/tests/fixtures/cmd/pkg/npm_ls.json b/crates/rskim/tests/fixtures/cmd/pkg/npm_ls.json new file mode 100644 index 0000000..f0f8657 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/npm_ls.json @@ -0,0 +1,10 @@ +{ + "version": "1.0.0", + "name": "my-app", + "dependencies": { + "express": {"version": "4.18.2"}, + "lodash": {"version": "4.17.21"}, + "typescript": {"version": "5.3.3"}, + "debug": {"version": "4.3.4", "problems": ["invalid: debug@4.3.4 expected: >=4.3.5"]} + } +} diff --git a/crates/rskim/tests/fixtures/cmd/pkg/npm_outdated.json b/crates/rskim/tests/fixtures/cmd/pkg/npm_outdated.json new file mode 100644 index 0000000..b7d4981 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/npm_outdated.json @@ -0,0 +1,5 @@ +{ + "lodash": {"current": "4.17.20", "wanted": "4.17.21", "latest": "4.17.21", "dependent": "my-app", "location": "node_modules/lodash"}, + "express": {"current": "4.17.1", "wanted": "4.18.2", "latest": "4.18.2", "dependent": "my-app", "location": "node_modules/express"}, + "typescript": {"current": "4.9.5", "wanted": "5.3.3", "latest": "5.3.3", "dependent": "my-app", "location": "node_modules/typescript"} +} diff --git a/crates/rskim/tests/fixtures/cmd/pkg/pip_check_clean.txt b/crates/rskim/tests/fixtures/cmd/pkg/pip_check_clean.txt new file mode 100644 index 0000000..c688d8c --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/pip_check_clean.txt @@ -0,0 +1 @@ +No broken requirements found. diff --git a/crates/rskim/tests/fixtures/cmd/pkg/pip_check_issues.txt b/crates/rskim/tests/fixtures/cmd/pkg/pip_check_issues.txt new file mode 100644 index 0000000..b3c7686 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/pip_check_issues.txt @@ -0,0 +1,2 @@ +flask 3.0.0 has requirement werkzeug>=3.0.1, which is not installed. +requests 2.28.0 has requirement urllib3<2,>=1.21.1, but you have urllib3 2.0.4. diff --git a/crates/rskim/tests/fixtures/cmd/pkg/pip_install.txt b/crates/rskim/tests/fixtures/cmd/pkg/pip_install.txt new file mode 100644 index 0000000..5192a34 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/pip_install.txt @@ -0,0 +1,8 @@ +Collecting flask>=2.0 + Downloading Flask-3.0.0-py3-none-any.whl (101 kB) +Collecting werkzeug>=3.0 + Downloading werkzeug-3.0.1-py3-none-any.whl (226 kB) +Collecting jinja2>=3.1.2 + Using cached Jinja2-3.1.2-py3-none-any.whl (133 kB) +Installing collected packages: werkzeug, jinja2, flask +Successfully installed flask-3.0.0 jinja2-3.1.2 werkzeug-3.0.1 diff --git a/crates/rskim/tests/fixtures/cmd/pkg/pip_outdated.json b/crates/rskim/tests/fixtures/cmd/pkg/pip_outdated.json new file mode 100644 index 0000000..f8b8f6a --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/pip_outdated.json @@ -0,0 +1,4 @@ +[ + {"name": "flask", "version": "2.3.0", "latest_version": "3.0.0", "latest_filetype": "wheel"}, + {"name": "requests", "version": "2.28.0", "latest_version": "2.31.0", "latest_filetype": "wheel"} +] diff --git a/crates/rskim/tests/fixtures/cmd/pkg/pnpm_audit.json b/crates/rskim/tests/fixtures/cmd/pkg/pnpm_audit.json new file mode 100644 index 0000000..4627a9c --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/pnpm_audit.json @@ -0,0 +1,9 @@ +{ + "advisories": { + "1234": {"module_name": "lodash", "severity": "high", "title": "Prototype Pollution", "url": "https://github.com/advisories/GHSA-1234", "findings": [{"version": "4.17.20", "paths": ["."]}]}, + "5678": {"module_name": "minimist", "severity": "critical", "title": "Prototype Pollution", "url": "https://github.com/advisories/GHSA-5678", "findings": [{"version": "1.2.0", "paths": ["mkdirp"]}]} + }, + "metadata": { + "vulnerabilities": {"info": 0, "low": 0, "moderate": 0, "high": 1, "critical": 1, "total": 2} + } +} diff --git a/crates/rskim/tests/fixtures/cmd/pkg/pnpm_install.txt b/crates/rskim/tests/fixtures/cmd/pkg/pnpm_install.txt new file mode 100644 index 0000000..3abf3bb --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/pnpm_install.txt @@ -0,0 +1,13 @@ +Packages: +127 -3 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +Progress: resolved 245, reused 230, downloaded 15, added 127, done + +dependencies: ++ express 4.18.2 ++ lodash 4.17.21 + +devDependencies: ++ typescript 5.3.3 ++ vitest 1.2.0 + +Done in 4.2s diff --git a/crates/rskim/tests/fixtures/cmd/pkg/pnpm_outdated.json b/crates/rskim/tests/fixtures/cmd/pkg/pnpm_outdated.json new file mode 100644 index 0000000..add7406 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/pkg/pnpm_outdated.json @@ -0,0 +1,4 @@ +{ + "lodash": {"current": "4.17.20", "wanted": "4.17.21", "latest": "4.17.21"}, + "express": {"current": "4.17.1", "wanted": "4.18.2", "latest": "4.18.2"} +}