diff --git a/crates/rskim/src/analytics/mod.rs b/crates/rskim/src/analytics/mod.rs index 5bc3127..9dbafe3 100644 --- a/crates/rskim/src/analytics/mod.rs +++ b/crates/rskim/src/analytics/mod.rs @@ -39,6 +39,8 @@ pub(crate) enum CommandType { Test, Build, Git, + Lint, + Pkg, } impl CommandType { @@ -48,6 +50,8 @@ impl CommandType { CommandType::Test => "test", CommandType::Build => "build", CommandType::Git => "git", + CommandType::Lint => "lint", + CommandType::Pkg => "pkg", } } } diff --git a/crates/rskim/src/cmd/lint/eslint.rs b/crates/rskim/src/cmd/lint/eslint.rs new file mode 100644 index 0000000..2a1717b --- /dev/null +++ b/crates/rskim/src/cmd/lint/eslint.rs @@ -0,0 +1,278 @@ +//! ESLint parser with three-tier degradation (#104). +//! +//! Executes `eslint` and parses the output into a structured `LintResult`. +//! +//! Three tiers: +//! - **Tier 1 (Full)**: JSON array parsing (`--format json`) +//! - **Tier 2 (Degraded)**: Regex on default formatter output +//! - **Tier 3 (Passthrough)**: Raw stdout+stderr concatenation + +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{LintIssue, LintResult, LintSeverity}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::{combine_stdout_stderr, group_issues, LinterConfig}; + +const CONFIG: LinterConfig<'static> = LinterConfig { + program: "eslint", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install eslint via npm: npm install -g eslint", +}; + +// Static regex patterns compiled once via LazyLock. +static RE_ESLINT_LINE: LazyLock = LazyLock::new(|| { + // Matches: " 12:7 warning 'x' is defined but never used no-unused-vars" + Regex::new(r"^\s+(\d+):\d+\s+(error|warning)\s+(.+?)\s{2,}(\S+)\s*$").unwrap() +}); + +static RE_ESLINT_FILE: LazyLock = + LazyLock::new(|| Regex::new(r"^(/[^\s]+|[A-Z]:\\[^\s]+)$").unwrap()); + +/// Run `skim lint eslint [args...]`. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_linter( + CONFIG, + args, + show_stats, + json_output, + prepare_args, + parse_impl, + ) +} + +/// Inject `--format json` if not already present. +fn prepare_args(cmd_args: &mut Vec) { + if !user_has_flag(cmd_args, &["--format", "-f"]) { + cmd_args.insert(0, "json".to_string()); + cmd_args.insert(0, "--format".to_string()); + } +} + +/// Three-tier parse function for eslint output. +fn parse_impl(output: &CommandOutput) -> ParseResult { + if let Some(result) = try_parse_json(&output.stdout) { + return ParseResult::Full(result); + } + + let combined = combine_stdout_stderr(output); + + if let Some(result) = try_parse_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + ParseResult::Passthrough(combined.into_owned()) +} + +// ============================================================================ +// Tier 1: JSON parsing +// ============================================================================ + +/// Parse eslint JSON output format. +/// +/// ESLint `--format json` produces an array of file results: +/// ```json +/// [{"filePath": "...", "messages": [{"ruleId": "...", "severity": 1, "message": "...", "line": 12}]}] +/// ``` +fn try_parse_json(stdout: &str) -> Option { + let arr: Vec = serde_json::from_str(stdout.trim()).ok()?; + + let mut issues: Vec = Vec::new(); + + for file_entry in &arr { + let Some(file_path) = file_entry.get("filePath").and_then(|v| v.as_str()) else { + continue; + }; + let Some(messages) = file_entry.get("messages").and_then(|v| v.as_array()) else { + continue; + }; + + for msg in messages { + let Some(severity_num) = msg.get("severity").and_then(|v| v.as_u64()) else { + continue; + }; + let severity = match severity_num { + 2 => LintSeverity::Error, + 1 => LintSeverity::Warning, + _ => LintSeverity::Info, + }; + let rule_id = msg + .get("ruleId") + .and_then(|v| v.as_str()) + .unwrap_or("(unknown)"); + let Some(message) = msg.get("message").and_then(|v| v.as_str()) else { + continue; + }; + let Some(line) = msg.get("line").and_then(|v| v.as_u64()) else { + continue; + }; + + issues.push(LintIssue { + file: file_path.to_string(), + line: u32::try_from(line).unwrap_or(u32::MAX), + rule: rule_id.to_string(), + message: message.to_string(), + severity, + }); + } + } + + Some(group_issues("eslint", issues)) +} + +// ============================================================================ +// Tier 2: regex fallback +// ============================================================================ + +/// Parse eslint default formatter output via regex. +/// +/// Format: +/// ```text +/// /path/to/file.ts +/// 12:7 warning 'x' is defined but never used no-unused-vars +/// ``` +fn try_parse_regex(text: &str) -> Option { + let mut issues: Vec = Vec::new(); + let mut current_file = String::new(); + + for line in text.lines() { + // Try to match a file path line + if RE_ESLINT_FILE.is_match(line.trim()) { + current_file = line.trim().to_string(); + continue; + } + + // Try to match an issue line + if let Some(caps) = RE_ESLINT_LINE.captures(line) { + let line_num: u32 = caps[1].parse().unwrap_or(0); + let severity = match &caps[2] { + "error" => LintSeverity::Error, + "warning" => LintSeverity::Warning, + _ => LintSeverity::Info, + }; + let message = caps[3].to_string(); + let rule = caps[4].to_string(); + + issues.push(LintIssue { + file: current_file.clone(), + line: line_num, + rule, + message, + severity, + }); + } + } + + if issues.is_empty() { + return None; + } + + Some(group_issues("eslint", issues)) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn load_fixture(name: &str) -> String { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/lint"); + path.push(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + #[test] + fn test_tier1_eslint_pass() { + let input = load_fixture("eslint_pass.json"); + let result = try_parse_json(&input); + assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 0); + assert_eq!(result.warnings, 0); + assert!(result.as_ref().contains("LINT OK")); + } + + #[test] + fn test_tier1_eslint_fail() { + let input = load_fixture("eslint_fail.json"); + let result = try_parse_json(&input); + assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 2); + assert_eq!(result.warnings, 3); + assert!(result.groups.len() >= 2, "Expected at least 2 rule groups"); + } + + #[test] + fn test_tier2_eslint_regex() { + let input = load_fixture("eslint_text.txt"); + let result = try_parse_regex(&input); + assert!(result.is_some(), "Expected Tier 2 regex parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 2); + assert_eq!(result.warnings, 2); + } + + #[test] + fn test_parse_impl_json_produces_full() { + let input = load_fixture("eslint_fail.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_full(), + "Expected Full parse result, got {}", + result.tier_name() + ); + } + + #[test] + fn test_parse_impl_text_produces_degraded() { + let input = load_fixture("eslint_text.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_degraded(), + "Expected Degraded parse result, got {}", + result.tier_name() + ); + } + + #[test] + fn test_parse_impl_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "completely unparseable output\nno json, no regex match".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_passthrough(), + "Expected Passthrough, got {}", + result.tier_name() + ); + } +} diff --git a/crates/rskim/src/cmd/lint/golangci.rs b/crates/rskim/src/cmd/lint/golangci.rs new file mode 100644 index 0000000..f1f5666 --- /dev/null +++ b/crates/rskim/src/cmd/lint/golangci.rs @@ -0,0 +1,264 @@ +//! golangci-lint parser with three-tier degradation (#104). +//! +//! Executes `golangci-lint run` and parses the output into a structured `LintResult`. +//! +//! Three tiers: +//! - **Tier 1 (Full)**: JSON object parsing (`--out-format json`) +//! - **Tier 2 (Degraded)**: Regex on default text output +//! - **Tier 3 (Passthrough)**: Raw stdout+stderr concatenation + +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{LintIssue, LintResult, LintSeverity}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::{combine_stdout_stderr, group_issues, LinterConfig}; + +const CONFIG: LinterConfig<'static> = LinterConfig { + program: "golangci-lint", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install golangci-lint: https://golangci-lint.run/welcome/install/", +}; + +// Static regex pattern compiled once via LazyLock. +static RE_GOLANGCI_LINE: LazyLock = + LazyLock::new(|| Regex::new(r"^(.+):(\d+)(?::\d+)?:\s+(.+)\s+\((\S+)\)$").unwrap()); + +/// Run `skim lint golangci [args...]`. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_linter( + CONFIG, + args, + show_stats, + json_output, + prepare_args, + parse_impl, + ) +} + +/// Ensure "run" subcommand is present and inject `--out-format json`. +fn prepare_args(cmd_args: &mut Vec) { + // Ensure "run" subcommand is present if args don't start with it + if cmd_args.first().is_none_or(|a| a != "run") { + cmd_args.insert(0, "run".to_string()); + } + + // Inject --out-format json if not already present + if !user_has_flag(cmd_args, &["--out-format"]) { + cmd_args.push("--out-format".to_string()); + cmd_args.push("json".to_string()); + } +} + +/// Three-tier parse function for golangci-lint output. +fn parse_impl(output: &CommandOutput) -> ParseResult { + if let Some(result) = try_parse_json(&output.stdout) { + return ParseResult::Full(result); + } + + let combined = combine_stdout_stderr(output); + + if let Some(result) = try_parse_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + ParseResult::Passthrough(combined.into_owned()) +} + +// ============================================================================ +// Tier 1: JSON parsing +// ============================================================================ + +/// Parse golangci-lint JSON output format. +/// +/// golangci-lint `--out-format json` produces: +/// ```json +/// {"Issues": [{"FromLinter": "govet", "Text": "...", "Pos": {"Filename": "...", "Line": 42}}]} +/// ``` +fn try_parse_json(stdout: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?; + let obj = value.as_object()?; + + // Must have "Issues" key (golangci-lint JSON always has this) + let issues_val = obj.get("Issues")?; + + // Issues can be null (no issues) or an array + let issues_arr = match issues_val { + serde_json::Value::Null => &[] as &[serde_json::Value], + serde_json::Value::Array(arr) => arr.as_slice(), + _ => return None, + }; + + let mut issues: Vec = Vec::new(); + + for entry in issues_arr { + let linter = entry + .get("FromLinter") + .and_then(|v| v.as_str()) + .unwrap_or("(unknown)"); + let text = entry.get("Text").and_then(|v| v.as_str()).unwrap_or(""); + let Some(pos) = entry.get("Pos") else { + continue; + }; + let Some(filename) = pos.get("Filename").and_then(|v| v.as_str()) else { + continue; + }; + let Some(line) = pos.get("Line").and_then(|v| v.as_u64()) else { + continue; + }; + + let severity_str = entry.get("Severity").and_then(|v| v.as_str()).unwrap_or(""); + let severity = match severity_str { + "error" => LintSeverity::Error, + // golangci-lint defaults to warning when Severity is empty/absent/unknown + _ => LintSeverity::Warning, + }; + + issues.push(LintIssue { + file: filename.to_string(), + line: u32::try_from(line).unwrap_or(u32::MAX), + rule: linter.to_string(), + message: text.to_string(), + severity, + }); + } + + Some(group_issues("golangci", issues)) +} + +// ============================================================================ +// Tier 2: regex fallback +// ============================================================================ + +/// Parse golangci-lint default text output via regex. +/// +/// Format: `file:line:col: message (linter)` +/// +/// **Known limitation:** The golangci-lint text format does not include severity +/// information, so all Tier 2 issues default to `Warning`. Accurate severity is +/// only available via Tier 1 JSON parsing (the `Severity` field in JSON output). +fn try_parse_regex(text: &str) -> Option { + let mut issues: Vec = Vec::new(); + + for line in text.lines() { + if let Some(caps) = RE_GOLANGCI_LINE.captures(line) { + let file = caps[1].to_string(); + let line_num: u32 = caps[2].parse().unwrap_or(0); + let message = caps[3].to_string(); + let linter = caps[4].to_string(); + + issues.push(LintIssue { + file, + line: line_num, + rule: linter, + message, + // Text format lacks severity; default to Warning (see doc comment above). + severity: LintSeverity::Warning, + }); + } + } + + if issues.is_empty() { + return None; + } + + Some(group_issues("golangci", issues)) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn load_fixture(name: &str) -> String { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/lint"); + path.push(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + #[test] + fn test_tier1_golangci_fail() { + let input = load_fixture("golangci_fail.json"); + let result = try_parse_json(&input); + assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed"); + let result = result.unwrap(); + // 4 issues total: 1 error (staticcheck), 3 warnings (govet x2 + errcheck) + assert_eq!(result.errors, 1); + assert_eq!(result.warnings, 3); + } + + #[test] + fn test_tier1_golangci_null_issues() { + let input = r#"{"Issues": null}"#; + let result = try_parse_json(input); + assert!(result.is_some()); + let result = result.unwrap(); + assert_eq!(result.errors, 0); + assert_eq!(result.warnings, 0); + assert!(result.as_ref().contains("LINT OK")); + } + + #[test] + fn test_tier2_golangci_regex() { + let input = load_fixture("golangci_text.txt"); + let result = try_parse_regex(&input); + assert!(result.is_some(), "Expected Tier 2 regex parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.warnings, 4); + } + + #[test] + fn test_parse_impl_json_produces_full() { + let input = load_fixture("golangci_fail.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_full()); + } + + #[test] + fn test_parse_impl_text_produces_degraded() { + let input = load_fixture("golangci_text.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_degraded(), + "Expected Degraded parse result, got {}", + result.tier_name() + ); + } + + #[test] + fn test_parse_impl_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "random garbage".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_passthrough()); + } +} diff --git a/crates/rskim/src/cmd/lint/mod.rs b/crates/rskim/src/cmd/lint/mod.rs new file mode 100644 index 0000000..7b2def3 --- /dev/null +++ b/crates/rskim/src/cmd/lint/mod.rs @@ -0,0 +1,301 @@ +//! Lint subcommand dispatcher (#104) +//! +//! Routes `skim lint [args...]` to the appropriate linter parser. +//! Currently supported linters: `eslint`, `ruff`, `mypy`, `golangci`. + +pub(crate) mod eslint; +pub(crate) mod golangci; +pub(crate) mod mypy; +pub(crate) mod ruff; + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::io::IsTerminal; +use std::process::ExitCode; + +use super::{extract_show_stats, run_parsed_command_with_mode, OutputFormat, ParsedCommandConfig}; +use crate::output::canonical::{LintGroup, LintIssue, LintResult, LintSeverity}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +/// Known linters that `skim lint` can dispatch to. +const KNOWN_LINTERS: &[&str] = &["eslint", "ruff", "mypy", "golangci"]; + +/// Entry point for `skim lint [args...]`. +/// +/// If no linter is specified or `--help` / `-h` is passed, prints usage +/// and exits. Otherwise dispatches to the linter-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) = extract_show_stats(args); + + // Extract --json flag + let (filtered_args, json_output) = extract_json_flag(&filtered_args); + + let Some((linter_name, linter_args)) = filtered_args.split_first() else { + print_help(); + return Ok(ExitCode::SUCCESS); + }; + + let linter = linter_name.as_str(); + + match linter { + "eslint" => eslint::run(linter_args, show_stats, json_output), + "ruff" => ruff::run(linter_args, show_stats, json_output), + "mypy" => mypy::run(linter_args, show_stats, json_output), + "golangci" => golangci::run(linter_args, show_stats, json_output), + _ => { + eprintln!( + "skim lint: unknown linter '{linter}'\n\ + Available linters: {}\n\ + Run 'skim lint --help' for usage information", + KNOWN_LINTERS.join(", ") + ); + Ok(ExitCode::FAILURE) + } + } +} + +fn print_help() { + println!("skim lint [args...]"); + println!(); + println!(" Run linters and parse the output for AI context windows."); + println!(); + println!("Available linters:"); + for linter in KNOWN_LINTERS { + println!(" {linter}"); + } + println!(); + println!("Flags:"); + println!(" --json Emit structured JSON output"); + println!(" --show-stats Show token statistics"); + println!(); + println!("Examples:"); + println!(" skim lint eslint . Run eslint"); + println!(" skim lint ruff check . Run ruff check"); + println!(" skim lint mypy src/ Run mypy"); + println!(" skim lint golangci run ./... Run golangci-lint"); + println!(" eslint . 2>&1 | skim lint eslint Pipe eslint output"); +} + +/// Extract `--json` flag from args, returning (filtered_args, json_output). +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) +} + +// ============================================================================ +// Shared linter execution helper +// ============================================================================ + +/// Static configuration for a linter binary. +/// +/// Each linter module exposes a `CONFIG` constant with this type. +pub(crate) struct LinterConfig<'a> { + /// Binary name of the linter (e.g., "eslint", "ruff"). + pub program: &'a str, + /// Environment variable overrides for the child process. + pub env_overrides: &'a [(&'a str, &'a str)], + /// Hint printed when the linter binary is not found. + pub install_hint: &'a str, +} + +/// Execute a linter, parse its output, and emit the result. +/// +/// This is the single implementation shared by all lint parsers, handling both +/// text and JSON output modes. It eliminates per-linter `run()` boilerplate by +/// delegating to [`super::run_parsed_command_with_mode`]. +/// +/// - `config`: static linter metadata (program name, env vars, install hint) +/// - `args`: raw user args (before prepare_args) +/// - `show_stats`: whether to report token statistics +/// - `json_output`: whether to emit JSON instead of text +/// - `prepare_args`: closure to inject linter-specific flags (e.g., `--format json`) +/// - `parse_fn`: linter-specific three-tier parse function +pub(crate) fn run_linter( + config: LinterConfig<'_>, + args: &[String], + show_stats: bool, + json_output: bool, + prepare_args: impl FnOnce(&mut Vec), + parse_fn: impl FnOnce(&CommandOutput) -> ParseResult, +) -> anyhow::Result { + let mut cmd_args = args.to_vec(); + prepare_args(&mut cmd_args); + + let use_stdin = !std::io::stdin().is_terminal() && args.is_empty(); + let output_format = if json_output { + OutputFormat::Json + } else { + OutputFormat::Text + }; + + run_parsed_command_with_mode( + 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::Lint, + output_format, + }, + |output, _args| parse_fn(output), + ) +} + +/// Combine stdout and stderr into a single string for regex fallback parsing. +/// +/// Returns a borrowed reference to stdout when stderr is empty to avoid an +/// unnecessary allocation. +pub(crate) fn combine_stdout_stderr(output: &CommandOutput) -> Cow<'_, str> { + if output.stderr.is_empty() { + Cow::Borrowed(&output.stdout) + } else { + Cow::Owned(format!("{}\n{}", output.stdout, output.stderr)) + } +} + +/// Group individual lint issues by rule into a `LintResult`. +/// +/// Uses `BTreeMap` for deterministic ordering of rule groups. +pub(crate) fn group_issues(tool: &str, issues: Vec) -> LintResult { + let mut groups: BTreeMap = BTreeMap::new(); + let mut errors = 0usize; + let mut warnings = 0usize; + for issue in issues { + match issue.severity { + LintSeverity::Error => errors += 1, + LintSeverity::Warning => warnings += 1, + LintSeverity::Info => {} + } + let location = format!("{}:{}", issue.file, issue.line); + let group = groups + .entry(issue.rule.clone()) + .or_insert_with(|| LintGroup { + rule: issue.rule, + count: 0, + severity: issue.severity, + locations: Vec::new(), + }); + group.count += 1; + group.locations.push(location); + } + LintResult::new( + tool.to_string(), + errors, + warnings, + groups.into_values().collect(), + ) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::output::canonical::{LintIssue, LintSeverity}; + + #[test] + fn test_group_issues_info_severity_not_counted() { + let issues = vec![ + LintIssue { + file: "a.ts".to_string(), + line: 1, + rule: "info-rule".to_string(), + message: "informational".to_string(), + severity: LintSeverity::Info, + }, + LintIssue { + file: "a.ts".to_string(), + line: 2, + rule: "err-rule".to_string(), + message: "real error".to_string(), + severity: LintSeverity::Error, + }, + ]; + let result = group_issues("test", issues); + assert_eq!(result.errors, 1); + assert_eq!(result.warnings, 0); + // Info issue is grouped but not counted as error or warning + assert_eq!(result.groups.len(), 2); + } + + #[test] + fn test_group_issues_empty() { + let result = group_issues("test", vec![]); + assert_eq!(result.errors, 0); + assert_eq!(result.warnings, 0); + assert!(result.groups.is_empty()); + assert!(result.as_ref().contains("LINT OK")); + } + + #[test] + fn test_combine_stdout_stderr_empty_stderr() { + let output = CommandOutput { + stdout: "hello world".to_string(), + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let combined = combine_stdout_stderr(&output); + assert_eq!(&*combined, "hello world"); + // When stderr is empty, should borrow (Cow::Borrowed) + assert!(matches!(combined, Cow::Borrowed(_))); + } + + #[test] + fn test_combine_stdout_stderr_with_stderr() { + let output = CommandOutput { + stdout: "out".to_string(), + stderr: "err".to_string(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let combined = combine_stdout_stderr(&output); + assert_eq!(&*combined, "out\nerr"); + // When stderr is non-empty, should own (Cow::Owned) + assert!(matches!(combined, Cow::Owned(_))); + } + + #[test] + fn test_combine_stdout_stderr_both_empty() { + let output = CommandOutput { + stdout: String::new(), + stderr: String::new(), + exit_code: Some(0), + duration: std::time::Duration::ZERO, + }; + let combined = combine_stdout_stderr(&output); + assert_eq!(&*combined, ""); + assert!(matches!(combined, Cow::Borrowed(_))); + } + + #[test] + fn test_extract_json_flag_present() { + let args = vec!["--json".to_string(), "eslint".to_string(), ".".to_string()]; + let (filtered, json_output) = extract_json_flag(&args); + assert!(json_output); + assert_eq!(filtered, vec!["eslint".to_string(), ".".to_string()]); + } + + #[test] + fn test_extract_json_flag_absent() { + let args = vec!["eslint".to_string(), ".".to_string()]; + let (filtered, json_output) = extract_json_flag(&args); + assert!(!json_output); + assert_eq!(filtered, vec!["eslint".to_string(), ".".to_string()]); + } +} diff --git a/crates/rskim/src/cmd/lint/mypy.rs b/crates/rskim/src/cmd/lint/mypy.rs new file mode 100644 index 0000000..39cf18c --- /dev/null +++ b/crates/rskim/src/cmd/lint/mypy.rs @@ -0,0 +1,257 @@ +//! mypy parser with three-tier degradation (#104). +//! +//! Executes `mypy` and parses the output into a structured `LintResult`. +//! +//! Three tiers: +//! - **Tier 1 (Full)**: NDJSON parsing (`--output json`) +//! - **Tier 2 (Degraded)**: Regex on default text output +//! - **Tier 3 (Passthrough)**: Raw stdout+stderr concatenation + +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{LintIssue, LintResult, LintSeverity}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::{combine_stdout_stderr, group_issues, LinterConfig}; + +const CONFIG: LinterConfig<'static> = LinterConfig { + program: "mypy", + env_overrides: &[("NO_COLOR", "1"), ("MYPY_FORCE_COLOR", "0")], + install_hint: "Install mypy: pip install mypy", +}; + +// Static regex pattern compiled once via LazyLock. +static RE_MYPY_LINE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(.+):(\d+):\s+(error|warning|note):\s+(.+?)(?:\s+\[(\S+)\])?$").unwrap() +}); + +/// Run `skim lint mypy [args...]`. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_linter( + CONFIG, + args, + show_stats, + json_output, + prepare_args, + parse_impl, + ) +} + +/// Inject `--output json` if not already present. +fn prepare_args(cmd_args: &mut Vec) { + if !user_has_flag(cmd_args, &["--output"]) { + cmd_args.insert(0, "json".to_string()); + cmd_args.insert(0, "--output".to_string()); + } +} + +/// Three-tier parse function for mypy output. +fn parse_impl(output: &CommandOutput) -> ParseResult { + if let Some(result) = try_parse_json(&output.stdout) { + return ParseResult::Full(result); + } + + let combined = combine_stdout_stderr(output); + + if let Some(result) = try_parse_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + ParseResult::Passthrough(combined.into_owned()) +} + +// ============================================================================ +// Tier 1: NDJSON parsing +// ============================================================================ + +/// Parse mypy NDJSON output format. +/// +/// mypy `--output json` produces one JSON object per line (NDJSON): +/// ```json +/// {"file": "...", "line": 10, "column": 5, "message": "...", "code": "...", "severity": "error"} +/// ``` +fn try_parse_json(stdout: &str) -> Option { + let mut issues: Vec = Vec::new(); + let mut any_parsed = false; + + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + + // Must have at least "file" and "line" fields to be a valid mypy JSON entry + let Some(file) = value.get("file").and_then(|v| v.as_str()) else { + continue; + }; + let Some(line_num) = value.get("line").and_then(|v| v.as_u64()) else { + continue; + }; + + any_parsed = true; + + let severity_str = value + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("error"); + let severity = match severity_str { + "error" => LintSeverity::Error, + "warning" => LintSeverity::Warning, + "note" => LintSeverity::Info, + _ => LintSeverity::Error, + }; + + let code = value + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("(unknown)"); + let message = value.get("message").and_then(|v| v.as_str()).unwrap_or(""); + + issues.push(LintIssue { + file: file.to_string(), + line: u32::try_from(line_num).unwrap_or(u32::MAX), + rule: code.to_string(), + message: message.to_string(), + severity, + }); + } + + if !any_parsed { + return None; + } + + Some(group_issues("mypy", issues)) +} + +// ============================================================================ +// Tier 2: regex fallback +// ============================================================================ + +/// Parse mypy default text output via regex. +/// +/// Format: `file:line: error: message [code]` +fn try_parse_regex(text: &str) -> Option { + let mut issues: Vec = Vec::new(); + + for line in text.lines() { + if let Some(caps) = RE_MYPY_LINE.captures(line) { + let file = caps[1].to_string(); + let line_num: u32 = caps[2].parse().unwrap_or(0); + let severity = match &caps[3] { + "error" => LintSeverity::Error, + "warning" => LintSeverity::Warning, + "note" => LintSeverity::Info, + _ => LintSeverity::Error, + }; + let message = caps[4].to_string(); + let code = caps + .get(5) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "(unknown)".to_string()); + + issues.push(LintIssue { + file, + line: line_num, + rule: code, + message, + severity, + }); + } + } + + if issues.is_empty() { + return None; + } + + Some(group_issues("mypy", issues)) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn load_fixture(name: &str) -> String { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/lint"); + path.push(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + #[test] + fn test_tier1_mypy_fail() { + let input = load_fixture("mypy_fail.json"); + let result = try_parse_json(&input); + assert!(result.is_some(), "Expected Tier 1 NDJSON parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 3); + assert_eq!(result.warnings, 0); + } + + #[test] + fn test_tier2_mypy_regex() { + let input = load_fixture("mypy_text.txt"); + let result = try_parse_regex(&input); + assert!(result.is_some(), "Expected Tier 2 regex parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 3); + } + + #[test] + fn test_parse_impl_json_produces_full() { + let input = load_fixture("mypy_fail.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_full()); + } + + #[test] + fn test_parse_impl_text_produces_degraded() { + let input = load_fixture("mypy_text.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_degraded(), + "Expected Degraded parse result, got {}", + result.tier_name() + ); + } + + #[test] + fn test_parse_impl_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "random garbage".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_passthrough()); + } +} diff --git a/crates/rskim/src/cmd/lint/ruff.rs b/crates/rskim/src/cmd/lint/ruff.rs new file mode 100644 index 0000000..1f03ccb --- /dev/null +++ b/crates/rskim/src/cmd/lint/ruff.rs @@ -0,0 +1,242 @@ +//! Ruff linter parser with three-tier degradation (#104). +//! +//! Executes `ruff check` and parses the output into a structured `LintResult`. +//! +//! Three tiers: +//! - **Tier 1 (Full)**: JSON array parsing (`--output-format json`) +//! - **Tier 2 (Degraded)**: Regex on default text output +//! - **Tier 3 (Passthrough)**: Raw stdout+stderr concatenation + +use std::sync::LazyLock; + +use regex::Regex; + +use crate::cmd::user_has_flag; +use crate::output::canonical::{LintIssue, LintResult, LintSeverity}; +use crate::output::ParseResult; +use crate::runner::CommandOutput; + +use super::{combine_stdout_stderr, group_issues, LinterConfig}; + +const CONFIG: LinterConfig<'static> = LinterConfig { + program: "ruff", + env_overrides: &[("NO_COLOR", "1")], + install_hint: "Install ruff: pip install ruff", +}; + +// Static regex pattern compiled once via LazyLock. +static RE_RUFF_LINE: LazyLock = + LazyLock::new(|| Regex::new(r"^(.+):(\d+):\d+:\s+(\S+)\s+(.+)").unwrap()); + +/// Run `skim lint ruff [args...]`. +pub(crate) fn run( + args: &[String], + show_stats: bool, + json_output: bool, +) -> anyhow::Result { + super::run_linter( + CONFIG, + args, + show_stats, + json_output, + prepare_args, + parse_impl, + ) +} + +/// Ensure "check" subcommand is present and inject `--output-format json`. +fn prepare_args(cmd_args: &mut Vec) { + // Ensure "check" subcommand is present if args don't start with it + if cmd_args.first().is_none_or(|a| a != "check") { + cmd_args.insert(0, "check".to_string()); + } + + // Inject --output-format json if not already present + if !user_has_flag(cmd_args, &["--output-format"]) { + cmd_args.push("--output-format".to_string()); + cmd_args.push("json".to_string()); + } +} + +/// Three-tier parse function for ruff output. +fn parse_impl(output: &CommandOutput) -> ParseResult { + if let Some(result) = try_parse_json(&output.stdout) { + return ParseResult::Full(result); + } + + let combined = combine_stdout_stderr(output); + + if let Some(result) = try_parse_regex(&combined) { + return ParseResult::Degraded(result, vec!["regex fallback".to_string()]); + } + + ParseResult::Passthrough(combined.into_owned()) +} + +// ============================================================================ +// Tier 1: JSON parsing +// ============================================================================ + +/// Parse ruff JSON output format. +/// +/// Ruff `--output-format json` produces an array: +/// ```json +/// [{"code": "F401", "message": "...", "filename": "...", "location": {"row": 1}}] +/// ``` +fn try_parse_json(stdout: &str) -> Option { + let arr: Vec = serde_json::from_str(stdout.trim()).ok()?; + + // An empty array is valid — means clean run + let mut issues: Vec = Vec::new(); + + for entry in &arr { + let Some(code) = entry.get("code").and_then(|v| v.as_str()) else { + continue; + }; + let Some(message) = entry.get("message").and_then(|v| v.as_str()) else { + continue; + }; + let Some(filename) = entry.get("filename").and_then(|v| v.as_str()) else { + continue; + }; + let Some(location) = entry.get("location") else { + continue; + }; + let Some(row) = location.get("row").and_then(|v| v.as_u64()) else { + continue; + }; + + issues.push(LintIssue { + file: filename.to_string(), + line: u32::try_from(row).unwrap_or(u32::MAX), + rule: code.to_string(), + message: message.to_string(), + // Ruff issues are all errors by default (no severity field) + severity: LintSeverity::Error, + }); + } + + Some(group_issues("ruff", issues)) +} + +// ============================================================================ +// Tier 2: regex fallback +// ============================================================================ + +/// Parse ruff default text output via regex. +/// +/// Format: `file:line:col: CODE message` +fn try_parse_regex(text: &str) -> Option { + let mut issues: Vec = Vec::new(); + + for line in text.lines() { + if let Some(caps) = RE_RUFF_LINE.captures(line) { + let file = caps[1].to_string(); + let line_num: u32 = caps[2].parse().unwrap_or(0); + let code = caps[3].to_string(); + let message = caps[4].to_string(); + + issues.push(LintIssue { + file, + line: line_num, + rule: code, + message, + severity: LintSeverity::Error, + }); + } + } + + if issues.is_empty() { + return None; + } + + Some(group_issues("ruff", issues)) +} + +// ============================================================================ +// Unit tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn load_fixture(name: &str) -> String { + let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("tests/fixtures/cmd/lint"); + path.push(name); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}")) + } + + #[test] + fn test_tier1_ruff_fail() { + let input = load_fixture("ruff_fail.json"); + let result = try_parse_json(&input); + assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 3); + assert_eq!(result.warnings, 0); + } + + #[test] + fn test_tier1_ruff_clean() { + let result = try_parse_json("[]"); + assert!(result.is_some()); + let result = result.unwrap(); + assert_eq!(result.errors, 0); + assert_eq!(result.warnings, 0); + assert!(result.as_ref().contains("LINT OK")); + } + + #[test] + fn test_tier2_ruff_regex() { + let input = load_fixture("ruff_text.txt"); + let result = try_parse_regex(&input); + assert!(result.is_some(), "Expected Tier 2 regex parse to succeed"); + let result = result.unwrap(); + assert_eq!(result.errors, 3); + } + + #[test] + fn test_parse_impl_json_produces_full() { + let input = load_fixture("ruff_fail.json"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_full()); + } + + #[test] + fn test_parse_impl_text_produces_degraded() { + let input = load_fixture("ruff_text.txt"); + let output = CommandOutput { + stdout: input, + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!( + result.is_degraded(), + "Expected Degraded parse result, got {}", + result.tier_name() + ); + } + + #[test] + fn test_parse_impl_garbage_produces_passthrough() { + let output = CommandOutput { + stdout: "random garbage".to_string(), + stderr: String::new(), + exit_code: Some(1), + duration: std::time::Duration::ZERO, + }; + let result = parse_impl(&output); + assert!(result.is_passthrough()); + } +} diff --git a/crates/rskim/src/cmd/mod.rs b/crates/rskim/src/cmd/mod.rs index ed5908b..665ba56 100644 --- a/crates/rskim/src/cmd/mod.rs +++ b/crates/rskim/src/cmd/mod.rs @@ -15,6 +15,8 @@ mod hooks; mod init; mod integrity; mod learn; +mod lint; +mod pkg; mod rewrite; mod session; mod stats; @@ -38,6 +40,8 @@ pub(crate) const KNOWN_SUBCOMMANDS: &[&str] = &[ "git", "init", "learn", + "lint", + "pkg", "rewrite", "stats", "test", @@ -92,6 +96,16 @@ pub(crate) fn inject_flag_before_separator(args: &mut Vec, flag: &str) { } } +/// Controls the output format of parsed command results. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum OutputFormat { + /// Render the parsed result as human-readable text (default). + #[default] + Text, + /// Serialize the parsed result as JSON (for `--json` flag). + Json, +} + /// Configuration for running an external command with parsed output. /// /// Groups the cross-cutting parameters for [`run_parsed_command_with_mode`] @@ -104,6 +118,7 @@ pub(crate) struct ParsedCommandConfig<'a> { pub use_stdin: bool, pub show_stats: bool, pub command_type: crate::analytics::CommandType, + pub output_format: OutputFormat, } /// Execute an external command, parse its output, and emit the result. @@ -122,7 +137,7 @@ pub(crate) fn run_parsed_command( parse: impl FnOnce(&CommandOutput, &[String]) -> ParseResult, ) -> anyhow::Result where - T: AsRef, + T: AsRef + serde::Serialize, { let use_stdin = !io::stdin().is_terminal(); let config = ParsedCommandConfig { @@ -133,6 +148,7 @@ where use_stdin, show_stats, command_type, + output_format: OutputFormat::default(), }; run_parsed_command_with_mode(config, parse) } @@ -155,7 +171,7 @@ pub(crate) fn run_parsed_command_with_mode( parse: impl FnOnce(&CommandOutput, &[String]) -> ParseResult, ) -> anyhow::Result where - T: AsRef, + T: AsRef + serde::Serialize, { /// Maximum bytes we will read from stdin (64 MiB), consistent with the /// runner's `MAX_OUTPUT_BYTES` limit for command output pipes. @@ -169,6 +185,7 @@ where use_stdin, show_stats, command_type, + output_format, } = config; let output = if use_stdin { @@ -218,36 +235,43 @@ where let result = parse(&output, args); // Emit markers (warnings/notices) to stderr - let stderr_stream = io::stderr(); - let mut stderr_handle = stderr_stream.lock(); - let _ = result.emit_markers(&mut stderr_handle); - drop(stderr_handle); + let _ = result.emit_markers(&mut io::stderr().lock()); - // Emit content to stdout - let stdout_stream = io::stdout(); - let mut stdout_handle = stdout_stream.lock(); - write!(stdout_handle, "{}", result.content())?; - // Ensure trailing newline - if !result.content().is_empty() && !result.content().ends_with('\n') { - writeln!(stdout_handle)?; - } - stdout_handle.flush()?; + // Capture exit code before moving stdout into analytics + let code = output.exit_code.unwrap_or(1); + + // Render output and capture the compressed content string for stats/analytics. + let compressed: String = match output_format { + OutputFormat::Json => { + let json_str = result.to_json_envelope()?; + let mut handle = io::stdout().lock(); + writeln!(handle, "{json_str}")?; + handle.flush()?; + json_str + } + OutputFormat::Text => { + let content = result.content(); + let mut handle = io::stdout().lock(); + write!(handle, "{content}")?; + if !content.is_empty() && !content.ends_with('\n') { + writeln!(handle)?; + } + handle.flush()?; + content.to_string() + } + }; - // Report token stats if requested if show_stats { - let (orig, comp) = crate::process::count_token_pair(&output.stdout, result.content()); + let (orig, comp) = crate::process::count_token_pair(&output.stdout, &compressed); crate::process::report_token_stats(orig, comp, ""); } - // Capture exit code before moving stdout into analytics - let code = output.exit_code.unwrap_or(1); - // Record analytics (fire-and-forget, non-blocking). - // Guard to avoid .to_string() allocation when analytics are disabled. + // Guard to avoid allocation when analytics are disabled. if crate::analytics::is_analytics_enabled() { crate::analytics::try_record_command( output.stdout, - result.content().to_string(), + compressed, format!("skim {program} {}", args.join(" ")), command_type, output.duration, @@ -284,6 +308,8 @@ pub(crate) fn dispatch(subcommand: &str, args: &[String]) -> anyhow::Result git::run(args), "init" => init::run(args), "learn" => learn::run(args), + "lint" => lint::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..2288463 --- /dev/null +++ b/crates/rskim/src/cmd/pkg/mod.rs @@ -0,0 +1,260 @@ +//! 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 + serde::Serialize, +{ + 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_format: crate::cmd::OutputFormat::default(), + }, + |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..2fffcea 100644 --- a/crates/rskim/src/cmd/rewrite.rs +++ b/crates/rskim/src/cmd/rewrite.rs @@ -34,6 +34,8 @@ enum RewriteCategory { Build, Git, Read, + Lint, + Pkg, } struct RewriteRule { @@ -122,7 +124,7 @@ fn serialize_category( } // ============================================================================ -// Rule table (15 rules, ordered longest-prefix-first within same leading token) +// Rule table (44 rules, ordered longest-prefix-first within same leading token) // ============================================================================ const REWRITE_RULES: &[RewriteRule] = &[ @@ -139,6 +141,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 +230,169 @@ const REWRITE_RULES: &[RewriteRule] = &[ skip_if_flag_prefix: &[], category: RewriteCategory::Build, }, + // lint — eslint + RewriteRule { + prefix: &["npx", "eslint"], + rewrite_to: &["skim", "lint", "eslint"], + skip_if_flag_prefix: &["--format", "-f"], + category: RewriteCategory::Lint, + }, + RewriteRule { + prefix: &["eslint"], + rewrite_to: &["skim", "lint", "eslint"], + skip_if_flag_prefix: &["--format", "-f"], + category: RewriteCategory::Lint, + }, + // lint — ruff + RewriteRule { + prefix: &["ruff", "check"], + rewrite_to: &["skim", "lint", "ruff"], + skip_if_flag_prefix: &["--output-format"], + category: RewriteCategory::Lint, + }, + RewriteRule { + prefix: &["ruff"], + rewrite_to: &["skim", "lint", "ruff"], + skip_if_flag_prefix: &["--output-format"], + category: RewriteCategory::Lint, + }, + // lint — mypy (longest prefix first: python3 -m mypy, python -m mypy, mypy) + RewriteRule { + prefix: &["python3", "-m", "mypy"], + rewrite_to: &["skim", "lint", "mypy"], + skip_if_flag_prefix: &["--output"], + category: RewriteCategory::Lint, + }, + RewriteRule { + prefix: &["python", "-m", "mypy"], + rewrite_to: &["skim", "lint", "mypy"], + skip_if_flag_prefix: &["--output"], + category: RewriteCategory::Lint, + }, + RewriteRule { + prefix: &["mypy"], + rewrite_to: &["skim", "lint", "mypy"], + skip_if_flag_prefix: &["--output"], + category: RewriteCategory::Lint, + }, + // lint — golangci-lint + RewriteRule { + prefix: &["golangci-lint", "run"], + rewrite_to: &["skim", "lint", "golangci"], + skip_if_flag_prefix: &["--out-format"], + category: RewriteCategory::Lint, + }, + RewriteRule { + prefix: &["golangci-lint"], + rewrite_to: &["skim", "lint", "golangci"], + skip_if_flag_prefix: &["--out-format"], + category: RewriteCategory::Lint, + }, + // 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 +1622,7 @@ mod tests { use super::*; // ======================================================================== - // Prefix rule matches (all 15 rules) + // Prefix rule matches (all 34 rules) // ======================================================================== #[test] @@ -1565,6 +1736,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/cmd/test/cargo.rs b/crates/rskim/src/cmd/test/cargo.rs index 0573df5..d0cc3dc 100644 --- a/crates/rskim/src/cmd/test/cargo.rs +++ b/crates/rskim/src/cmd/test/cargo.rs @@ -22,7 +22,8 @@ use std::sync::LazyLock; use regex::Regex; use crate::cmd::{ - inject_flag_before_separator, run_parsed_command_with_mode, user_has_flag, ParsedCommandConfig, + inject_flag_before_separator, run_parsed_command_with_mode, user_has_flag, OutputFormat, + ParsedCommandConfig, }; use crate::output::canonical::{TestEntry, TestOutcome, TestResult, TestSummary}; use crate::output::ParseResult; @@ -73,6 +74,7 @@ pub(crate) fn run(args: &[String], show_stats: bool) -> anyhow::Result use_stdin, show_stats, command_type: crate::analytics::CommandType::Test, + output_format: OutputFormat::default(), }, move |output, _args| parse_impl(output, is_nextest), ) diff --git a/crates/rskim/src/output/canonical.rs b/crates/rskim/src/output/canonical.rs index 91801b1..3835a67 100644 --- a/crates/rskim/src/output/canonical.rs +++ b/crates/rskim/src/output/canonical.rs @@ -265,6 +265,244 @@ impl fmt::Display for BuildResult { } } +// ============================================================================ +// LintResult types +// ============================================================================ + +/// Severity level for a lint issue +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum LintSeverity { + Error, + Warning, + Info, +} + +impl fmt::Display for LintSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LintSeverity::Error => write!(f, "error"), + LintSeverity::Warning => write!(f, "warning"), + LintSeverity::Info => write!(f, "info"), + } + } +} + +/// A single lint issue from any linter +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct LintIssue { + pub(crate) file: String, + pub(crate) line: u32, + pub(crate) rule: String, + pub(crate) message: String, + pub(crate) severity: LintSeverity, +} + +/// A group of lint issues sharing the same rule +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct LintGroup { + pub(crate) rule: String, + pub(crate) count: usize, + pub(crate) severity: LintSeverity, + pub(crate) locations: Vec, +} + +/// Complete lint result with summary and grouped issues +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct LintResult { + pub(crate) tool: String, + pub(crate) errors: usize, + pub(crate) warnings: usize, + pub(crate) groups: Vec, + #[serde(default)] + rendered: String, +} + +// ============================================================================ +// 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 LintResult { + /// Create a new LintResult with pre-computed rendered output + pub(crate) fn new( + tool: String, + errors: usize, + warnings: usize, + groups: Vec, + ) -> Self { + let rendered = Self::render(&tool, errors, warnings, &groups); + Self { + tool, + errors, + warnings, + groups, + 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.errors, self.warnings, &self.groups); + } + } + + fn render(tool: &str, errors: usize, warnings: usize, groups: &[LintGroup]) -> String { + use std::fmt::Write; + + let total = errors + warnings; + if total == 0 { + return format!("LINT OK | {tool} | 0 issues"); + } + + let mut output = format!("LINT: {errors} errors, {warnings} warnings | {tool}"); + for group in groups { + let suffix = if group.count == 1 { "" } else { "s" }; + let _ = write!( + output, + "\n {} ({} {}{suffix}):", + group.rule, group.count, group.severity + ); + for loc in &group.locations { + let _ = write!(output, "\n {loc}"); + } + } + + output + } +} + +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 LintResult { + fn as_ref(&self) -> &str { + &self.rendered + } +} + +impl AsRef for PkgResult { + fn as_ref(&self) -> &str { + &self.rendered + } +} + +impl fmt::Display for LintResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.rendered) + } +} + +impl fmt::Display for PkgResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.rendered) + } +} + // ============================================================================ // Helpers // ============================================================================ @@ -531,4 +769,205 @@ mod tests { assert_eq!(format!("{}", TestOutcome::Fail), "FAIL"); assert_eq!(format!("{}", TestOutcome::Skip), "SKIP"); } + + // ======================================================================== + // LintResult Display tests + // ======================================================================== + + #[test] + fn test_lint_result_display_clean() { + let result = LintResult::new("eslint".to_string(), 0, 0, vec![]); + assert_eq!(format!("{result}"), "LINT OK | eslint | 0 issues"); + } + + #[test] + fn test_lint_result_display_with_issues() { + let groups = vec![ + LintGroup { + rule: "no-unused-vars".to_string(), + count: 3, + severity: LintSeverity::Warning, + locations: vec![ + "src/api/auth.ts:12".to_string(), + "src/api/users.ts:34".to_string(), + "src/utils/format.ts:8".to_string(), + ], + }, + LintGroup { + rule: "@typescript-eslint/no-explicit-any".to_string(), + count: 2, + severity: LintSeverity::Error, + locations: vec![ + "src/types.ts:45".to_string(), + "src/api/client.ts:89".to_string(), + ], + }, + ]; + let result = LintResult::new("eslint".to_string(), 2, 3, groups); + let display = format!("{result}"); + assert!(display.contains("LINT: 2 errors, 3 warnings | eslint")); + assert!(display.contains("no-unused-vars (3 warnings):")); + assert!(display.contains("src/api/auth.ts:12")); + assert!(display.contains("@typescript-eslint/no-explicit-any (2 errors):")); + assert!(display.contains("src/types.ts:45")); + } + + #[test] + fn test_lint_result_serde_roundtrip() { + let groups = vec![LintGroup { + rule: "F401".to_string(), + count: 2, + severity: LintSeverity::Error, + locations: vec!["src/main.py:1".to_string(), "src/main.py:2".to_string()], + }]; + let original = LintResult::new("ruff".to_string(), 2, 0, groups); + let json = serde_json::to_string(&original).unwrap(); + let mut deserialized: LintResult = serde_json::from_str(&json).unwrap(); + deserialized.ensure_rendered(); + assert_eq!(format!("{original}"), format!("{deserialized}")); + } + + // ======================================================================== + // 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_lint_result_ensure_rendered_recomputes_when_empty() { + let mut result = LintResult { + tool: "mypy".to_string(), + errors: 0, + warnings: 0, + groups: vec![], + rendered: String::new(), + }; + assert_eq!(result.as_ref(), ""); + result.ensure_rendered(); + assert_eq!(result.as_ref(), "LINT OK | mypy | 0 issues"); + } + + #[test] + fn test_lint_severity_display() { + assert_eq!(format!("{}", LintSeverity::Error), "error"); + assert_eq!(format!("{}", LintSeverity::Warning), "warning"); + assert_eq!(format!("{}", LintSeverity::Info), "info"); + } + + #[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/src/output/mod.rs b/crates/rskim/src/output/mod.rs index c0e9b2e..efadd35 100644 --- a/crates/rskim/src/output/mod.rs +++ b/crates/rskim/src/output/mod.rs @@ -88,6 +88,34 @@ impl> ParseResult { } } +impl ParseResult { + /// Serialize the parse result as a JSON string suitable for `--json` output. + /// + /// - `Full(result)` -> direct JSON serialization (no envelope -- preserves existing behavior) + /// - `Degraded(result, warnings)` -> `{"tier":"degraded","warnings":[...],"result":{...}}` + /// - `Passthrough(raw)` -> `{"tier":"passthrough","raw":"..."}` + pub(crate) fn to_json_envelope(&self) -> serde_json::Result { + match self { + ParseResult::Full(inner) => serde_json::to_string(inner), + ParseResult::Degraded(inner, warnings) => { + let val = serde_json::json!({ + "tier": "degraded", + "warnings": warnings, + "result": inner, + }); + serde_json::to_string(&val) + } + ParseResult::Passthrough(raw) => { + let val = serde_json::json!({ + "tier": "passthrough", + "raw": raw, + }); + serde_json::to_string(&val) + } + } + } +} + impl> ParseResult { /// Consuming access to inner content as `String`. pub(crate) fn into_content(self) -> String { @@ -929,4 +957,61 @@ mod tests { "expected ~100% savings for huge input with zero output, got: {output}" ); } + + // ======================================================================== + // to_json_envelope tests + // ======================================================================== + + /// Minimal type for testing to_json_envelope without coupling to canonical types. + #[derive(Debug, Clone, serde::Serialize)] + struct StubResult { + name: String, + count: usize, + } + + #[test] + fn test_to_json_envelope_full_no_wrapper() { + let inner = StubResult { + name: "test".to_string(), + count: 42, + }; + let result: ParseResult = ParseResult::Full(inner); + let json_str = result.to_json_envelope().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + // Full: direct serialization, no envelope + assert_eq!(parsed["name"], "test"); + assert_eq!(parsed["count"], 42); + assert!(parsed.get("tier").is_none(), "Full should have no tier key"); + } + + #[test] + fn test_to_json_envelope_degraded_has_envelope() { + let inner = StubResult { + name: "partial".to_string(), + count: 7, + }; + let warnings = vec!["regex fallback".to_string(), "missing field".to_string()]; + let result: ParseResult = ParseResult::Degraded(inner, warnings); + let json_str = result.to_json_envelope().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + assert_eq!(parsed["tier"], "degraded"); + assert_eq!(parsed["warnings"][0], "regex fallback"); + assert_eq!(parsed["warnings"][1], "missing field"); + assert_eq!(parsed["result"]["name"], "partial"); + assert_eq!(parsed["result"]["count"], 7); + } + + #[test] + fn test_to_json_envelope_passthrough_has_envelope() { + let result: ParseResult = + ParseResult::Passthrough("raw output here".to_string()); + let json_str = result.to_json_envelope().unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + assert_eq!(parsed["tier"], "passthrough"); + assert_eq!(parsed["raw"], "raw output here"); + assert!( + parsed.get("result").is_none(), + "Passthrough should have no result key" + ); + } } diff --git a/crates/rskim/tests/cli_e2e_lint_parsers.rs b/crates/rskim/tests/cli_e2e_lint_parsers.rs new file mode 100644 index 0000000..952f91f --- /dev/null +++ b/crates/rskim/tests/cli_e2e_lint_parsers.rs @@ -0,0 +1,343 @@ +//! E2E tests for lint parser degradation tiers (#104). +//! +//! Tests each linter at different degradation tiers via stdin piping, +//! verifying structured output markers and stderr diagnostics. +//! +//! Tier behavior reference (from emit_markers in output/mod.rs): +//! - 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() +} + +// ============================================================================ +// ESLint: Tier 1 (JSON) -- Full +// ============================================================================ + +#[test] +fn test_eslint_tier1_json_pass() { + let fixture = include_str!("fixtures/cmd/lint/eslint_pass.json"); + skim_cmd() + .args(["lint", "eslint"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT OK")); +} + +#[test] +fn test_eslint_tier1_json_fail() { + let fixture = include_str!("fixtures/cmd/lint/eslint_fail.json"); + skim_cmd() + .args(["lint", "eslint"]) + .write_stdin(fixture) + .assert() + .code(0) // stdin mode always exits 0 + .stdout(predicate::str::contains("LINT:")) + .stdout(predicate::str::contains("2 errors")) + .stdout(predicate::str::contains("3 warnings")); +} + +// ============================================================================ +// ESLint: Tier 2 (regex) -- Degraded +// ============================================================================ + +#[test] +fn test_eslint_tier2_regex_degraded() { + let fixture = include_str!("fixtures/cmd/lint/eslint_text.txt"); + skim_cmd() + .args(["lint", "eslint"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT:")) + .stderr(predicate::str::contains("[warning]")); +} + +// ============================================================================ +// ESLint: Tier 3 (passthrough) +// ============================================================================ + +#[test] +fn test_eslint_tier3_passthrough_garbage() { + skim_cmd() + .args(["lint", "eslint"]) + .write_stdin("random garbage not eslint output\n") + .assert() + .success() + .stdout(predicate::str::contains("random garbage")) + .stderr(predicate::str::contains("[notice]")); +} + +// ============================================================================ +// ESLint: --json flag +// ============================================================================ + +#[test] +fn test_eslint_json_flag_full() { + let fixture = include_str!("fixtures/cmd/lint/eslint_fail.json"); + skim_cmd() + .args(["lint", "--json", "eslint"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("\"tool\":\"eslint\"")) + .stdout(predicate::str::contains("\"errors\":2")); +} + +#[test] +fn test_eslint_json_flag_degraded() { + let fixture = include_str!("fixtures/cmd/lint/eslint_text.txt"); + skim_cmd() + .args(["lint", "--json", "eslint"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("\"tier\":\"degraded\"")) + .stdout(predicate::str::contains("\"tool\":\"eslint\"")); +} + +// ============================================================================ +// Ruff: Tier 1 (JSON) -- Full +// ============================================================================ + +#[test] +fn test_ruff_tier1_json_fail() { + let fixture = include_str!("fixtures/cmd/lint/ruff_fail.json"); + skim_cmd() + .args(["lint", "ruff"]) + .write_stdin(fixture) + .assert() + .code(0) + .stdout(predicate::str::contains("LINT:")) + .stdout(predicate::str::contains("3 errors")); +} + +// ============================================================================ +// Ruff: Tier 2 (regex) -- Degraded +// ============================================================================ + +#[test] +fn test_ruff_tier2_regex_degraded() { + let fixture = include_str!("fixtures/cmd/lint/ruff_text.txt"); + skim_cmd() + .args(["lint", "ruff"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT:")) + .stderr(predicate::str::contains("[warning]")); +} + +// ============================================================================ +// Ruff: Tier 3 (passthrough) +// ============================================================================ + +#[test] +fn test_ruff_tier3_passthrough_garbage() { + skim_cmd() + .args(["lint", "ruff"]) + .write_stdin("random garbage not ruff output\n") + .assert() + .success() + .stdout(predicate::str::contains("random garbage")) + .stderr(predicate::str::contains("[notice]")); +} + +// ============================================================================ +// Ruff: --json flag +// ============================================================================ + +#[test] +fn test_ruff_json_flag_full() { + let fixture = include_str!("fixtures/cmd/lint/ruff_fail.json"); + skim_cmd() + .args(["lint", "--json", "ruff"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("\"tool\":\"ruff\"")) + .stdout(predicate::str::contains("\"errors\":3")); +} + +// ============================================================================ +// mypy: Tier 1 (NDJSON) -- Full +// ============================================================================ + +#[test] +fn test_mypy_tier1_json_fail() { + let fixture = include_str!("fixtures/cmd/lint/mypy_fail.json"); + skim_cmd() + .args(["lint", "mypy"]) + .write_stdin(fixture) + .assert() + .code(0) + .stdout(predicate::str::contains("LINT:")) + .stdout(predicate::str::contains("3 errors")); +} + +// ============================================================================ +// mypy: Tier 2 (regex) -- Degraded +// ============================================================================ + +#[test] +fn test_mypy_tier2_regex_degraded() { + let fixture = include_str!("fixtures/cmd/lint/mypy_text.txt"); + skim_cmd() + .args(["lint", "mypy"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT:")) + .stderr(predicate::str::contains("[warning]")); +} + +// ============================================================================ +// mypy: Tier 3 (passthrough) +// ============================================================================ + +#[test] +fn test_mypy_tier3_passthrough_garbage() { + skim_cmd() + .args(["lint", "mypy"]) + .write_stdin("random garbage not mypy output\n") + .assert() + .success() + .stdout(predicate::str::contains("random garbage")) + .stderr(predicate::str::contains("[notice]")); +} + +// ============================================================================ +// mypy: --json flag +// ============================================================================ + +#[test] +fn test_mypy_json_flag_full() { + let fixture = include_str!("fixtures/cmd/lint/mypy_fail.json"); + skim_cmd() + .args(["lint", "--json", "mypy"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("\"tool\":\"mypy\"")) + .stdout(predicate::str::contains("\"errors\":3")); +} + +// ============================================================================ +// golangci-lint: Tier 1 (JSON) -- Full +// ============================================================================ + +#[test] +fn test_golangci_tier1_json_fail() { + let fixture = include_str!("fixtures/cmd/lint/golangci_fail.json"); + skim_cmd() + .args(["lint", "golangci"]) + .write_stdin(fixture) + .assert() + .code(0) + .stdout(predicate::str::contains("LINT:")) + .stdout(predicate::str::contains("1 error")) + .stdout(predicate::str::contains("3 warning")); +} + +// ============================================================================ +// golangci-lint: Tier 2 (regex) -- Degraded +// ============================================================================ + +#[test] +fn test_golangci_tier2_regex_degraded() { + let fixture = include_str!("fixtures/cmd/lint/golangci_text.txt"); + skim_cmd() + .args(["lint", "golangci"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT:")) + .stderr(predicate::str::contains("[warning]")); +} + +// ============================================================================ +// golangci-lint: Tier 3 (passthrough) +// ============================================================================ + +#[test] +fn test_golangci_tier3_passthrough_garbage() { + skim_cmd() + .args(["lint", "golangci"]) + .write_stdin("random garbage not golangci output\n") + .assert() + .success() + .stdout(predicate::str::contains("random garbage")) + .stderr(predicate::str::contains("[notice]")); +} + +// ============================================================================ +// golangci-lint: --json flag +// ============================================================================ + +#[test] +fn test_golangci_json_flag_full() { + let fixture = include_str!("fixtures/cmd/lint/golangci_fail.json"); + skim_cmd() + .args(["lint", "--json", "golangci"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("\"tool\":\"golangci\"")); +} + +// ============================================================================ +// Dispatcher: help and unknown linter +// ============================================================================ + +#[test] +fn test_lint_help() { + skim_cmd() + .args(["lint", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Available linters:")) + .stdout(predicate::str::contains("eslint")) + .stdout(predicate::str::contains("ruff")) + .stdout(predicate::str::contains("mypy")) + .stdout(predicate::str::contains("golangci")); +} + +#[test] +fn test_lint_unknown_linter() { + skim_cmd() + .args(["lint", "unknown-linter"]) + .assert() + .failure() + .stderr(predicate::str::contains("unknown linter 'unknown-linter'")); +} + +#[test] +fn test_lint_no_args_shows_help() { + skim_cmd() + .args(["lint"]) + .assert() + .success() + .stdout(predicate::str::contains("Available linters:")); +} + +// ============================================================================ +// --show-stats integration +// ============================================================================ + +#[test] +fn test_lint_show_stats_reports_tokens() { + let fixture = include_str!("fixtures/cmd/lint/eslint_fail.json"); + skim_cmd() + .args(["lint", "--show-stats", "eslint"]) + .write_stdin(fixture) + .assert() + .success() + .stdout(predicate::str::contains("LINT:")) + .stderr(predicate::str::contains("tokens")); +} 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..05a4c0d 100644 --- a/crates/rskim/tests/cli_e2e_rewrite.rs +++ b/crates/rskim/tests/cli_e2e_rewrite.rs @@ -664,3 +664,256 @@ fn test_rewrite_hook_passthrough_zero_stderr() { "Passthrough hook mode should produce zero stderr, got: {stderr}" ); } + +// ============================================================================ +// Lint rewrite rules (#104) +// ============================================================================ + +#[test] +fn test_rewrite_eslint() { + skim_cmd() + .args(["rewrite", "eslint", "."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint eslint .")); +} + +#[test] +fn test_rewrite_eslint_skip_format_flag() { + // When user already has --format json, rewrite should be suppressed + skim_cmd() + .args(["rewrite", "eslint", "--format", "json", "."]) + .assert() + .failure(); // No match = exit 1 +} + +#[test] +fn test_rewrite_npx_eslint() { + skim_cmd() + .args(["rewrite", "npx", "eslint", "src/"]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint eslint src/")); +} + +#[test] +fn test_rewrite_ruff_check() { + skim_cmd() + .args(["rewrite", "ruff", "check", "."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint ruff .")); +} + +#[test] +fn test_rewrite_ruff_bare() { + skim_cmd() + .args(["rewrite", "ruff", "."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint ruff .")); +} + +#[test] +fn test_rewrite_mypy() { + skim_cmd() + .args(["rewrite", "mypy", "."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint mypy .")); +} + +#[test] +fn test_rewrite_python_m_mypy() { + skim_cmd() + .args(["rewrite", "python", "-m", "mypy", "."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint mypy .")); +} + +#[test] +fn test_rewrite_python3_m_mypy() { + skim_cmd() + .args(["rewrite", "python3", "-m", "mypy", "src/"]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint mypy src/")); +} + +#[test] +fn test_rewrite_golangci_lint_run() { + skim_cmd() + .args(["rewrite", "golangci-lint", "run", "./..."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint golangci ./...")); +} + +#[test] +fn test_rewrite_golangci_lint_bare() { + skim_cmd() + .args(["rewrite", "golangci-lint", "./..."]) + .assert() + .success() + .stdout(predicate::str::contains("skim lint golangci ./...")); +} + +// ============================================================================ +// 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/lint/eslint_fail.json b/crates/rskim/tests/fixtures/cmd/lint/eslint_fail.json new file mode 100644 index 0000000..f95e870 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/eslint_fail.json @@ -0,0 +1,21 @@ +[ + { + "filePath": "/home/user/project/src/api/auth.ts", + "messages": [ + {"ruleId": "no-unused-vars", "severity": 1, "message": "'x' is defined but never used.", "line": 12, "column": 7}, + {"ruleId": "@typescript-eslint/no-explicit-any", "severity": 2, "message": "Unexpected any. Specify a different type.", "line": 45, "column": 18} + ], + "errorCount": 1, + "warningCount": 1 + }, + { + "filePath": "/home/user/project/src/utils/format.ts", + "messages": [ + {"ruleId": "no-unused-vars", "severity": 1, "message": "'helper' is defined but never used.", "line": 8, "column": 10}, + {"ruleId": "no-unused-vars", "severity": 1, "message": "'result' is defined but never used.", "line": 22, "column": 7}, + {"ruleId": "eqeqeq", "severity": 2, "message": "Expected '===' and instead saw '=='.", "line": 15, "column": 12} + ], + "errorCount": 1, + "warningCount": 2 + } +] diff --git a/crates/rskim/tests/fixtures/cmd/lint/eslint_pass.json b/crates/rskim/tests/fixtures/cmd/lint/eslint_pass.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/eslint_pass.json @@ -0,0 +1 @@ +[] diff --git a/crates/rskim/tests/fixtures/cmd/lint/eslint_text.txt b/crates/rskim/tests/fixtures/cmd/lint/eslint_text.txt new file mode 100644 index 0000000..da85a3e --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/eslint_text.txt @@ -0,0 +1,9 @@ +/home/user/project/src/api/auth.ts + 12:7 warning 'x' is defined but never used no-unused-vars + 45:18 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any + +/home/user/project/src/utils/format.ts + 8:10 warning 'helper' is defined but never used no-unused-vars + 15:12 error Expected '===' and instead saw '==' eqeqeq + +✖ 4 problems (2 errors, 2 warnings) diff --git a/crates/rskim/tests/fixtures/cmd/lint/golangci_fail.json b/crates/rskim/tests/fixtures/cmd/lint/golangci_fail.json new file mode 100644 index 0000000..ed647a7 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/golangci_fail.json @@ -0,0 +1,6 @@ +{"Issues": [ + {"FromLinter": "govet", "Text": "printf: non-constant format string in call to fmt.Printf", "Pos": {"Filename": "cmd/main.go", "Line": 42, "Column": 12}, "Severity": ""}, + {"FromLinter": "govet", "Text": "unreachable code", "Pos": {"Filename": "cmd/main.go", "Line": 55, "Column": 2}, "Severity": "warning"}, + {"FromLinter": "errcheck", "Text": "Error return value of `os.Remove` is not checked", "Pos": {"Filename": "pkg/cleanup.go", "Line": 18, "Column": 9}, "Severity": ""}, + {"FromLinter": "staticcheck", "Text": "SA1006: printf-style function with dynamic format string", "Pos": {"Filename": "internal/log.go", "Line": 33, "Column": 5}, "Severity": "error"} +]} diff --git a/crates/rskim/tests/fixtures/cmd/lint/golangci_text.txt b/crates/rskim/tests/fixtures/cmd/lint/golangci_text.txt new file mode 100644 index 0000000..f5198f1 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/golangci_text.txt @@ -0,0 +1,4 @@ +cmd/main.go:42:12: printf: non-constant format string in call to fmt.Printf (govet) +cmd/main.go:55:2: unreachable code (govet) +pkg/cleanup.go:18:9: Error return value of `os.Remove` is not checked (errcheck) +internal/log.go:33:5: SA1006: printf-style function with dynamic format string (staticcheck) diff --git a/crates/rskim/tests/fixtures/cmd/lint/mypy_fail.json b/crates/rskim/tests/fixtures/cmd/lint/mypy_fail.json new file mode 100644 index 0000000..8eb75b5 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/mypy_fail.json @@ -0,0 +1,3 @@ +{"file": "src/main.py", "line": 10, "column": 5, "message": "Incompatible return value type (got \"str\", expected \"int\")", "hint": null, "code": "return-value", "severity": "error"} +{"file": "src/main.py", "line": 25, "column": 12, "message": "\"None\" has no attribute \"strip\"", "hint": null, "code": "union-attr", "severity": "error"} +{"file": "src/utils.py", "line": 3, "column": 1, "message": "Cannot find implementation or library stub for module named \"nonexistent\"", "hint": null, "code": "import", "severity": "error"} diff --git a/crates/rskim/tests/fixtures/cmd/lint/mypy_text.txt b/crates/rskim/tests/fixtures/cmd/lint/mypy_text.txt new file mode 100644 index 0000000..bdadbe8 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/mypy_text.txt @@ -0,0 +1,4 @@ +src/main.py:10: error: Incompatible return value type (got "str", expected "int") [return-value] +src/main.py:25: error: "None" has no attribute "strip" [union-attr] +src/utils.py:3: error: Cannot find implementation or library stub for module named "nonexistent" [import] +Found 3 errors in 2 files (checked 5 source files) diff --git a/crates/rskim/tests/fixtures/cmd/lint/ruff_fail.json b/crates/rskim/tests/fixtures/cmd/lint/ruff_fail.json new file mode 100644 index 0000000..782b02c --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/ruff_fail.json @@ -0,0 +1,5 @@ +[ + {"code": "F401", "message": "`os` imported but unused", "filename": "src/main.py", "location": {"row": 1, "column": 1}}, + {"code": "F401", "message": "`sys` imported but unused", "filename": "src/main.py", "location": {"row": 2, "column": 1}}, + {"code": "E501", "message": "Line too long (120 > 88)", "filename": "src/utils.py", "location": {"row": 45, "column": 89}} +] diff --git a/crates/rskim/tests/fixtures/cmd/lint/ruff_text.txt b/crates/rskim/tests/fixtures/cmd/lint/ruff_text.txt new file mode 100644 index 0000000..9e4b645 --- /dev/null +++ b/crates/rskim/tests/fixtures/cmd/lint/ruff_text.txt @@ -0,0 +1,4 @@ +src/main.py:1:1: F401 `os` imported but unused +src/main.py:2:1: F401 `sys` imported but unused +src/utils.py:45:89: E501 Line too long (120 > 88) +Found 3 errors. 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"} +}