diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index fd4922c1f..9867d4b63 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -123,7 +123,8 @@ regex = "1" similar = "3.0.0" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } - +anyhow = "1.0" +toml = "0.8" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/pi-natives/src/shell.rs b/crates/pi-natives/src/shell.rs index 8bfa56227..54d073d19 100644 --- a/crates/pi-natives/src/shell.rs +++ b/crates/pi-natives/src/shell.rs @@ -26,6 +26,8 @@ use std::{ #[cfg(windows)] mod windows; +mod minimizer; + use brush_builtins::{BuiltinSet, default_builtins}; use brush_core::{ CreateOptions, ExecutionContext, ExecutionControlFlow, ExecutionExitCode, ExecutionResult, @@ -83,6 +85,7 @@ impl ShellAbortState { struct ShellConfig { session_env: Option>, snapshot_path: Option, + minimizer: Option, } /// Options for configuring a persistent shell session. @@ -92,16 +95,20 @@ pub struct ShellOptions { pub session_env: Option>, /// Optional snapshot file to source on session creation. pub snapshot_path: Option, + /// Optional per-command output minimizer configuration. + pub minimizer: Option, } /// Options for running a shell command (internal, lifetime-free). struct ShellRunConfig { /// Command string to execute in the shell. - command: String, + command: String, /// Working directory for the command. - cwd: Option, + cwd: Option, /// Environment variables to apply for this command only. - env: Option>, + env: Option>, + /// Resolved output minimizer config for this command. + minimizer: Option, } /// Options for running a shell command. @@ -145,10 +152,20 @@ impl Shell { /// /// The options set session-scoped environment variables and a snapshot path. pub fn new(options: Option) -> Self { - let config = options.map_or_else( - || ShellConfig { session_env: None, snapshot_path: None }, - |opt| ShellConfig { session_env: opt.session_env, snapshot_path: opt.snapshot_path }, - ); + let config = match options { + None => ShellConfig { session_env: None, snapshot_path: None, minimizer: None }, + Some(opt) => { + let minimizer = opt + .minimizer + .as_ref() + .map(minimizer::MinimizerConfig::from_options); + ShellConfig { + session_env: opt.session_env, + snapshot_path: opt.snapshot_path, + minimizer, + } + }, + }; Self { session: Arc::new(TokioMutex::new(None)), abort_state: ShellAbortState::default(), @@ -174,8 +191,12 @@ impl Shell { let abort_state = self.abort_state.clone(); let config = self.config.clone(); - let run_config = - ShellRunConfig { command: options.command, cwd: options.cwd, env: options.env }; + let run_config = ShellRunConfig { + command: options.command, + cwd: options.cwd, + env: options.env, + minimizer: config.minimizer.clone(), + }; task::future(env, "shell.run", async move { run_shell_session(session, abort_state, config, run_config, on_chunk, ct).await @@ -269,6 +290,8 @@ pub struct ShellExecuteOptions<'env> { pub timeout_ms: Option, /// Optional snapshot file to source on session creation. pub snapshot_path: Option, + /// Optional per-command output minimizer configuration. + pub minimizer: Option, /// Abort signal for cancelling the operation. pub signal: Option>, } @@ -296,10 +319,17 @@ pub fn execute_shell<'env>( #[napi(ts_arg_type = "((error: Error | null, chunk: string) => void) | undefined | null")] on_chunk: Option>, ) -> Result> { - let config = - ShellConfig { session_env: options.session_env, snapshot_path: options.snapshot_path }; + let minimizer = options + .minimizer + .as_ref() + .map(minimizer::MinimizerConfig::from_options); + let config = ShellConfig { + session_env: options.session_env, + snapshot_path: options.snapshot_path, + minimizer: minimizer.clone(), + }; let run_config = - ShellRunConfig { command: options.command, cwd: options.cwd, env: options.env }; + ShellRunConfig { command: options.command, cwd: options.cwd, env: options.env, minimizer }; let ct = task::CancelToken::new(options.timeout_ms, options.signal); task::future(env, "shell.execute", async move { @@ -575,13 +605,36 @@ async fn run_shell_command( } } + let should_minimize = if let Some(config) = options.minimizer.as_ref() { + minimizer::engine::should_minimize(&options.command, config) + } else { + false + }; + let max_capture_bytes = if let Some(config) = options.minimizer.as_ref() { + config.max_capture_bytes as usize + } else { + 0 + }; + let reader_cancel = CancellationToken::new(); let (activity_tx, mut activity_rx) = mpsc::channel::<()>(1); + let (reader_callback, final_callback) = if should_minimize { + (None, on_chunk) + } else { + (on_chunk, None) + }; let mut reader_handle = tokio::spawn({ let reader_cancel = reader_cancel.clone(); async move { - Box::pin(read_output(reader_file, on_chunk, reader_cancel, activity_tx)).await; - Result::<()>::Ok(()) + if should_minimize { + let output = + read_output_buffered(reader_file, reader_cancel, activity_tx, max_capture_bytes) + .await; + Result::::Ok(OutputRead::Buffered(output)) + } else { + Box::pin(read_output(reader_file, reader_callback, reader_cancel, activity_tx)).await; + Result::::Ok(OutputRead::Streaming) + } } }); let cancel_bridge = tokio::spawn({ @@ -619,13 +672,16 @@ async fn run_shell_command( const READER_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); let mut reader_finished = false; + let mut reader_output = None; let mut idle_timer = Box::pin(time::sleep(POST_EXIT_IDLE)); let mut max_timer = Box::pin(time::sleep(POST_EXIT_MAX)); loop { tokio::select! { res = &mut reader_handle => { - let _ = res; + if let Ok(Ok(output)) = res { + reader_output = Some(output); + } reader_finished = true; break; } @@ -643,7 +699,11 @@ async fn run_shell_command( if !reader_finished { reader_cancel.cancel(); if let Ok(res) = time::timeout(READER_SHUTDOWN_TIMEOUT, &mut reader_handle).await { - let _ = res; + if let Ok(output) = res + && let Ok(output) = output + { + reader_output = Some(output); + } } else { reader_handle.abort(); let _ = reader_handle.await; @@ -652,7 +712,21 @@ async fn run_shell_command( cancel_bridge.abort(); let _ = cancel_bridge.await; - result.map_err(|err| Error::from_reason(format!("Shell execution failed: {err}"))) + let result = + result.map_err(|err| Error::from_reason(format!("Shell execution failed: {err}")))?; + if let Some(OutputRead::Buffered(output)) = reader_output + && let Some(config) = options.minimizer.as_ref() + { + if output.exceeded { + emit_chunk(&output.text, final_callback.as_ref()); + } else { + let minimized = + minimizer::apply(&options.command, &output.text, exit_code(&result), config); + let _ = minimized.changed; + emit_chunk(&minimized.text, final_callback.as_ref()); + } + } + Ok(result) } #[cfg(unix)] @@ -793,6 +867,16 @@ const fn session_keepalive(result: &ExecutionResult) -> bool { } } +enum OutputRead { + Streaming, + Buffered(BufferedOutput), +} + +struct BufferedOutput { + text: String, + exceeded: bool, +} + async fn read_output( reader: fs::File, on_chunk: Option>, @@ -901,6 +985,69 @@ async fn read_output( } } +async fn read_output_buffered( + reader: fs::File, + cancel_token: CancellationToken, + activity: mpsc::Sender<()>, + max_capture_bytes: usize, +) -> BufferedOutput { + const BUF: usize = 65536; + let mut buf = vec![0u8; BUF]; + let mut captured = Vec::new(); + let mut exceeded = false; + + #[cfg(unix)] + let Ok(reader) = register_nonblocking_pipe(reader) else { + return BufferedOutput { text: String::new(), exceeded: true }; + }; + #[cfg(not(unix))] + let reader = tokio::fs::File::from_std(reader); + #[cfg(not(unix))] + tokio::pin!(reader); + + loop { + #[cfg(unix)] + let n = { + let Ok(mut readiness) = (tokio::select! { + ready = reader.readable() => ready, + () = cancel_token.cancelled() => break, + }) else { + break; + }; + match readiness.try_io(|inner| read_nonblocking(inner.get_ref(), &mut buf)) { + Ok(Ok(0)) => break, + Ok(Ok(n)) => n, + Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue, + Ok(Err(_)) => break, + Err(_would_block) => continue, + } + }; + #[cfg(not(unix))] + let n = { + let read_future = reader.read(&mut buf); + tokio::pin!(read_future); + match tokio::select! { + res = &mut read_future => res, + () = cancel_token.cancelled() => break, + } { + Ok(0) => break, + Ok(n) => n, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + }; + if n > 0 { + let _ = activity.try_send(()); + } + if captured.len().saturating_add(n) > max_capture_bytes { + exceeded = true; + } + captured.extend_from_slice(&buf[..n]); + } + + BufferedOutput { text: String::from_utf8_lossy(&captured).into_owned(), exceeded } +} + #[cfg(unix)] fn register_nonblocking_pipe(reader: fs::File) -> io::Result> { set_nonblocking(&reader)?; diff --git a/crates/pi-natives/src/shell/minimizer.rs b/crates/pi-natives/src/shell/minimizer.rs new file mode 100644 index 000000000..4f0ce95a4 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer.rs @@ -0,0 +1,63 @@ +//! Opt-in output minimizer for `Shell::run` / `execute_shell`. +//! +//! Compresses a shell command's stdout/stderr before it reaches the JS +//! caller. +//! +//! The engine is inert unless a [`MinimizerConfig`] explicitly opts in. + +pub mod config; +pub mod detect; +pub mod engine; +pub mod filters; +pub mod primitives; + +use std::borrow::Cow; + +pub use config::{MinimizerConfig, MinimizerOptions}; + +/// Per-invocation context passed to every filter. +#[derive(Debug, Clone)] +pub struct MinimizerCtx<'a> { + /// Resolved program name (lowercased, e.g. `"git"`). + pub program: &'a str, + /// Detected subcommand (lowercased, e.g. `"status"`), if any. + pub subcommand: Option<&'a str>, + /// Raw command string as the caller supplied it. + pub command: &'a str, + /// Effective configuration. + pub config: &'a MinimizerConfig, +} + +/// Output produced by a filter. +#[derive(Debug, Clone)] +pub struct MinimizerOutput { + /// Rewritten output. + pub text: String, + /// Whether the filter modified the input at all. + pub changed: bool, +} + +impl MinimizerOutput { + /// Pass-through constructor — the filter emits the original text unchanged. + pub fn passthrough<'a>(text: impl Into>) -> Self { + Self { text: text.into().into_owned(), changed: false } + } + + /// Transformed output. + pub const fn transformed(text: String) -> Self { + Self { text, changed: true } + } +} + +/// Apply the configured filter pipeline to a captured buffer. +/// +/// Returns the original text unchanged when minimization is disabled, no +/// filter matches, or a filter panics. +pub fn apply( + command: &str, + captured: &str, + exit_code: i32, + config: &MinimizerConfig, +) -> MinimizerOutput { + engine::apply(command, captured, exit_code, config) +} diff --git a/crates/pi-natives/src/shell/minimizer/config.rs b/crates/pi-natives/src/shell/minimizer/config.rs new file mode 100644 index 000000000..2f7d6196e --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/config.rs @@ -0,0 +1,222 @@ +//! Configuration for the shell output minimizer. +//! +//! [`MinimizerOptions`] is the N-API surface exposed through `ShellOptions` +//! and `ShellExecuteOptions`. [`MinimizerConfig`] is the internal resolved +//! view after merging field-level values with an optional TOML settings +//! file. + +use std::{ + collections::{HashMap, HashSet}, + fs, + path::{Path, PathBuf}, +}; + +use napi_derive::napi; +use serde::Deserialize; + +const DEFAULT_MAX_CAPTURE_BYTES: u32 = 4 * 1024 * 1024; + +/// N-API opt-in handle for the minimizer. +#[napi(object)] +#[derive(Debug, Clone, Default)] +pub struct MinimizerOptions { + /// Master switch. Absent / false = disabled. + pub enabled: Option, + /// Optional path to a TOML settings file whose values override + /// field-level defaults. `~` is expanded. + pub settings_path: Option, + /// Opt-in allowlist of program names (e.g. `"git"`). When empty or + /// absent, all built-in filters are active. + pub only: Option>, + /// Program names explicitly excluded from minimization. + pub except: Option>, + /// Maximum captured bytes per command before the engine falls back to + /// the raw, un-minimized output. Default 4 MiB. + pub max_capture_bytes: Option, +} + +/// Resolved minimizer configuration used by the engine. +#[derive(Debug, Clone)] +pub struct MinimizerConfig { + pub enabled: bool, + pub only: HashSet, + pub except: HashSet, + pub max_capture_bytes: u32, + pub per_command: HashMap, +} + +impl Default for MinimizerConfig { + fn default() -> Self { + Self { + enabled: false, + only: HashSet::new(), + except: HashSet::new(), + max_capture_bytes: DEFAULT_MAX_CAPTURE_BYTES, + per_command: HashMap::new(), + } + } +} + +impl MinimizerConfig { + /// Build a resolved configuration from `MinimizerOptions`, optionally + /// merging in a TOML settings file. + pub fn from_options(opts: &MinimizerOptions) -> Self { + let mut cfg = Self::default(); + if let Some(enabled) = opts.enabled { + cfg.enabled = enabled; + } + if let Some(list) = opts.only.as_ref() { + cfg.only = list.iter().map(|s| s.to_lowercase()).collect(); + } + if let Some(list) = opts.except.as_ref() { + cfg.except = list.iter().map(|s| s.to_lowercase()).collect(); + } + if let Some(n) = opts.max_capture_bytes { + cfg.max_capture_bytes = n.max(1024); + } + if let Some(path) = opts.settings_path.as_deref() + && !path.is_empty() + { + let expanded = expand_tilde(path); + if let Ok(contents) = fs::read_to_string(&expanded) + && let Ok(file) = toml::from_str::(&contents) + { + file.merge_into(&mut cfg); + } + } + cfg + } + + /// Whether the engine should attempt to minimize output for `program`. + pub fn is_program_enabled(&self, program: &str) -> bool { + if !self.enabled { + return false; + } + let key = program.to_lowercase(); + if self.except.contains(&key) { + return false; + } + if !self.only.is_empty() && !self.only.contains(&key) { + return false; + } + true + } + + /// Fetch a per-command TOML table, if any. + pub fn per_command(&self, program: &str) -> Option<&toml::Value> { + self.per_command.get(&program.to_lowercase()) + } +} + +#[derive(Debug, Default, Deserialize)] +struct SettingsFile { + enabled: Option, + only: Option>, + except: Option>, + max_capture_bytes: Option, + #[serde(flatten)] + tables: HashMap, +} + +impl SettingsFile { + fn merge_into(self, cfg: &mut MinimizerConfig) { + if let Some(v) = self.enabled { + cfg.enabled = v; + } + if let Some(list) = self.only { + cfg.only = list.into_iter().map(|s| s.to_lowercase()).collect(); + } + if let Some(list) = self.except { + cfg.except = list.into_iter().map(|s| s.to_lowercase()).collect(); + } + if let Some(n) = self.max_capture_bytes { + cfg.max_capture_bytes = n.max(1024); + } + for (k, v) in self.tables { + if v.is_table() { + cfg.per_command.insert(k.to_lowercase(), v); + } + } + } +} + +fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") + && let Some(home) = home_dir() + { + return home.join(rest); + } + if path == "~" + && let Some(home) = home_dir() + { + return home; + } + Path::new(path).to_path_buf() +} + +fn home_dir() -> Option { + #[cfg(unix)] + { + std::env::var_os("HOME").map(PathBuf::from) + } + #[cfg(windows)] + { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOMEPATH")) + .map(PathBuf::from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_disabled() { + let cfg = MinimizerConfig::default(); + assert!(!cfg.enabled); + assert!(!cfg.is_program_enabled("git")); + } + + #[test] + fn enabled_without_only_enables_any_program() { + let cfg = MinimizerConfig::from_options(&MinimizerOptions { + enabled: Some(true), + ..Default::default() + }); + assert!(cfg.is_program_enabled("git")); + assert!(cfg.is_program_enabled("cargo")); + } + + #[test] + fn only_list_is_respected() { + let cfg = MinimizerConfig::from_options(&MinimizerOptions { + enabled: Some(true), + only: Some(vec!["git".into()]), + ..Default::default() + }); + assert!(cfg.is_program_enabled("git")); + assert!(!cfg.is_program_enabled("cargo")); + } + + #[test] + fn except_overrides_only() { + let cfg = MinimizerConfig::from_options(&MinimizerOptions { + enabled: Some(true), + except: Some(vec!["docker".into()]), + ..Default::default() + }); + assert!(!cfg.is_program_enabled("docker")); + assert!(cfg.is_program_enabled("git")); + } + + #[test] + fn missing_settings_path_is_not_fatal() { + let cfg = MinimizerConfig::from_options(&MinimizerOptions { + enabled: Some(true), + settings_path: Some("/does/not/exist.toml".into()), + ..Default::default() + }); + assert!(cfg.enabled); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/detect.rs b/crates/pi-natives/src/shell/minimizer/detect.rs new file mode 100644 index 000000000..f1778d67a --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/detect.rs @@ -0,0 +1,473 @@ +//! Best-effort command detection for minimizer dispatch. + +/// Parsed command identity used for filter dispatch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandIdentity { + pub program: String, + pub subcommand: Option, +} + +/// Extract the executable and the relevant subcommand from a shell command. +/// +/// The detector intentionally handles the common interactive subset instead +/// of emulating a full shell parser. Ambiguous commands return `None` and are +/// left streaming unchanged. +pub fn detect(command: &str) -> Option { + let tokens = tokenize(command); + let tokens = strip_launch_prefix(&tokens)?; + let (program, rest) = tokens.split_first()?; + let normalized = normalize_program(program)?; + let subcommand = detect_subcommand(&normalized, rest); + Some(CommandIdentity { program: normalized, subcommand }) +} + +fn strip_launch_prefix(tokens: &[String]) -> Option<&[String]> { + let mut index = 0; + + loop { + let before = index; + while tokens + .get(index) + .is_some_and(|token| is_env_assignment(token)) + { + index += 1; + } + + let token = tokens.get(index)?; + + match token.as_str() { + "env" => { + index = skip_env_options(tokens, index + 1)?; + }, + "sudo" => { + index = skip_sudo_options(tokens, index + 1)?; + }, + "command" => { + index = skip_command_options(tokens, index + 1)?; + }, + "builtin" | "noglob" => { + index += 1; + }, + "exec" => { + index = skip_exec_options(tokens, index + 1)?; + }, + "time" => { + index = skip_time_options(tokens, index + 1)?; + }, + _ => {}, + } + + if index == before { + break; + } + } + + Some(&tokens[index..]) +} + +fn is_env_assignment(token: &str) -> bool { + let Some((name, _)) = token.split_once('=') else { + return false; + }; + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn normalize_program(program: &str) -> Option { + let name = program.rsplit('/').next()?; + if name.is_empty() { + return None; + } + Some(name.to_lowercase()) +} + +fn skip_env_options(tokens: &[String], mut index: usize) -> Option { + while let Some(token) = tokens.get(index) { + match token.as_str() { + "--" => return Some(index + 1), + "-S" | "--split-string" => return None, + "-i" | "-" | "--ignore-environment" => index += 1, + "-u" | "--unset" | "-C" | "--chdir" => index = skip_option_value(tokens, index)?, + _ if token.starts_with("--unset=") || token.starts_with("--chdir=") => index += 1, + _ if is_env_assignment(token) => index += 1, + _ => break, + } + } + Some(index) +} + +fn skip_sudo_options(tokens: &[String], mut index: usize) -> Option { + while let Some(token) = tokens.get(index) { + match token.as_str() { + "--" => return Some(index + 1), + "-E" | "-H" | "-n" | "-S" | "-k" | "-K" | "-b" => index += 1, + "-u" | "--user" | "-g" | "--group" | "-h" | "--host" | "-p" | "--prompt" | "-C" + | "--close-from" | "-T" | "--command-timeout" => index = skip_option_value(tokens, index)?, + _ if token.starts_with("--user=") + || token.starts_with("--group=") + || token.starts_with("--host=") + || token.starts_with("--prompt=") + || token.starts_with("--close-from=") + || token.starts_with("--command-timeout=") => + { + index += 1; + }, + _ if token.starts_with('-') => return None, + _ => break, + } + } + Some(index) +} + +fn skip_command_options(tokens: &[String], mut index: usize) -> Option { + while let Some(token) = tokens.get(index) { + match token.as_str() { + "--" => return Some(index + 1), + "-p" => index += 1, + "-v" | "-V" => return None, + _ if token.starts_with('-') => return None, + _ => break, + } + } + Some(index) +} + +fn skip_exec_options(tokens: &[String], mut index: usize) -> Option { + while let Some(token) = tokens.get(index) { + match token.as_str() { + "--" => return Some(index + 1), + "-c" | "-l" => index += 1, + "-a" => index = skip_option_value(tokens, index)?, + _ if token.starts_with('-') => return None, + _ => break, + } + } + Some(index) +} + +fn skip_time_options(tokens: &[String], mut index: usize) -> Option { + while let Some(token) = tokens.get(index) { + match token.as_str() { + "--" => return Some(index + 1), + "-p" | "--portability" | "-v" | "--verbose" => index += 1, + "-f" | "--format" | "-o" | "--output" => index = skip_option_value(tokens, index)?, + _ if token.starts_with("--format=") || token.starts_with("--output=") => index += 1, + _ if token.starts_with('-') => return None, + _ => break, + } + } + Some(index) +} + +fn skip_option_value(tokens: &[String], index: usize) -> Option { + let token = tokens.get(index)?; + if token.starts_with("--") && token.contains('=') { + return Some(index + 1); + } + if token.starts_with('-') && !token.starts_with("--") && token.len() > 2 { + return Some(index + 1); + } + tokens.get(index + 1).map(|_| index + 2) +} + +fn detect_subcommand(program: &str, args: &[String]) -> Option { + match program { + "git" | "yadm" => first_non_global_arg( + args, + &["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--html-path"], + &[ + "--bare", + "--no-pager", + "--paginate", + "--no-optional-locks", + "--literal-pathspecs", + "--glob-pathspecs", + "--noglob-pathspecs", + "--icase-pathspecs", + "--no-replace-objects", + ], + &[], + ), + "cargo" => first_non_global_arg( + args, + &["--manifest-path", "--target-dir", "--config", "-Z", "--color", "--jobs", "-j"], + &[ + "--locked", + "--offline", + "--frozen", + "--workspace", + "--all", + "--verbose", + "-v", + "--quiet", + "-q", + ], + &["+"], + ), + "docker" => first_non_global_arg( + args, + &[ + "--config", + "--context", + "-c", + "--host", + "-H", + "--log-level", + "--tlscacert", + "--tlscert", + "--tlskey", + ], + &["--debug", "-D", "--tls", "--tlsverify"], + &[], + ), + "gh" => first_non_global_arg( + args, + &["--repo", "-R", "--hostname", "--jq", "--template"], + &["--paginate", "--slurp", "--verbose"], + &[], + ), + "gt" => first_non_global_arg( + args, + &["--repo", "--cwd", "--config", "--debug-context"], + &["--no-interactive", "--interactive", "--version", "--help"], + &[], + ), + "npm" => first_non_global_arg( + args, + &["--prefix", "-C", "--workspace", "-w", "--userconfig", "--cache", "--registry"], + &[ + "--global", + "-g", + "--workspaces", + "--include-workspace-root", + "--offline", + "--prefer-offline", + ], + &[], + ), + "pnpm" => first_non_global_arg( + args, + &["--dir", "-C", "--filter", "-F", "--workspace", "--config", "--store-dir"], + &["--global", "-g", "--workspace-root", "-w", "--offline", "--recursive", "-r"], + &[], + ), + "yarn" => first_non_global_arg( + args, + &["--cwd", "--cache-folder", "--global-folder", "--modules-folder", "--mutex"], + &["--offline", "--silent", "--verbose"], + &[], + ), + "bun" => first_non_global_arg( + args, + &["--cwd", "-C", "--config", "--registry", "--cache-dir"], + &["--bun", "--silent", "--verbose", "--watch", "--hot", "--no-clear-screen"], + &[], + ), + "pip" | "pip3" => first_non_global_arg( + args, + &[ + "--python", + "--cache-dir", + "--proxy", + "--timeout", + "--trusted-host", + "--cert", + "--client-cert", + ], + &["--isolated", "--require-virtualenv", "--no-cache-dir", "--disable-pip-version-check"], + &[], + ), + "bundle" => first_non_global_arg( + args, + &["--gemfile", "--path", "--jobs", "--retry"], + &["--verbose", "--quiet", "--no-color"], + &[], + ), + "jest" | "vitest" => first_non_global_arg(args, &[], &[], &[]), + _ => args + .iter() + .find(|arg| !arg.starts_with('-')) + .map(|arg| arg.to_lowercase()), + } +} + +fn first_non_global_arg( + args: &[String], + flags_with_values: &[&str], + flag_only: &[&str], + bare_prefixes: &[&str], +) -> Option { + let mut index = 0; + while let Some(arg) = args.get(index) { + if arg == "--" { + return args.get(index + 1).map(|value| value.to_lowercase()); + } + if bare_prefixes.iter().any(|prefix| arg.starts_with(prefix)) { + index += 1; + continue; + } + if flag_only.contains(&arg.as_str()) { + index += 1; + continue; + } + if option_consumes_value(arg, flags_with_values) { + index += if option_has_inline_value(arg, flags_with_values) { + 1 + } else { + 2 + }; + continue; + } + if arg.starts_with('-') { + index += 1; + continue; + } + return Some(arg.to_lowercase()); + } + None +} + +fn option_consumes_value(arg: &str, flags_with_values: &[&str]) -> bool { + flags_with_values.iter().any(|flag| { + arg == *flag + || (flag.starts_with("--") && arg.starts_with(&format!("{flag}="))) + || (!flag.starts_with("--") && arg.starts_with(flag) && arg.len() > flag.len()) + }) +} + +fn option_has_inline_value(arg: &str, flags_with_values: &[&str]) -> bool { + flags_with_values.iter().any(|flag| { + (flag.starts_with("--") && arg.starts_with(&format!("{flag}="))) + || (!flag.starts_with("--") && arg.starts_with(flag) && arg.len() > flag.len()) + }) +} + +fn tokenize(command: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut chars = command.chars(); + let mut quote: Option = None; + while let Some(ch) = chars.next() { + match (quote, ch) { + (None, '\'' | '"') => quote = Some(ch), + (Some(q), c) if c == q => quote = None, + (None, '\\') => { + if let Some(next) = chars.next() { + current.push(next); + } + }, + (None, c) if c.is_whitespace() => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + }, + (None, ';' | '|' | '&') => break, + (_, c) => current.push(c), + } + } + if !current.is_empty() { + tokens.push(current); + } + tokens +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_basic_program_and_subcommand() { + let command = detect("git status --short").expect("git command is detected"); + assert_eq!(command.program, "git"); + assert_eq!(command.subcommand.as_deref(), Some("status")); + } + + #[test] + fn skips_env_prefix_and_git_globals() { + let command = detect("FOO=1 git -C repo -c color.ui=false status") + .expect("git command after env assignment is detected"); + assert_eq!(command.program, "git"); + assert_eq!(command.subcommand.as_deref(), Some("status")); + } + + #[test] + fn handles_quoted_program_args() { + let command = detect("env FOO=bar cargo --manifest-path 'a b/Cargo.toml' test") + .expect("cargo command is detected"); + assert_eq!(command.program, "cargo"); + assert_eq!(command.subcommand.as_deref(), Some("test")); + } + + #[test] + fn normalizes_env_and_executable_path() { + let command = detect("env FOO=1 /usr/bin/git status").expect("path command is detected"); + assert_eq!(command.program, "git"); + assert_eq!(command.subcommand.as_deref(), Some("status")); + } + + #[test] + fn skips_shell_launch_wrappers() { + let command = detect("sudo -E command -p /usr/bin/git --no-pager status") + .expect("wrapped command is detected"); + assert_eq!(command.program, "git"); + assert_eq!(command.subcommand.as_deref(), Some("status")); + } + + #[test] + fn skips_cargo_toolchain_and_globals() { + let command = detect("cargo +nightly --manifest-path path/Cargo.toml clippy") + .expect("cargo command is detected"); + assert_eq!(command.program, "cargo"); + assert_eq!(command.subcommand.as_deref(), Some("clippy")); + } + + #[test] + fn skips_package_manager_globals() { + let command = detect("pnpm --filter @app/web install").expect("pnpm command is detected"); + assert_eq!(command.program, "pnpm"); + assert_eq!(command.subcommand.as_deref(), Some("install")); + } + + #[test] + fn stops_at_compound_command_boundary() { + let command = detect("git -C repo status | cat").expect("first command is detected"); + assert_eq!(command.program, "git"); + assert_eq!(command.subcommand.as_deref(), Some("status")); + } + + #[test] + fn returns_none_for_non_launching_wrappers() { + assert!(detect("command -v git").is_none()); + assert!(detect("env -S 'git status'").is_none()); + } + + #[test] + fn detects_gt_through_wrappers_and_globals() { + let command = detect("env GRAPHITE_TOKEN=x command gt --repo owner/repo submit --stack") + .expect("gt command is detected"); + assert_eq!(command.program, "gt"); + assert_eq!(command.subcommand.as_deref(), Some("submit")); + } + + #[test] + fn detects_gt_inline_global_value() { + let command = detect("gt --repo=owner/repo sync").expect("gt command is detected"); + assert_eq!(command.program, "gt"); + assert_eq!(command.subcommand.as_deref(), Some("sync")); + } +} + +#[test] +fn detects_bun_globals_and_subcommands() { + let command = detect("bun --cwd packages/app install").expect("bun command is detected"); + assert_eq!(command.program, "bun"); + assert_eq!(command.subcommand.as_deref(), Some("install")); + + let command = detect("env CI=1 /usr/local/bin/bun test").expect("bun test is detected"); + assert_eq!(command.program, "bun"); + assert_eq!(command.subcommand.as_deref(), Some("test")); +} diff --git a/crates/pi-natives/src/shell/minimizer/engine.rs b/crates/pi-natives/src/shell/minimizer/engine.rs new file mode 100644 index 000000000..285cd2b61 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/engine.rs @@ -0,0 +1,75 @@ +//! Minimizer pipeline: detect, dispatch, and fail-safe filter execution. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use crate::shell::minimizer::{MinimizerConfig, MinimizerCtx, MinimizerOutput, detect, filters}; + +/// Return true when the command has an enabled built-in filter. +pub fn should_minimize(command: &str, config: &MinimizerConfig) -> bool { + let Some(identity) = detect::detect(command) else { + return false; + }; + config.is_program_enabled(&identity.program) + && filters::supports(&identity.program, identity.subcommand.as_deref()) +} + +/// Apply a matching filter to captured output. +/// +/// Panics inside filters are caught and converted to pass-through output so +/// minimization can never be the reason a shell command loses output. +pub fn apply( + command: &str, + captured: &str, + exit_code: i32, + config: &MinimizerConfig, +) -> MinimizerOutput { + let Some(identity) = detect::detect(command) else { + return MinimizerOutput::passthrough(captured); + }; + if !config.is_program_enabled(&identity.program) + || !filters::supports(&identity.program, identity.subcommand.as_deref()) + { + return MinimizerOutput::passthrough(captured); + } + let ctx = MinimizerCtx { + program: &identity.program, + subcommand: identity.subcommand.as_deref(), + command, + config, + }; + match catch_unwind(AssertUnwindSafe(|| filters::filter(&ctx, captured, exit_code))) { + Ok(output) => output, + Err(_) => MinimizerOutput::passthrough(captured), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disabled_config_does_not_minimize() { + let cfg = MinimizerConfig::default(); + assert!(!should_minimize("git status", &cfg)); + let out = apply("git status", "## main\n", 0, &cfg); + assert!(!out.changed); + } + + #[test] + fn enabled_known_filter_minimizes() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + assert!(should_minimize("git status", &cfg)); + let out = apply("git status", "## main\n M file.rs\n", 0, &cfg); + assert!(out.changed); + assert!(out.text.contains("unstaged: 1")); + } + + #[test] + fn unknown_command_is_passthrough() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + assert!(!should_minimize("echo hello", &cfg)); + let out = apply("echo hello", "hello\n", 0, &cfg); + assert_eq!(out.text, "hello\n"); + assert!(!out.changed); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/bun.rs b/crates/pi-natives/src/shell/minimizer/filters/bun.rs new file mode 100644 index 000000000..128e86995 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/bun.rs @@ -0,0 +1,169 @@ +//! Bun package-manager, test-runner, and tool output filters. + +use super::{generic, js_tools, lint, node_tests, pkg}; +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +const BUN_PACKAGE_SUBCOMMANDS: &[&str] = &[ + "install", "i", "add", "update", "up", "upgrade", "remove", "rm", "outdated", "pm", "audit", + "run", "exec", +]; +const BUN_TEST_SUBCOMMANDS: &[&str] = &["test"]; +const BUN_BUILD_SUBCOMMANDS: &[&str] = &["build"]; +const BUN_TOOL_SUBCOMMANDS: &[&str] = + &["tsc", "eslint", "biome", "next", "prettier", "prisma", "jest", "vitest", "playwright"]; + +pub fn supports(program: &str, subcommand: Option<&str>) -> bool { + match program { + "bun" => subcommand.is_some_and(|subcommand| { + BUN_PACKAGE_SUBCOMMANDS.contains(&subcommand) + || BUN_TEST_SUBCOMMANDS.contains(&subcommand) + || BUN_BUILD_SUBCOMMANDS.contains(&subcommand) + || BUN_TOOL_SUBCOMMANDS.contains(&subcommand) + }), + "bunx" => subcommand.is_some_and(|subcommand| BUN_TOOL_SUBCOMMANDS.contains(&subcommand)), + _ => false, + } +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let subcommand = ctx.subcommand; + if is_test_invocation(ctx.program, subcommand, ctx.command) { + return node_tests::filter(ctx, input, exit_code); + } + if is_lint_invocation(ctx.program, subcommand, ctx.command) { + return lint::filter(ctx, input, exit_code); + } + if is_js_tool_invocation(ctx.program, subcommand, ctx.command) { + return js_tools::filter(ctx, input, exit_code); + } + match (ctx.program, subcommand) { + ("bun", Some(subcommand)) if BUN_PACKAGE_SUBCOMMANDS.contains(&subcommand) => { + pkg::filter(ctx, input, exit_code) + }, + ("bun", Some("build")) => filter_bun_build(input, exit_code), + _ => generic::filter(ctx, input, exit_code), + } +} + +fn is_test_invocation(program: &str, subcommand: Option<&str>, command: &str) -> bool { + matches!( + (program, subcommand), + ("bun", Some("test")) | ("bunx", Some("jest" | "vitest" | "playwright")) + ) || command_contains_tool(command, &["jest", "vitest", "playwright"]) +} + +fn is_lint_invocation(program: &str, subcommand: Option<&str>, command: &str) -> bool { + matches!((program, subcommand), ("bun" | "bunx", Some("tsc" | "eslint" | "biome"))) + || command_contains_tool(command, &["tsc", "eslint", "biome"]) +} + +fn is_js_tool_invocation(program: &str, subcommand: Option<&str>, command: &str) -> bool { + matches!((program, subcommand), ("bun" | "bunx", Some("next" | "prettier" | "prisma"))) + || command_contains_tool(command, &["next", "prettier", "prisma"]) +} + +fn command_contains_tool(command: &str, tools: &[&str]) -> bool { + command + .split(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '|' | '&')) + .any(|token| tools.contains(&token)) +} + +fn filter_bun_build(input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let mut out = String::new(); + for line in cleaned.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_bun_build_noise(trimmed, exit_code) { + continue; + } + out.push_str(line.trim_end()); + out.push('\n'); + } + let text = if out.trim().is_empty() { + primitives::head_tail_lines(&cleaned, 120, 80) + } else { + primitives::head_tail_lines(&primitives::dedup_consecutive_lines(&out), 120, 80) + }; + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn is_bun_build_noise(line: &str, exit_code: i32) -> bool { + if exit_code != 0 && is_important(line) { + return false; + } + let lower = line.to_ascii_lowercase(); + lower.starts_with("bun build ") + || lower.starts_with("bundled ") && lower.contains(" in ") + || lower.starts_with("transpiled ") + || lower.starts_with("resolving ") + || lower.starts_with("installing ") + || lower.starts_with("saved lockfile") +} + +fn is_important(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("error") + || lower.contains("failed") + || lower.contains("warning") + || lower.contains("panic") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + fn ctx<'a>( + program: &'a str, + subcommand: Option<&'a str>, + command: &'a str, + config: &'a MinimizerConfig, + ) -> MinimizerCtx<'a> { + MinimizerCtx { program, subcommand, command, config } + } + + #[test] + fn supports_bun_package_test_and_tool_subcommands() { + for subcommand in ["install", "add", "run", "test", "build", "tsc", "next"] { + assert!(supports("bun", Some(subcommand)), "{subcommand} should be supported"); + } + assert!(supports("bunx", Some("vitest"))); + assert!(!supports("bun", Some("unknown"))); + } + + #[test] + fn bun_install_uses_package_noise_filter() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("bun", Some("install"), "bun install", &cfg); + let out = filter(&ctx, "Resolving dependencies\nDownloaded left-pad\nerror: failed\n", 1); + assert!(!out.text.contains("Resolving dependencies")); + assert!(out.text.contains("error: failed")); + } + + #[test] + fn bun_test_uses_test_failure_filter() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("bun", Some("test"), "bun test", &cfg); + let out = filter(&ctx, "✓ ok\nFAIL app.test.ts\nError: nope\nTests 1 failed\n", 1); + assert!(!out.text.contains("✓ ok")); + assert!(out.text.contains("FAIL app.test.ts")); + assert!(out.text.contains("Tests 1 failed")); + } + + #[test] + fn bun_build_strips_success_noise_but_keeps_errors() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("bun", Some("build"), "bun build src/index.ts", &cfg); + let out = filter( + &ctx, + "bun build src/index.ts\nBundled 12 modules in 20ms\nerror: missing export\n", + 1, + ); + assert!(!out.text.contains("Bundled 12 modules")); + assert!(out.text.contains("error: missing export")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/cargo.rs b/crates/pi-natives/src/shell/minimizer/filters/cargo.rs new file mode 100644 index 000000000..0d96a85dd --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/cargo.rs @@ -0,0 +1,233 @@ +//! Cargo build/test output filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(subcommand: Option<&str>) -> bool { + matches!( + subcommand, + Some( + "build" + | "check" + | "test" | "clippy" + | "nextest" + | "fmt" | "doc" + | "bench" + | "run" | "metadata" + | "tree" | "update" + | "install" + | "publish" + ) + ) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = match ctx.subcommand { + Some("test" | "bench") => failures_only(&cleaned, exit_code), + Some("nextest") => filter_nextest(&cleaned), + Some("build" | "check" | "clippy" | "doc" | "run") => condense_build(&cleaned), + Some("fmt") => condense_fmt(&cleaned), + Some("metadata" | "tree" | "update" | "install" | "publish") => compact_general(&cleaned), + _ => cleaned, + }; + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn condense_build(input: &str) -> String { + let stripped = primitives::strip_lines(input, &[is_compiling_noise]); + let grouped = primitives::group_by_file(&stripped, 20); + let deduped = primitives::dedup_consecutive_lines(&grouped); + primitives::head_tail_lines(&deduped, 120, 60) +} + +fn is_compiling_noise(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("Compiling ") + || trimmed.starts_with("Checking ") + || trimmed.starts_with("Fresh ") + || trimmed.starts_with("Finished ") + || trimmed.starts_with("Documenting ") + || trimmed.starts_with("Running ") + || trimmed.starts_with("Downloading ") + || trimmed.starts_with("Downloaded ") + || trimmed.starts_with("Locking ") + || trimmed.starts_with("Updating ") +} + +fn failures_only(input: &str, exit_code: i32) -> String { + if exit_code == 0 { + return strip_passing_tests(input); + } + let mut out = String::new(); + let mut keep = false; + for line in input.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("failures:") + || trimmed.starts_with("---- ") + || trimmed.starts_with("error:") + || trimmed.starts_with("error[") + || trimmed.starts_with("thread '") + || trimmed.starts_with("test result: FAILED") + || trimmed.starts_with("test result: FAILED.") + { + keep = true; + } + if keep || trimmed.starts_with("running ") { + out.push_str(line); + out.push('\n'); + } + } + if out.is_empty() { + condense_build(input) + } else { + out + } +} + +fn strip_passing_tests(input: &str) -> String { + let mut out = String::new(); + for line in input.lines() { + let trimmed = line.trim_start(); + if is_passing_test_line(trimmed) { + continue; + } + out.push_str(line); + out.push('\n'); + } + out +} + +fn is_passing_test_line(trimmed: &str) -> bool { + trimmed.starts_with("test ") && (trimmed.ends_with(" ... ok") || trimmed.ends_with("... ok")) +} + +fn filter_nextest(input: &str) -> String { + let mut out = String::new(); + let mut in_failure = false; + let mut summary = None; + let mut canceled = false; + + for line in input.lines() { + let trimmed = line.trim(); + if is_compiling_noise(trimmed) + || trimmed.starts_with("PASS ") + || trimmed.starts_with("────") + || trimmed.starts_with("Starting ") + { + continue; + } + if trimmed.starts_with("Summary [") { + summary = Some(trimmed.to_string()); + in_failure = false; + continue; + } + if trimmed.starts_with("Cancelling") { + canceled = true; + continue; + } + if trimmed.starts_with("FAIL ") { + in_failure = true; + out.push_str(trimmed); + out.push('\n'); + continue; + } + if in_failure && !trimmed.starts_with("error: test run failed") { + out.push_str(line); + out.push('\n'); + } + } + + if canceled { + out.push_str("Cancelling due to test failure\n"); + } + if let Some(line) = summary { + out.push_str(&line); + out.push('\n'); + } + if out.is_empty() { + compact_general(input) + } else { + out + } +} + +fn condense_fmt(input: &str) -> String { + let deduped = primitives::dedup_consecutive_lines(input); + let grouped = primitives::group_by_file(&deduped, 20); + primitives::head_tail_lines(&grouped, 80, 40) +} + +fn compact_general(input: &str) -> String { + let stripped = primitives::strip_lines(input, &[is_general_cargo_noise]); + let deduped = primitives::dedup_consecutive_lines(&stripped); + primitives::head_tail_lines(&deduped, 80, 40) +} + +fn is_general_cargo_noise(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("Downloaded ") + || trimmed.starts_with("Downloading ") + || trimmed.starts_with("Compiling ") + || trimmed.starts_with("Checking ") + || trimmed.starts_with("Fresh ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + #[test] + fn strips_compiling_noise() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = MinimizerCtx { + program: "cargo", + subcommand: Some("build"), + command: "cargo build", + config: &cfg, + }; + let out = filter(&ctx, " Compiling foo v0.1.0\nerror: nope\nsrc/lib.rs:1:1 bad\n", 1); + assert!(!out.text.contains("Compiling")); + assert!(out.text.contains("error: nope")); + } + + #[test] + fn drops_passing_test_lines_on_success() { + let out = + strip_passing_tests("running 2 tests\ntest a ... ok\ntest b ... ok\ntest result: ok\n"); + assert_eq!(out, "running 2 tests\ntest result: ok\n"); + } + + #[test] + fn supports_nextest_and_keeps_failures_with_summary() { + assert!(supports(Some("nextest"))); + let out = filter_nextest( + "Starting 3 tests across 1 binary\nPASS crate::ok\nFAIL crate::bad\nstdout text\nSummary \ + [0.2s] 2 tests run: 1 passed, 1 failed\nerror: test run failed\n", + ); + assert!(!out.contains("PASS crate::ok")); + assert!(out.contains("FAIL crate::bad")); + assert!(out.contains("stdout text")); + assert!(out.contains("Summary [0.2s] 2 tests run: 1 passed, 1 failed")); + } + + #[test] + fn install_uses_general_head_tail_dedup_strategy() { + assert!(supports(Some("install"))); + let mut input = "Downloading crate\n".repeat(2); + input.push_str("Installed package `tool v1.0.0`\n"); + for i in 0..130 { + input.push_str("line "); + input.push_str(&i.to_string()); + input.push('\n'); + } + let out = compact_general(&input); + assert!(!out.contains("Downloading crate")); + assert!(out.contains("Installed package `tool v1.0.0`")); + assert!(out.contains("lines omitted")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/cloud.rs b/crates/pi-natives/src/shell/minimizer/filters/cloud.rs new file mode 100644 index 000000000..f03cdc8ba --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/cloud.rs @@ -0,0 +1,436 @@ +//! Cloud and data command output filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +const MAX_AWS_LINES: usize = 120; +const MAX_HTTP_LINES: usize = 100; +const MAX_PSQL_ROWS: usize = 30; +const MAX_LINE_CHARS: usize = 500; + +pub fn supports(program: &str, _subcommand: Option<&str>) -> bool { + matches!(program, "aws" | "curl" | "wget" | "psql") +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = match ctx.program { + "aws" => filter_aws(&cleaned, exit_code), + "curl" | "wget" => filter_http_transfer(&cleaned, exit_code), + "psql" => filter_psql(&cleaned, exit_code), + _ => head_tail_dedup(&cleaned, 80, 40), + }; + + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn filter_aws(input: &str, exit_code: i32) -> String { + let without_progress = strip_transfer_progress(input); + let compacted = if looks_like_table(&without_progress) { + compact_delimited_table(&without_progress, 40) + } else { + compact_jsonish_or_text(&without_progress, MAX_AWS_LINES, 70, 50) + }; + + if exit_code == 0 { + compacted + } else { + preserve_important_lines(&without_progress, &compacted) + } +} + +fn filter_http_transfer(input: &str, exit_code: i32) -> String { + let without_progress = strip_transfer_progress(input); + let compacted = compact_jsonish_or_text(&without_progress, MAX_HTTP_LINES, 60, 40); + if exit_code == 0 { + compacted + } else { + preserve_important_lines(&without_progress, &compacted) + } +} + +fn filter_psql(input: &str, exit_code: i32) -> String { + if input.trim().is_empty() { + return String::new(); + } + + let compacted = if looks_like_psql_table(input) { + compact_psql_table(input) + } else if looks_like_psql_expanded(input) { + compact_psql_expanded(input) + } else { + compact_jsonish_or_text(input, 120, 80, 40) + }; + + if exit_code == 0 { + preserve_important_lines(input, &compacted) + } else { + preserve_important_lines(input, &head_tail_dedup(&compacted, 80, 40)) + } +} + +fn strip_transfer_progress(input: &str) -> String { + let mut out = String::new(); + for line in input.lines() { + if is_transfer_progress_line(line) { + continue; + } + out.push_str(line.trim_end()); + out.push('\n'); + } + out +} + +fn is_transfer_progress_line(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.is_empty() { + return false; + } + if trimmed.starts_with("% Total") || trimmed.contains(" Dload ") && trimmed.contains(" Upload ") + { + return true; + } + if trimmed.starts_with("--") && trimmed.contains("://") { + return true; + } + if trimmed.starts_with("Resolving ") + || trimmed.starts_with("Connecting to ") + || trimmed.starts_with("HTTP request sent") + || trimmed.starts_with("Length: ") + || trimmed.starts_with("Saving to:") + || trimmed.starts_with("Downloaded:") + { + return true; + } + if trimmed.contains("--:--:--") || trimmed.contains("100%[") { + return true; + } + if trimmed.contains('%') && trimmed.contains('[') && trimmed.contains(']') { + return true; + } + if trimmed.contains('%') && (trimmed.contains("K/s") || trimmed.contains("M/s")) { + return true; + } + let first_is_digit = trimmed.chars().next().is_some_and(|ch| ch.is_ascii_digit()); + first_is_digit && trimmed.contains('%') +} + +fn compact_jsonish_or_text(input: &str, max_lines: usize, head: usize, tail: usize) -> String { + let line_compacted = compact_long_lines(input); + if line_compacted.lines().count() <= max_lines { + line_compacted + } else { + primitives::head_tail_lines(&line_compacted, head, tail) + } +} + +fn compact_long_lines(input: &str) -> String { + let mut out = String::new(); + for line in input.lines() { + let compacted = compact_line(line, MAX_LINE_CHARS); + out.push_str(&compacted); + out.push('\n'); + } + out +} + +fn compact_line(line: &str, max_chars: usize) -> String { + let chars: Vec = line.chars().collect(); + if chars.len() <= max_chars { + return line.to_string(); + } + let edge = max_chars / 2; + let start: String = chars.iter().take(edge).collect(); + let end: String = chars.iter().skip(chars.len() - edge).collect(); + format!("{start} … {} chars omitted … {end}", chars.len() - edge * 2) +} + +fn looks_like_table(input: &str) -> bool { + input.lines().any(|line| { + let trimmed = line.trim(); + trimmed.starts_with('+') && trimmed.ends_with('+') && trimmed.contains('-') + }) || input + .lines() + .any(|line| line.contains("---+---") || line.contains("-+-")) +} + +fn looks_like_psql_table(input: &str) -> bool { + input + .lines() + .any(|line| line.contains("---+---") || line.contains("-+-")) + || input.lines().any(|line| { + let trimmed = line.trim(); + trimmed.starts_with('+') && trimmed.ends_with('+') && trimmed.contains('-') + }) +} + +fn looks_like_psql_expanded(input: &str) -> bool { + input.lines().any(|line| { + let trimmed = line.trim(); + trimmed.starts_with("-[ RECORD ") && trimmed.ends_with(" ]-") + }) +} + +fn compact_delimited_table(input: &str, max_rows: usize) -> String { + let mut out = Vec::new(); + let mut data_rows = 0usize; + let mut saw_header = false; + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_border_line(trimmed) { + continue; + } + let normalized = if trimmed.contains('|') { + normalize_pipe_row(trimmed) + } else { + trimmed.to_string() + }; + if !saw_header { + saw_header = true; + out.push(normalized); + continue; + } + data_rows += 1; + if data_rows <= max_rows || is_important_line(trimmed) { + out.push(normalized); + } + } + if data_rows > max_rows { + out.push(format!("… {} more rows", data_rows - max_rows)); + } + join_lines(out) +} + +fn compact_psql_table(input: &str) -> String { + let mut out = Vec::new(); + let mut row_count_lines = Vec::new(); + let mut data_rows = 0usize; + let mut saw_header = false; + + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_border_line(trimmed) { + continue; + } + if is_psql_row_count(trimmed) { + row_count_lines.push(trimmed.to_string()); + continue; + } + if is_important_line(trimmed) { + out.push(trimmed.to_string()); + continue; + } + if trimmed.contains('|') { + let normalized = normalize_pipe_row(trimmed); + if !saw_header { + saw_header = true; + out.push(normalized); + continue; + } + data_rows += 1; + if data_rows <= MAX_PSQL_ROWS { + out.push(normalized); + } + } else { + out.push(trimmed.to_string()); + } + } + + if data_rows > MAX_PSQL_ROWS { + out.push(format!("... +{} more rows", data_rows - MAX_PSQL_ROWS)); + } + out.extend(row_count_lines); + join_lines(out) +} + +fn compact_psql_expanded(input: &str) -> String { + let mut out = Vec::new(); + let mut current = Vec::new(); + let mut records = 0usize; + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_psql_row_count(trimmed) { + continue; + } + if trimmed.starts_with("-[ RECORD ") && trimmed.ends_with(" ]-") { + flush_record(&mut out, &mut current, records); + records += 1; + current.push(trimmed.to_string()); + continue; + } + if is_important_line(trimmed) && current.is_empty() { + out.push(trimmed.to_string()); + continue; + } + if let Some((key, value)) = trimmed.split_once('|') { + current.push(format!("{}={}", key.trim(), value.trim())); + } else if current.is_empty() { + out.push(trimmed.to_string()); + } + } + flush_record(&mut out, &mut current, records); + if records > MAX_PSQL_ROWS { + out.push(format!("... +{} more records", records - MAX_PSQL_ROWS)); + } + join_lines(out) +} + +fn flush_record(out: &mut Vec, current: &mut Vec, records: usize) { + if current.is_empty() { + return; + } + if records <= MAX_PSQL_ROWS { + out.push(current.join(" ")); + } + current.clear(); +} + +fn normalize_pipe_row(line: &str) -> String { + line + .trim_matches('|') + .split('|') + .map(str::trim) + .collect::>() + .join("\t") +} + +fn is_border_line(line: &str) -> bool { + let trimmed = line.trim(); + !trimmed.is_empty() + && trimmed + .chars() + .all(|ch| matches!(ch, '+' | '-' | '=' | '|' | ' ')) + && (trimmed.contains('-') || trimmed.contains('=')) +} + +fn is_psql_row_count(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.starts_with('(') + && trimmed.ends_with(')') + && trimmed.contains(" row") + && trimmed.chars().any(|ch| ch.is_ascii_digit()) +} + +fn preserve_important_lines(original: &str, compacted: &str) -> String { + let mut out = Vec::new(); + for line in original.lines() { + let trimmed = line.trim(); + if is_important_line(trimmed) + && !contains_line(&out, trimmed) + && !compacted.lines().any(|existing| existing.trim() == trimmed) + { + out.push(trimmed.to_string()); + } + } + if out.is_empty() { + return compacted.to_string(); + } + out.push(compacted.trim_end().to_string()); + join_lines(out) +} + +fn is_important_line(line: &str) -> bool { + let upper = line.trim_start().to_ascii_uppercase(); + upper.starts_with("ERROR") + || upper.starts_with("FATAL") + || upper.starts_with("PANIC") + || upper.starts_with("DETAIL") + || upper.starts_with("HINT") + || upper.starts_with("LINE ") + || upper.starts_with("SQLSTATE") + || upper.starts_with("AN ERROR OCCURRED") + || upper.contains("EXCEPTION") +} + +fn contains_line(lines: &[String], needle: &str) -> bool { + lines.iter().any(|line| line == needle) +} + +fn head_tail_dedup(input: &str, head: usize, tail: usize) -> String { + primitives::head_tail_lines(&primitives::dedup_consecutive_lines(input), head, tail) +} + +fn join_lines(lines: Vec) -> String { + if lines.is_empty() { + String::new() + } else { + let mut out = lines.join("\n"); + out.push('\n'); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + fn ctx<'a>(program: &'a str, cfg: &'a MinimizerConfig) -> MinimizerCtx<'a> { + MinimizerCtx { program, subcommand: None, command: program, config: cfg } + } + + #[test] + fn strips_curl_progress_and_keeps_json_body() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("curl", &cfg); + let input = " % Total % Received % Xferd Average Speed Time Time Time \ + Current\n100 1234 100 1234 0 0 9999 0 --:--:-- --:--:-- \ + --:--:-- 9999\n{\"ok\":true}\n"; + let out = filter(&ctx, input, 0); + assert!(!out.text.contains("% Total")); + assert!(!out.text.contains("--:--:--")); + assert!(out.text.contains("{\"ok\":true}")); + } + + #[test] + fn strips_wget_progress_and_keeps_body() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("wget", &cfg); + let input = "--2026-04-24-- https://example.test/data.json\nResolving example.test... \ + 127.0.0.1\n 0K .......... .......... 50% 1.2M 0s\n 20K .......... \ + .......... 100% 2.0M=0.1s\n[{\"id\":1}]\n"; + let out = filter(&ctx, input, 0); + assert!(!out.text.contains("Resolving example")); + assert!(!out.text.contains("........")); + assert!(out.text.contains("[{\"id\":1}]")); + } + + #[test] + fn preserves_psql_table_row_count_and_errors() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("psql", &cfg); + let input = " id | name\n----+------\n 1 | alice\n 2 | bob\nERROR: duplicate key value \ + violates unique constraint\n(2 rows)\n"; + let out = filter(&ctx, input, 1); + assert!(out.text.contains("id\tname")); + assert!(out.text.contains("1\talice")); + assert!( + out.text + .contains("ERROR: duplicate key value violates unique constraint") + ); + assert!(out.text.contains("(2 rows)")); + } + + #[test] + fn compacts_long_aws_output() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("aws", &cfg); + let mut input = String::new(); + for idx in 0..160 { + input.push_str("{\"InstanceId\":\"i-"); + let id = idx.to_string(); + for _ in id.len()..4 { + input.push('0'); + } + input.push_str(&id); + input.push_str("\"}\n"); + } + let out = filter(&ctx, &input, 0); + assert!(out.text.contains("… 40 lines omitted …")); + assert!(out.text.contains("i-0000")); + assert!(out.text.contains("i-0159")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/docker.rs b/crates/pi-natives/src/shell/minimizer/filters/docker.rs new file mode 100644 index 000000000..adc69218d --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/docker.rs @@ -0,0 +1,187 @@ +//! Container and cloud command output filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(subcommand: Option<&str>) -> bool { + matches!( + subcommand, + Some( + "ps" + | "images" + | "logs" | "compose" + | "build" + | "pull" | "push" + | "get" | "describe" + | "status" + | "list" | "ls" + | "install" + | "upgrade" + | "template" + | "lint" + ) + ) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = match ctx.program { + "docker" => filter_docker(ctx, &cleaned, exit_code), + "kubectl" => filter_kubectl(ctx, &cleaned, exit_code), + "helm" => filter_helm(ctx, &cleaned, exit_code), + _ => head_tail_dedup(&cleaned), + }; + + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn filter_docker(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> String { + if is_log_command(ctx) { + return filter_logs(input); + } + if is_table_command(ctx) { + return compact_table(input, 12); + } + if exit_code != 0 { + return head_tail_dedup(input); + } + compact_build_or_progress(input) +} + +fn filter_kubectl(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> String { + match ctx.subcommand { + Some("logs") => filter_logs(input), + Some("get") => compact_table(input, 20), + Some("describe") => { + primitives::head_tail_lines(&primitives::dedup_consecutive_lines(input), 120, 80) + }, + _ if exit_code != 0 => head_tail_dedup(input), + _ => compact_build_or_progress(input), + } +} + +fn filter_helm(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> String { + match ctx.subcommand { + Some("list" | "ls" | "status") => compact_table(input, 20), + Some("install" | "upgrade" | "template" | "lint") => compact_build_or_progress(input), + _ if exit_code != 0 => head_tail_dedup(input), + _ => head_tail_dedup(input), + } +} + +fn is_log_command(ctx: &MinimizerCtx<'_>) -> bool { + ctx.subcommand == Some("logs") || ctx.command.split_whitespace().any(|part| part == "logs") +} + +fn is_table_command(ctx: &MinimizerCtx<'_>) -> bool { + matches!(ctx.subcommand, Some("ps" | "images")) + || ctx + .command + .split_whitespace() + .any(|part| matches!(part, "ps" | "images")) +} + +fn filter_logs(input: &str) -> String { + let without_empty_runs = drop_repeated_blank_lines(input); + let deduped = primitives::dedup_consecutive_lines(&without_empty_runs); + primitives::head_tail_lines(&deduped, 120, 80) +} + +fn compact_table(input: &str, visible_rows: usize) -> String { + let lines: Vec<&str> = input + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + if lines.len() <= visible_rows + 1 { + return input.to_string(); + } + + let mut out = String::new(); + if let Some(header) = lines.first() { + out.push_str(header.trim_end()); + out.push('\n'); + } + out.push_str(&(lines.len() - 1).to_string()); + out.push_str(" rows\n"); + for line in lines.iter().skip(1).take(visible_rows) { + out.push_str(line.trim_end()); + out.push('\n'); + } + out.push_str("… "); + out.push_str(&(lines.len() - 1 - visible_rows).to_string()); + out.push_str(" more rows\n"); + out +} + +fn compact_build_or_progress(input: &str) -> String { + let mut out = String::new(); + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_progress_line(trimmed) { + continue; + } + out.push_str(line.trim_end()); + out.push('\n'); + } + head_tail_dedup(&out) +} + +fn is_progress_line(line: &str) -> bool { + line.starts_with("=> ") + || line.starts_with('#') && line.contains("DONE") + || line.contains("Pulling fs layer") + || line.contains("Download complete") + || line.contains("Extracting") + || line.contains("Waiting") + || line.contains("Verifying Checksum") +} + +fn drop_repeated_blank_lines(input: &str) -> String { + let mut out = String::new(); + let mut saw_blank = false; + for line in input.lines() { + if line.trim().is_empty() { + if !saw_blank { + out.push('\n'); + } + saw_blank = true; + continue; + } + saw_blank = false; + out.push_str(line); + out.push('\n'); + } + out +} + +fn head_tail_dedup(input: &str) -> String { + primitives::head_tail_lines(&primitives::dedup_consecutive_lines(input), 120, 80) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedups_repeated_log_lines_before_truncation() { + let input = "api | ready\napi | ready\napi | ready\napi | failed\n"; + let out = filter_logs(input); + assert!(out.contains("api | ready (×3)")); + assert!(out.contains("api | failed")); + } + + #[test] + fn compacts_large_table_with_header_and_omission_count() { + let mut input = String::from("ID IMAGE STATUS\n"); + for i in 0..25 { + input.push_str(&i.to_string()); + input.push_str(" img running\n"); + } + let out = compact_table(&input, 10); + assert!(out.contains("25 rows")); + assert!(out.contains("… 15 more rows")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/dotnet.rs b/crates/pi-natives/src/shell/minimizer/filters/dotnet.rs new file mode 100644 index 000000000..f611d4eee --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/dotnet.rs @@ -0,0 +1,360 @@ +//! .NET CLI output filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(program: &str, subcommand: Option<&str>) -> bool { + program == "dotnet" && matches!(subcommand, Some("build" | "test" | "restore" | "format")) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = match ctx.subcommand { + Some("build") => filter_build_like("dotnet build", &cleaned, exit_code), + Some("test") => filter_test(&cleaned, exit_code), + Some("restore") => filter_build_like("dotnet restore", &cleaned, exit_code), + Some("format") => filter_format(&cleaned), + _ => compact_general(&cleaned), + }; + + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn filter_build_like(label: &str, input: &str, exit_code: i32) -> String { + let mut diagnostics = String::new(); + let mut summaries = String::new(); + + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_dotnet_boilerplate(trimmed) { + continue; + } + if is_msbuild_diagnostic(trimmed) || is_failure_line(trimmed) { + diagnostics.push_str(trimmed); + diagnostics.push('\n'); + } else if is_dotnet_summary(trimmed) { + summaries.push_str(trimmed); + summaries.push('\n'); + } + } + + let mut out = String::new(); + if exit_code != 0 { + out.push_str(label); + out.push_str(": failed\n"); + } + out.push_str(&primitives::group_by_file(&diagnostics, 24)); + out.push_str(&summaries); + + if out.trim().is_empty() { + compact_general(input) + } else { + primitives::head_tail_lines(&primitives::dedup_consecutive_lines(&out), 140, 80) + } +} + +fn filter_test(input: &str, exit_code: i32) -> String { + let mut out = String::new(); + let mut in_failed_test = false; + + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_dotnet_boilerplate(trimmed) { + continue; + } + + if is_failed_test_start(trimmed) { + in_failed_test = true; + out.push_str(trimmed); + out.push('\n'); + continue; + } + + if in_failed_test { + if is_test_section_boundary(trimmed) { + in_failed_test = false; + } else { + out.push_str(trimmed); + out.push('\n'); + continue; + } + } + + if is_msbuild_diagnostic(trimmed) || is_failure_line(trimmed) || is_test_summary(trimmed) { + out.push_str(trimmed); + out.push('\n'); + } + } + + if out.trim().is_empty() { + return filter_build_like("dotnet test", input, exit_code); + } + + primitives::head_tail_lines(&primitives::dedup_consecutive_lines(&out), 180, 100) +} + +fn filter_format(input: &str) -> String { + let trimmed = input.trim(); + if (trimmed.starts_with('{') || trimmed.starts_with('[')) + && let Some(out) = compact_format_json(trimmed) + { + return out; + } + + let mut out = String::new(); + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_dotnet_format_noise(trimmed) { + continue; + } + if is_msbuild_diagnostic(trimmed) + || looks_like_path(trimmed) + || contains_format_signal(trimmed) + { + out.push_str(trimmed); + out.push('\n'); + } + } + + if out.is_empty() { + compact_general(input) + } else { + primitives::head_tail_lines(&primitives::dedup_consecutive_lines(&out), 140, 80) + } +} + +fn compact_format_json(input: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(input).ok()?; + let mut rows = Vec::new(); + collect_format_json_rows(&value, None, &mut rows); + if rows.is_empty() { + return Some("dotnet format: no diagnostics in report\n".to_string()); + } + + let mut out = format!("dotnet format: {} diagnostics\n", rows.len()); + for row in rows.iter().take(40) { + out.push_str(row); + out.push('\n'); + } + if rows.len() > 40 { + out.push_str("… "); + out.push_str(&(rows.len() - 40).to_string()); + out.push_str(" more diagnostics\n"); + } + Some(out) +} + +fn collect_format_json_rows( + value: &serde_json::Value, + inherited_path: Option<&str>, + rows: &mut Vec, +) { + match value { + serde_json::Value::Object(map) => { + let path = + first_string(map, &["FileName", "FilePath", "Path", "DocumentPath"]).or(inherited_path); + let diagnostic = first_string(map, &["DiagnosticId", "Id", "RuleId"]); + let message = first_string(map, &["Message", "FormatDescription", "Description"]); + let line = first_number(map, &["LineNumber", "Line"]); + let column = first_number(map, &["CharNumber", "Column"]); + + if diagnostic.is_some() || message.is_some() || line.is_some() { + let mut row = if let Some(path) = path { + path.to_string() + } else { + "".to_string() + }; + if let Some(line) = line { + row.push(':'); + row.push_str(&line.to_string()); + } + if let Some(column) = column { + row.push(':'); + row.push_str(&column.to_string()); + } + if let Some(diagnostic) = diagnostic { + row.push_str(": "); + row.push_str(diagnostic); + } + if let Some(message) = message { + if diagnostic.is_none() { + row.push_str(": "); + } else { + row.push_str(" - "); + } + row.push_str(message); + } + rows.push(row); + } + + for child in map.values() { + collect_format_json_rows(child, path, rows); + } + }, + serde_json::Value::Array(items) => { + for item in items { + collect_format_json_rows(item, inherited_path, rows); + } + }, + _ => {}, + } +} + +fn first_string<'a>( + map: &'a serde_json::Map, + keys: &[&str], +) -> Option<&'a str> { + keys + .iter() + .find_map(|key| map.get(*key).and_then(|value| value.as_str())) +} + +fn first_number(map: &serde_json::Map, keys: &[&str]) -> Option { + keys + .iter() + .find_map(|key| map.get(*key).and_then(|value| value.as_u64())) +} + +fn compact_general(input: &str) -> String { + let stripped = primitives::strip_lines(input, &[is_dotnet_boilerplate]); + let deduped = primitives::dedup_consecutive_lines(&stripped); + primitives::head_tail_lines(&deduped, 120, 80) +} + +fn is_dotnet_boilerplate(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.starts_with("determining projects to restore") + || lower.starts_with("all projects are up-to-date for restore") + || lower.starts_with("restored ") && !contains_diagnostic_signal(&lower) + || lower.starts_with(" ") && lower.contains(" -> ") + || lower.starts_with("build started") + || lower.starts_with("build succeeded") + || lower.starts_with("test run for ") + || lower.starts_with("starting test execution") + || lower.starts_with("a total of ") && lower.contains("test files matched") +} + +fn is_dotnet_format_noise(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + is_dotnet_boilerplate(line) + || lower.starts_with("formatting code files") + || lower.starts_with("running formatters") + || lower.starts_with(" formatted ") && !contains_diagnostic_signal(&lower) +} + +fn is_msbuild_diagnostic(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + looks_like_msbuild_location(line) && (lower.contains("error") || lower.contains("warning")) +} + +fn looks_like_msbuild_location(line: &str) -> bool { + line.contains(":line ") + || line.contains(".cs(") + || line.contains(".fs(") + || line.contains(".vb(") + || line.contains(".csproj") + || line.contains(".fsproj") + || line.contains(".vbproj") + || line.contains(".sln") +} + +fn looks_like_path(line: &str) -> bool { + line.contains('/') + || line.contains('\\') + || line.contains(".cs") + || line.contains(".fs") + || line.contains(".vb") +} + +fn is_failure_line(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + contains_diagnostic_signal(&lower) + || lower.starts_with("failed! ") + || lower.starts_with("failed ") + || lower.starts_with("error ") + || lower.starts_with("warning ") +} + +fn is_dotnet_summary(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.starts_with("build failed") + || lower.starts_with("restore failed") + || lower.starts_with("time elapsed") + || lower.contains(" error(s)") + || lower.contains(" warning(s)") +} + +fn is_test_summary(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.starts_with("total tests:") + || lower.starts_with("passed:") + || lower.starts_with("failed:") + || lower.starts_with("skipped:") + || lower.starts_with("test run failed") + || lower.starts_with("test run successful") + || lower.contains("failed:") && lower.contains("passed:") +} + +fn is_failed_test_start(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.starts_with("failed ") || lower.starts_with("[fail]") || lower.contains(" failed [") +} + +fn is_test_section_boundary(line: &str) -> bool { + is_test_summary(line) || line.starts_with("Passed ") || line.starts_with("Skipped ") +} + +fn contains_format_signal(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("format") + || lower.contains("whitespace") + || lower.contains("diagnostic") + || lower.contains("files formatted") + || lower.contains("files need formatting") +} + +fn contains_diagnostic_signal(lower: &str) -> bool { + lower.contains("error") + || lower.contains("warning") + || lower.contains("failed") + || lower.contains("exception") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + #[test] + fn keeps_dotnet_build_diagnostic_and_strips_restore_noise() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = MinimizerCtx { + program: "dotnet", + subcommand: Some("build"), + command: "dotnet build", + config: &cfg, + }; + let input = " Determining projects to restore...\n Restored app.csproj (in 1 \ + sec).\nProgram.cs(10,5): error CS1002: ; expected [/repo/app.csproj]\nBuild \ + FAILED.\n 0 Warning(s)\n 1 Error(s)\n"; + + let out = filter(&ctx, input, 1); + assert!(out.text.contains("dotnet build: failed")); + assert!(out.text.contains("Program.cs(10,5): error CS1002")); + assert!(out.text.contains("1 Error(s)")); + assert!(!out.text.contains("Determining projects")); + assert!(!out.text.contains("Restored app.csproj")); + } + + #[test] + fn compacts_dotnet_format_json_report() { + let input = r#"{"FileName":"src/App.cs","Changes":[{"DiagnosticId":"IDE0055","LineNumber":4,"CharNumber":9,"FormatDescription":"Fix formatting"}]}"#; + let out = filter_format(input); + assert!(out.contains("dotnet format: 1 diagnostics")); + assert!(out.contains("src/App.cs:4:9")); + assert!(out.contains("IDE0055")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/generic.rs b/crates/pi-natives/src/shell/minimizer/filters/generic.rs new file mode 100644 index 000000000..8833a336e --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/generic.rs @@ -0,0 +1,18 @@ +//! Generic fallback transforms. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn filter(_ctx: &MinimizerCtx<'_>, input: &str, _exit_code: i32) -> MinimizerOutput { + let stripped = primitives::strip_ansi(input); + let deduped = primitives::dedup_consecutive_lines(&stripped); + let text = if deduped.lines().count() > 200 { + primitives::head_tail_lines(&deduped, 100, 60) + } else { + deduped + }; + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/gh.rs b/crates/pi-natives/src/shell/minimizer/filters/gh.rs new file mode 100644 index 000000000..d5b3e6574 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/gh.rs @@ -0,0 +1,133 @@ +//! GitHub CLI output filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(subcommand: Option<&str>) -> bool { + matches!( + subcommand, + Some( + "pr" + | "issue" + | "run" | "workflow" + | "repo" | "api" + | "search" + | "release" + | "codespace" + | "gist" + ) + ) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = match ctx.subcommand { + Some("pr" | "issue") => filter_pr_issue(&cleaned, exit_code), + Some("run" | "workflow") => filter_run(&cleaned, exit_code), + Some("api") if exit_code == 0 => primitives::head_tail_lines(&cleaned, 80, 80), + _ => head_tail_dedup(&cleaned), + }; + + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn filter_pr_issue(input: &str, exit_code: i32) -> String { + if exit_code != 0 { + return head_tail_dedup(input); + } + let markdown_filtered = filter_markdown_noise(input); + head_tail_dedup(&markdown_filtered) +} + +fn filter_run(input: &str, exit_code: i32) -> String { + let deduped = primitives::dedup_consecutive_lines(input); + if exit_code != 0 || contains_failure_signal(input) { + return primitives::head_tail_lines(&deduped, 160, 120); + } + primitives::head_tail_lines(&deduped, 120, 80) +} + +fn filter_markdown_noise(input: &str) -> String { + let mut out = String::new(); + let mut in_html_comment = false; + let mut previous_blank = false; + + for line in input.lines() { + let trimmed = line.trim(); + if in_html_comment { + if trimmed.contains("-->") { + in_html_comment = false; + } + continue; + } + if trimmed.starts_with("") { + in_html_comment = true; + } + continue; + } + if is_markdown_badge_or_image(trimmed) || is_horizontal_rule(trimmed) { + continue; + } + if trimmed.is_empty() { + if !previous_blank { + out.push('\n'); + } + previous_blank = true; + continue; + } + previous_blank = false; + out.push_str(line.trim_end()); + out.push('\n'); + } + out +} + +fn is_markdown_badge_or_image(line: &str) -> bool { + line.starts_with("![") || line.starts_with("[![") || line.contains("img.shields.io") +} + +fn is_horizontal_rule(line: &str) -> bool { + line.len() >= 3 && line.chars().all(|ch| matches!(ch, '-' | '*' | '_' | ' ')) +} + +fn contains_failure_signal(input: &str) -> bool { + input.lines().any(|line| { + let lower = line.to_ascii_lowercase(); + lower.contains("error") + || lower.contains("failed") + || lower.contains("failure") + || lower.contains("cancelled") + }) +} + +fn head_tail_dedup(input: &str) -> String { + primitives::head_tail_lines(&primitives::dedup_consecutive_lines(input), 120, 80) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pr_issue_filter_removes_markdown_template_noise() { + let input = + "\n# Title\n[![CI](https://img.shields.io/badge.svg)](url)\nBody\n---\n"; + let out = filter_pr_issue(input, 0); + assert!(!out.contains("template")); + assert!(!out.contains("shields.io")); + assert!(out.contains("# Title")); + assert!(out.contains("Body")); + } + + #[test] + fn run_filter_preserves_failure_tail_and_dedups() { + let input = "step ok\nstep ok\nError: failed job\n"; + let out = filter_run(input, 1); + assert!(out.contains("step ok (×2)")); + assert!(out.contains("Error: failed job")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/git.rs b/crates/pi-natives/src/shell/minimizer/filters/git.rs new file mode 100644 index 000000000..400dd1a3f --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/git.rs @@ -0,0 +1,277 @@ +//! Git output filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(subcommand: Option<&str>) -> bool { + matches!( + subcommand, + Some( + "status" + | "diff" | "show" + | "log" | "add" + | "commit" + | "push" | "pull" + | "branch" + | "fetch" + | "stash" + | "worktree" + | "merge" + | "rebase" + | "checkout" + | "switch" + | "restore" + | "clean" + | "reset" + | "tag", + ), + ) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, _exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = match ctx.subcommand { + Some("status") => condense_status(&cleaned), + Some("diff" | "show") => primitives::head_tail_lines(&cleaned, 80, 40), + Some("log") => condense_log(&cleaned, 32, 16), + Some("branch" | "stash" | "worktree" | "tag") => primitives::compact_listing(&cleaned, 40), + Some( + "push" | "pull" | "fetch" | "merge" | "rebase" | "checkout" | "switch" | "restore" + | "clean" | "reset" | "add" | "commit", + ) => condense_noisy_output(&cleaned), + _ => cleaned, + }; + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn condense_status(input: &str) -> String { + if input + .lines() + .any(|line| line.starts_with("## ") || is_short_status(line)) + { + return summarize_short_status(input); + } + let mut branch = None; + let mut staged = 0usize; + let mut unstaged = 0usize; + let mut untracked = 0usize; + let mut conflicts = 0usize; + let mut current: Option<&str> = None; + for line in input.lines() { + let trimmed = line.trim(); + if let Some(name) = trimmed.strip_prefix("On branch ") { + branch = Some(name); + continue; + } + if trimmed.starts_with("Changes to be committed") { + current = Some("staged"); + continue; + } + if trimmed.starts_with("Changes not staged") { + current = Some("unstaged"); + continue; + } + if trimmed.starts_with("Untracked files") { + current = Some("untracked"); + continue; + } + if trimmed.starts_with("Unmerged paths") { + current = Some("conflicts"); + continue; + } + if trimmed.starts_with("modified:") + || trimmed.starts_with("new file:") + || trimmed.starts_with("deleted:") + || trimmed.starts_with("renamed:") + { + match current { + Some("staged") => staged += 1, + Some("unstaged") => unstaged += 1, + Some("conflicts") => conflicts += 1, + _ => {}, + } + } else if current == Some("untracked") && !trimmed.is_empty() && !trimmed.starts_with('(') { + untracked += 1; + } + } + if staged + unstaged + untracked + conflicts == 0 { + return input.to_string(); + } + let mut out = String::from("git status summary"); + if let Some(branch) = branch { + out.push_str(" on "); + out.push_str(branch); + } + out.push('\n'); + push_count(&mut out, "staged", staged); + push_count(&mut out, "unstaged", unstaged); + push_count(&mut out, "untracked", untracked); + push_count(&mut out, "conflicts", conflicts); + out +} + +fn summarize_short_status(input: &str) -> String { + let mut branch = None; + let mut staged = 0usize; + let mut unstaged = 0usize; + let mut untracked = 0usize; + let mut conflicts = 0usize; + for line in input.lines() { + if let Some(value) = line.strip_prefix("## ") { + branch = Some(value); + continue; + } + if line.starts_with("??") { + untracked += 1; + continue; + } + let mut chars = line.chars(); + let x = chars.next(); + let y = chars.next(); + if matches!(x, Some('U' | 'A' | 'D')) && matches!(y, Some('U' | 'A' | 'D')) { + conflicts += 1; + continue; + } + if matches!(x, Some('M' | 'A' | 'D' | 'R' | 'C')) { + staged += 1; + } + if matches!(y, Some('M' | 'D')) { + unstaged += 1; + } + } + let mut out = String::from("git status summary"); + if let Some(branch) = branch { + out.push_str(" on "); + out.push_str(branch); + } + out.push('\n'); + push_count(&mut out, "staged", staged); + push_count(&mut out, "unstaged", unstaged); + push_count(&mut out, "untracked", untracked); + push_count(&mut out, "conflicts", conflicts); + out +} + +fn is_short_status(line: &str) -> bool { + line.starts_with("??") || line.len() > 2 && line.as_bytes().get(2) == Some(&b' ') +} + +fn push_count(out: &mut String, label: &str, count: usize) { + if count == 0 { + return; + } + out.push_str(label); + out.push_str(": "); + out.push_str(&count.to_string()); + out.push('\n'); +} + +fn condense_log(input: &str, head: usize, tail: usize) -> String { + let mut out = String::new(); + for line in input.lines() { + if let Some(commit) = line.strip_prefix("commit ") { + out.push_str("commit "); + if let Some(short) = commit.get(..12) { + out.push_str(short); + } else { + out.push_str(commit); + } + out.push('\n'); + } else if !(line.trim_start().starts_with("Author:") + || line.trim_start().starts_with("Date:")) + { + out.push_str(line.trim_end()); + out.push('\n'); + } + } + primitives::head_tail_lines(&out, head, tail) +} + +fn condense_noisy_output(input: &str) -> String { + let deduped = primitives::dedup_consecutive_lines(input); + primitives::head_tail_lines(&deduped, 80, 40) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + fn test_ctx<'a>( + subcommand: Option<&'a str>, + command: &'a str, + config: &'a MinimizerConfig, + ) -> MinimizerCtx<'a> { + MinimizerCtx { program: "git", subcommand, command, config } + } + + #[test] + fn condenses_short_status() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = test_ctx(Some("status"), "git status", &cfg); + let out = filter(&ctx, "## main\n M a.rs\n?? b.rs\n", 0); + assert!(out.text.contains("unstaged: 1")); + assert!(out.text.contains("untracked: 1")); + } + + #[test] + fn supports_git_coverage_subcommands() { + for subcommand in ["show", "branch", "fetch", "stash", "worktree"] { + assert!(supports(Some(subcommand)), "{subcommand} should be buffered"); + } + } + + #[test] + fn branch_listing_is_compacted() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = test_ctx(Some("branch"), "git branch -a", &cfg); + let mut input = String::new(); + for idx in 0..60 { + input.push_str(" feature/"); + input.push_str(&idx.to_string()); + input.push('\n'); + } + let out = filter(&ctx, &input, 0); + assert!(out.text.starts_with("60 entries\n")); + assert!(out.text.contains("feature/0")); + assert!(out.text.contains("feature/59")); + assert!(out.text.contains("…")); + } + + #[test] + fn fetch_output_strips_ansi_and_dedups_progress() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = test_ctx(Some("fetch"), "git fetch", &cfg); + let out = filter( + &ctx, + "\x1b[32mremote: Counting objects: 1\x1b[0m\nremote: Counting objects: 1\nerror: failed\n", + 1, + ); + assert_eq!(out.text, "remote: Counting objects: 1 (×2)\nerror: failed\n"); + } + + #[test] + fn log_is_head_tail_truncated_after_metadata_removal() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = test_ctx(Some("log"), "git log", &cfg); + let mut input = String::new(); + for idx in 0..70 { + input.push_str("commit abcdef1234567890"); + input.push_str(&idx.to_string()); + input.push('\n'); + input.push_str("Author: Somebody \nDate: today\n"); + input.push_str(" message "); + input.push_str(&idx.to_string()); + input.push('\n'); + } + let out = filter(&ctx, &input, 0); + assert!(out.text.contains("… ")); + assert!(out.text.contains("message 0")); + assert!(out.text.contains("message 69")); + assert!(!out.text.contains("Author:")); + assert!(!out.text.contains("Date:")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/go.rs b/crates/pi-natives/src/shell/minimizer/filters/go.rs new file mode 100644 index 000000000..968d88abc --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/go.rs @@ -0,0 +1,327 @@ +//! Go toolchain output filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(program: &str, subcommand: Option<&str>) -> bool { + match program { + "go" => matches!(subcommand, Some("test" | "build" | "vet" | "tool")), + "golangci-lint" => matches!(subcommand, None | Some("run")), + _ => false, + } +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = if ctx.program == "golangci-lint" || ctx.command.contains("golangci-lint") { + filter_golangci_lint(&cleaned) + } else { + match ctx.subcommand { + Some("test") => filter_go_test(&cleaned, exit_code), + Some("build") => filter_go_build(&cleaned, exit_code), + Some("vet") => filter_go_vet(&cleaned), + _ => compact_general(&cleaned), + } + }; + + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn filter_go_test(input: &str, exit_code: i32) -> String { + let mut out = String::new(); + let mut kept = 0usize; + + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if let Some(rendered) = render_go_test_json_line(trimmed) { + if should_keep_go_test_line(&rendered, exit_code) { + out.push_str(&rendered); + out.push('\n'); + kept += 1; + } + continue; + } + + if should_keep_go_test_line(trimmed, exit_code) { + out.push_str(line.trim_end()); + out.push('\n'); + kept += 1; + } + } + + if kept == 0 { + return compact_general(input); + } + + primitives::head_tail_lines(&primitives::dedup_consecutive_lines(&out), 140, 80) +} + +fn render_go_test_json_line(line: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + let action = value + .get("Action") + .and_then(|v| v.as_str()) + .map_or("", |value| value); + let package = value + .get("Package") + .and_then(|v| v.as_str()) + .map_or("", |value| value); + let test = value + .get("Test") + .and_then(|v| v.as_str()) + .map_or("", |value| value); + + if let Some(output) = value.get("Output").and_then(|v| v.as_str()) { + let rendered = output.trim_end(); + if rendered.is_empty() { + return None; + } + return Some(rendered.to_string()); + } + + match action { + "fail" if !test.is_empty() => Some(format!("--- FAIL: {test}")), + "fail" if !package.is_empty() => Some(format!("FAIL\t{package}")), + "pass" if !package.is_empty() && test.is_empty() => Some(format!("ok\t{package}")), + "skip" if !test.is_empty() => Some(format!("--- SKIP: {test}")), + _ => None, + } +} + +fn should_keep_go_test_line(line: &str, exit_code: i32) -> bool { + let trimmed = line.trim(); + let lower = trimmed.to_ascii_lowercase(); + + trimmed.starts_with("FAIL") + || trimmed.starts_with("--- FAIL") + || trimmed.starts_with("panic:") + || trimmed.starts_with("# ") + || is_go_location_line(trimmed) + || lower.contains("error:") + || lower.contains("fatal") + || lower.contains("failed") + || lower.contains("expected") + || lower.contains("actual") + || lower.contains("got") && lower.contains("want") + || lower.contains("assert") + || lower.starts_with("ok\t") + || lower.starts_with("?\t") + || exit_code != 0 && (lower.contains("timeout") || lower.contains("signal")) +} + +fn filter_go_build(input: &str, exit_code: i32) -> String { + let mut out = String::new(); + let mut saw_diagnostic = false; + + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_go_noise(trimmed) { + continue; + } + if trimmed.starts_with("# ") + || is_go_build_diagnostic(trimmed) + || exit_code != 0 && looks_like_go_error(trimmed) + { + saw_diagnostic = true; + out.push_str(trimmed); + out.push('\n'); + } + } + + if !saw_diagnostic { + return compact_general(input); + } + + let grouped = primitives::group_by_file(&out, 24); + primitives::head_tail_lines(&grouped, 120, 80) +} + +fn filter_go_vet(input: &str) -> String { + let mut out = String::new(); + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with("# ") { + continue; + } + if is_go_location_line(trimmed) || looks_like_go_error(trimmed) { + out.push_str(trimmed); + out.push('\n'); + } + } + + if out.is_empty() { + return compact_general(input); + } + + let grouped = primitives::group_by_file(&out, 24); + primitives::head_tail_lines(&grouped, 120, 80) +} + +fn filter_golangci_lint(input: &str) -> String { + if let Some(json_line) = input + .lines() + .find(|line| line.trim_start().starts_with('{')) + && let Some(summary) = summarize_golangci_json(json_line.trim()) + { + return summary; + } + + let mut out = String::new(); + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_golangci_noise(trimmed) { + continue; + } + out.push_str(trimmed); + out.push('\n'); + } + + if out.is_empty() { + compact_general(input) + } else { + let grouped = primitives::group_by_file(&out, 24); + primitives::head_tail_lines(&grouped, 160, 80) + } +} + +fn summarize_golangci_json(line: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + let issues = value.get("Issues")?.as_array()?; + if issues.is_empty() { + return Some("golangci-lint: no issues found\n".to_string()); + } + + let mut out = format!("golangci-lint: {} issues\n", issues.len()); + for issue in issues.iter().take(40) { + let file = issue + .get("Pos") + .and_then(|pos| pos.get("Filename")) + .and_then(|v| v.as_str()) + .map_or("", |value| value); + let line_no = issue + .get("Pos") + .and_then(|pos| pos.get("Line")) + .and_then(|v| v.as_u64()) + .map_or(0, |value| value); + let col_no = issue + .get("Pos") + .and_then(|pos| pos.get("Column")) + .and_then(|v| v.as_u64()) + .map_or(0, |value| value); + let linter = issue + .get("FromLinter") + .and_then(|v| v.as_str()) + .map_or("lint", |value| value); + let text = issue + .get("Text") + .and_then(|v| v.as_str()) + .map_or("", |value| value); + out.push_str(file); + out.push(':'); + out.push_str(&line_no.to_string()); + out.push(':'); + out.push_str(&col_no.to_string()); + out.push_str(": "); + out.push_str(text); + out.push_str(" ("); + out.push_str(linter); + out.push_str(")\n"); + } + if issues.len() > 40 { + out.push_str("… "); + out.push_str(&(issues.len() - 40).to_string()); + out.push_str(" more issues\n"); + } + Some(out) +} + +fn compact_general(input: &str) -> String { + let stripped = primitives::strip_lines(input, &[is_go_noise]); + let deduped = primitives::dedup_consecutive_lines(&stripped); + primitives::head_tail_lines(&deduped, 100, 60) +} + +fn is_go_build_diagnostic(line: &str) -> bool { + is_go_location_line(line) + || line.contains("go.mod:") + || line.contains("go.work:") + || line.contains("go.sum:") +} + +fn is_go_location_line(line: &str) -> bool { + line.contains(".go:") +} + +fn looks_like_go_error(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.starts_with("undefined: ") + || lower.starts_with("cannot use ") + || lower.starts_with("cannot find package ") + || lower.starts_with("no required module provides package ") + || lower.starts_with("missing go.sum entry") + || lower.starts_with("found packages ") + || lower.starts_with("go: ") + && (lower.contains("error") || lower.contains("failed") || lower.contains("not found")) + || lower.contains("import cycle not allowed") + || lower.contains("build constraints exclude all go files") +} + +fn is_go_noise(line: &str) -> bool { + let lower = line.trim_start().to_ascii_lowercase(); + lower.starts_with("go: downloading ") + || lower.starts_with("go: finding ") + || lower.starts_with("go: extracting ") + || lower.starts_with("go: upgraded ") + || lower.starts_with("go: added ") +} + +fn is_golangci_noise(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.starts_with("level=") && lower.contains("msg=\"[linters_context]") + || lower.starts_with("golangci-lint has version") + || lower.starts_with("running ") && lower.contains("linters") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + #[test] + fn keeps_go_test_failure_from_json_lines() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = MinimizerCtx { + program: "go", + subcommand: Some("test"), + command: "go test ./...", + config: &cfg, + }; + let input = r#"{"Action":"run","Package":"example.com/app","Test":"TestBad"} +{"Action":"output","Package":"example.com/app","Test":"TestBad","Output":"=== RUN TestBad\n"} +{"Action":"output","Package":"example.com/app","Test":"TestBad","Output":" app_test.go:12: expected 2, got 1\n"} +{"Action":"fail","Package":"example.com/app","Test":"TestBad"} +{"Action":"fail","Package":"example.com/app"} +"#; + + let out = filter(&ctx, input, 1); + assert!(out.text.contains("app_test.go:12")); + assert!(out.text.contains("expected 2, got 1")); + assert!(out.text.contains("--- FAIL: TestBad")); + assert!(!out.text.contains("=== RUN")); + } + + #[test] + fn summarizes_golangci_json_issues() { + let input = r#"{"Issues":[{"FromLinter":"govet","Text":"unreachable code","Pos":{"Filename":"main.go","Line":7,"Column":2}}]}"#; + let out = filter_golangci_lint(input); + assert!(out.contains("golangci-lint: 1 issues")); + assert!(out.contains("main.go:7:2: unreachable code (govet)")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/gt.rs b/crates/pi-natives/src/shell/minimizer/filters/gt.rs new file mode 100644 index 000000000..a3dd007bb --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/gt.rs @@ -0,0 +1,229 @@ +//! Graphite (`gt`) output filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +const GT_SUBCOMMANDS: &[&str] = &["log", "submit", "sync", "restack", "create", "branch"]; + +pub fn supports(program: &str, subcommand: Option<&str>) -> bool { + program == "gt" && subcommand.is_some_and(|subcommand| GT_SUBCOMMANDS.contains(&subcommand)) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = match ctx.subcommand { + Some("log") => compact_log(&cleaned), + Some("branch") => primitives::compact_listing(&cleaned, 40), + Some("submit" | "sync" | "restack" | "create") => compact_noisy_command(&cleaned, exit_code), + _ => cleaned, + }; + + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn compact_log(input: &str) -> String { + let mut out = String::new(); + let mut entries = 0usize; + let mut omitted_entries = 0usize; + let max_entries = 24usize; + + for line in input.lines() { + if is_graph_node(line) { + entries += 1; + if entries > max_entries { + omitted_entries += 1; + continue; + } + } else if entries > max_entries { + continue; + } + + let trimmed = remove_email_fragments(line.trim_end()); + if !trimmed.trim().is_empty() || !out.ends_with("\n\n") { + out.push_str(&trim_line(&trimmed, 140)); + out.push('\n'); + } + } + + if omitted_entries > 0 { + out.push_str("… "); + out.push_str(&omitted_entries.to_string()); + out.push_str(" entries omitted …\n"); + } + + primitives::head_tail_lines(&out, 80, 24) +} + +fn compact_noisy_command(input: &str, exit_code: i32) -> String { + let deduped = primitives::dedup_consecutive_lines(input); + let mut kept = String::new(); + + for line in deduped.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_progress_noise(trimmed) { + continue; + } + if exit_code == 0 && is_low_value_status(trimmed) { + continue; + } + kept.push_str(trimmed); + kept.push('\n'); + } + + let candidate = if kept.trim().is_empty() { + deduped + } else { + kept + }; + + primitives::head_tail_lines(&candidate, 80, 40) +} + +fn is_graph_node(line: &str) -> bool { + let stripped = line + .trim_start_matches('│') + .trim_start_matches('|') + .trim_start(); + matches!(stripped.chars().next(), Some('◉' | '○' | '◯' | '◆' | '●' | '@' | '*')) +} + +fn remove_email_fragments(line: &str) -> String { + let mut words = Vec::new(); + for word in line.split_whitespace() { + let stripped = word.trim_matches(|ch: char| matches!(ch, '<' | '>' | '(' | ')' | ',')); + if stripped.contains('@') && stripped.contains('.') { + continue; + } + words.push(word); + } + words.join(" ") +} + +fn trim_line(line: &str, max_chars: usize) -> String { + let mut out = String::new(); + for (idx, ch) in line.chars().enumerate() { + if idx >= max_chars { + out.push('…'); + return out; + } + out.push(ch); + } + out +} + +fn is_progress_noise(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.starts_with("enumerating objects:") + || lower.starts_with("counting objects:") + || lower.starts_with("compressing objects:") + || lower.starts_with("writing objects:") + || lower.starts_with("remote: counting objects:") + || lower.starts_with("remote: compressing objects:") + || lower.starts_with("remote: total") + || lower.starts_with("resolving deltas:") + || lower.starts_with("delta compression") + || lower.starts_with("total ") +} + +fn is_low_value_status(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.starts_with("pushing to remote") + || lower.starts_with("syncing with remote") + || lower.starts_with("creating new branch") + || lower.starts_with("restacking branches") + || lower.starts_with("checking out from ") + || lower.starts_with("tracking branch set up") + || lower.starts_with("creating pull request for ") + || lower.starts_with("updating pull request for ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + fn test_ctx<'a>(subcommand: Option<&'a str>, config: &'a MinimizerConfig) -> MinimizerCtx<'a> { + MinimizerCtx { program: "gt", subcommand, command: "gt", config } + } + + #[test] + fn supports_only_known_gt_subcommands() { + assert!(supports("gt", Some("log"))); + assert!(supports("gt", Some("submit"))); + assert!(!supports("gt", Some("status"))); + assert!(!supports("git", Some("log"))); + } + + #[test] + fn log_listing_is_compacted_and_sanitized() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = test_ctx(Some("log"), &cfg); + let mut input = String::new(); + for idx in 0..30 { + input.push_str("◉ abc123"); + input.push_str(&idx.to_string()); + input.push_str(" feat/branch "); + input.push_str(&idx.to_string()); + input.push_str("d ago user@example.com\n│ commit message\n│\n"); + } + + let out = filter(&ctx, &input, 0); + + assert!(out.changed); + assert!(out.text.contains("abc1230")); + assert!(out.text.contains("entries omitted")); + assert!(!out.text.contains("user@example.com")); + } + + #[test] + fn branch_listing_is_compacted() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = test_ctx(Some("branch"), &cfg); + let mut input = String::new(); + for idx in 0..60 { + input.push_str(" feat/"); + input.push_str(&idx.to_string()); + input.push('\n'); + } + + let out = filter(&ctx, &input, 0); + + assert!(out.text.starts_with("60 entries\n")); + assert!(out.text.contains("feat/0")); + assert!(out.text.contains("feat/59")); + assert!(out.text.contains("…")); + } + + #[test] + fn submit_noise_is_stripped_and_summaries_remain() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = test_ctx(Some("submit"), &cfg); + let input = "\x1b[32mCounting objects: 100% (2/2), done.\x1b[0m\rCounting objects: 100% (2/2), done.\nPushed branch feat/a to origin\nCreated pull request #42 for feat/a: https://example.test/pr/42\nAll branches submitted successfully!\n"; + + let out = filter(&ctx, input, 0); + + assert!(out.changed); + assert!(!out.text.contains("Counting objects")); + assert!(out.text.contains("Pushed branch feat/a to origin")); + assert!(out.text.contains("Created pull request #42")); + assert!(out.text.contains("All branches submitted successfully!")); + assert!(!out.text.contains('\x1b')); + } + + #[test] + fn sync_noise_is_stripped_and_errors_remain() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = test_ctx(Some("sync"), &cfg); + let input = "remote: Counting objects: 1\nremote: Counting objects: 1\nSynced branch feat/a \ + with remote\nerror: failed to rebase feat/b\n"; + + let out = filter(&ctx, input, 1); + + assert!(!out.text.contains("Counting objects")); + assert!(out.text.contains("Synced branch feat/a with remote")); + assert!(out.text.contains("error: failed to rebase feat/b")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/js_tools.rs b/crates/pi-natives/src/shell/minimizer/filters/js_tools.rs new file mode 100644 index 000000000..5894fab04 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/js_tools.rs @@ -0,0 +1,463 @@ +//! JavaScript framework and tool output filters. +//! +//! Covers command output that is not already handled by the package-manager, +//! test-runner, or lint filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +const SUPPORTED_TOOLS: &[&str] = &["next", "prettier", "prisma"]; +const NPX_ROUTABLE_TOOLS: &[&str] = &["tsc", "eslint", "prisma", "prettier", "next"]; + +pub fn supports(program: &str, subcommand: Option<&str>) -> bool { + effective_tool(program, subcommand).is_some() +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = match effective_tool(ctx.program, ctx.subcommand) { + Some("next") => filter_next(&cleaned, exit_code), + Some("prettier") => filter_prettier(&cleaned, exit_code), + Some("prisma") => filter_prisma(&cleaned, exit_code), + _ => primitives::head_tail_lines(&cleaned, 120, 80), + }; + let text = primitives::dedup_consecutive_lines(&text); + + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +pub fn effective_tool<'a>(program: &'a str, subcommand: Option<&'a str>) -> Option<&'a str> { + if SUPPORTED_TOOLS.contains(&program) { + return Some(program); + } + if is_npx_like(program) { + let tool = subcommand?; + if NPX_ROUTABLE_TOOLS.contains(&tool) { + return Some(tool); + } + } + None +} + +fn is_npx_like(program: &str) -> bool { + matches!(program, "npx" | "bunx" | "pnpm dlx") +} + +fn filter_next(input: &str, exit_code: i32) -> String { + let mut out = String::new(); + let mut in_route_table = false; + let mut kept_any = false; + + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + if in_route_table && kept_any && !out.ends_with("\n\n") { + out.push('\n'); + } + continue; + } + + if is_error_or_warning(trimmed) || is_next_summary(trimmed) { + push_line(&mut out, trimmed); + kept_any = true; + continue; + } + if is_next_noise(trimmed) { + continue; + } + if is_next_route_header(trimmed) { + in_route_table = true; + push_line(&mut out, trimmed); + kept_any = true; + continue; + } + if in_route_table && is_next_route_or_legend(trimmed) { + push_line(&mut out, trimmed); + kept_any = true; + continue; + } + if exit_code != 0 && !is_spinner_frame(trimmed) { + push_line(&mut out, trimmed); + kept_any = true; + } + } + + if kept_any { + out + } else { + primitives::head_tail_lines(input, 80, 80) + } +} + +fn is_next_noise(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + line.starts_with('▲') + || line.starts_with('-') + || line.starts_with('✓') && !lower.contains("error") && !lower.contains("warning") + || line.starts_with('○') && line.contains("Static") + || line.starts_with('●') && (line.contains("SSG") || line.contains("Dynamic")) + || line.starts_with('ƒ') && line.contains("Dynamic") + || lower.starts_with("creating an optimized") + || lower.starts_with("compiling") + || lower.starts_with("collecting page data") + || lower.starts_with("generating static pages") + || lower.starts_with("finalizing page optimization") + || lower.starts_with("collecting build traces") + || lower.starts_with("linting and checking") + || is_spinner_frame(line) +} + +fn is_next_summary(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("compiled successfully") + || lower.contains("built in ") + || lower.contains("compiled in ") + || lower.contains("failed to compile") + || lower.starts_with("error:") + || lower.starts_with("warning:") +} + +fn is_next_route_header(line: &str) -> bool { + line.starts_with("Route (") || line.starts_with("Page") && line.contains("Size") +} + +fn is_next_route_or_legend(line: &str) -> bool { + let line = trim_tree_prefix(line); + line.starts_with('○') + || line.starts_with('●') + || line.starts_with('ƒ') + || line.starts_with('λ') + || line.starts_with('+') + || line.starts_with("First Load JS") +} + +fn trim_tree_prefix(line: &str) -> &str { + line.trim_start_matches(['┌', '├', '└', '─', '│', ' ']) +} + +fn filter_prettier(input: &str, exit_code: i32) -> String { + if input.trim().is_empty() { + return "Prettier: no output\n".to_string(); + } + + let mut files = Vec::new(); + let mut errors = Vec::new(); + let mut saw_check = false; + let mut saw_write = false; + let mut all_matched = false; + + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let lower = trimmed.to_ascii_lowercase(); + if lower.contains("checking formatting") { + saw_check = true; + continue; + } + if lower.contains("all matched files use prettier") { + all_matched = true; + continue; + } + if lower.contains("code style issues found") { + continue; + } + if lower.contains("[error]") || lower.starts_with("error") { + errors.push(trimmed.to_string()); + continue; + } + if lower.contains("[warn]") { + let cleaned = trimmed.trim_start_matches("[warn]").trim(); + if looks_like_file(cleaned) { + files.push(cleaned.to_string()); + } + continue; + } + if looks_like_prettier_write_line(trimmed) { + saw_write = true; + if let Some(file) = trimmed.split_whitespace().next() { + files.push(file.to_string()); + } + continue; + } + if looks_like_file(trimmed) { + files.push(trimmed.to_string()); + } + } + + let mut out = String::new(); + if !errors.is_empty() { + out.push_str("Prettier errors\n"); + for error in errors.iter().take(20) { + push_line(&mut out, error); + } + if errors.len() > 20 { + out.push_str("... +"); + out.push_str(&(errors.len() - 20).to_string()); + out.push_str(" more errors\n"); + } + return out; + } + + if saw_check || exit_code != 0 { + if files.is_empty() && all_matched && exit_code == 0 { + return "Prettier: all files formatted\n".to_string(); + } + if files.is_empty() && exit_code == 0 { + return "Prettier: no formatting issues\n".to_string(); + } + out.push_str("Prettier: "); + out.push_str(&files.len().to_string()); + out.push_str(" file(s) need formatting\n"); + push_file_list(&mut out, &files, 30); + return out; + } + + if saw_write || !files.is_empty() { + out.push_str("Prettier: "); + out.push_str(&files.len().to_string()); + out.push_str(" file(s) written\n"); + push_file_list(&mut out, &files, 30); + return out; + } + + "Prettier: completed\n".to_string() +} + +fn looks_like_prettier_write_line(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + line.split_whitespace().next().is_some_and(looks_like_file) + && (lower.contains("ms") || lower.contains("unchanged") || lower.contains("written")) +} + +fn looks_like_file(line: &str) -> bool { + let line = line.trim(); + if line.starts_with('-') || line.contains(' ') && !line.contains('/') { + return false; + } + let path = line.split(':').next().map_or(line, |value| value); + matches!( + path.rsplit('.').next(), + Some( + "js" + | "jsx" | "ts" + | "tsx" | "json" + | "jsonc" + | "md" | "mdx" + | "css" | "scss" + | "sass" | "html" + | "yaml" | "yml" + | "graphql" + | "vue" | "svelte" + ) + ) +} + +fn filter_prisma(input: &str, exit_code: i32) -> String { + let mut out = String::new(); + let mut in_schema_changes = false; + + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if is_prisma_noise(trimmed) { + continue; + } + if is_prisma_schema_change_header(trimmed) { + in_schema_changes = true; + push_line(&mut out, trimmed); + continue; + } + if in_schema_changes && is_prisma_change_line(trimmed) { + push_line(&mut out, trimmed); + continue; + } + if should_keep_prisma_line(trimmed, exit_code) { + in_schema_changes = false; + push_line(&mut out, trimmed); + } + } + + if out.is_empty() { + primitives::head_tail_lines(input, 80, 80) + } else { + out + } +} + +fn is_prisma_noise(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + line + .chars() + .any(|ch| matches!(ch, '█' | '▀' | '▄' | '┌' | '└' | '│' | '┐' | '┘')) + || lower.starts_with("prisma schema loaded from") + || lower.starts_with("datasource ") + || lower.starts_with("generator ") + || lower.starts_with("start by importing") + || lower.starts_with("import { prismaclient") + || lower.starts_with("tips:") + || lower.starts_with("run prisma") + || lower.starts_with("running generate") + || lower.contains("learn more about prisma") +} + +fn should_keep_prisma_line(line: &str, exit_code: i32) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("generated prisma client") + || lower.contains("generated ") && lower.contains("@prisma/client") + || lower.contains("migrations") + || lower.contains("migration") + || lower.contains("database is now in sync") + || lower.contains("database schema is up to date") + || lower.contains("your database is now in sync") + || lower.contains("no pending migrations") + || lower.contains("already in sync") + || lower.contains("drift detected") + || lower.contains("failed") + || lower.contains("error") + || lower.contains("warning") + || lower.starts_with("applying migration") + || lower.starts_with("applied migration") + || lower.starts_with("the following migration") + || lower.starts_with("all migrations") + || lower.starts_with("pending migrations") + || lower.starts_with("schema pushed") + || exit_code != 0 && !is_spinner_frame(line) +} + +fn is_prisma_schema_change_header(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("the following changes") || lower.contains("changes to your database") +} + +fn is_prisma_change_line(line: &str) -> bool { + line.starts_with('+') + || line.starts_with('-') + || line.starts_with('~') + || line.starts_with('*') + || line.starts_with("CREATE ") + || line.starts_with("ALTER ") + || line.starts_with("DROP ") + || line.contains("CREATE TABLE") + || line.contains("ALTER TABLE") + || line.contains("DROP TABLE") +} + +fn is_error_or_warning(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("error") + || lower.contains("failed") + || lower.contains("warning") + || lower.contains("warn ") +} + +fn is_spinner_frame(line: &str) -> bool { + line + .chars() + .all(|ch| matches!(ch, '⠋' | '⠙' | '⠹' | '⠸' | '⠼' | '⠴' | '⠦' | '⠧' | '⠇' | '⠏' | ' ')) +} + +fn push_file_list(out: &mut String, files: &[String], limit: usize) { + for file in files.iter().take(limit) { + push_line(out, file); + } + if files.len() > limit { + out.push_str("... +"); + out.push_str(&(files.len() - limit).to_string()); + out.push_str(" more files\n"); + } +} + +fn push_line(out: &mut String, line: &str) { + out.push_str(line.trim_end()); + out.push('\n'); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supports_direct_and_npx_routing_tools() { + assert!(supports("next", Some("build"))); + assert!(supports("prettier", Some("--check"))); + assert!(supports("prisma", Some("generate"))); + assert!(supports("npx", Some("prettier"))); + assert!(supports("npx", Some("prisma"))); + assert!(supports("npx", Some("tsc"))); + assert!(supports("npx", Some("eslint"))); + assert!(!supports("npx", Some("jest"))); + } + + #[test] + fn next_build_keeps_route_table_and_strips_progress() { + let input = " ▲ Next.js 15.2.0\n Creating an optimized production build ...\n✓ Compiled \ + successfully\n✓ Collecting page data\nRoute (app) Size \ + First Load JS\n┌ ○ / 1.2 kB 132 kB\n├ ● \ + /dashboard 2.5 kB 156 kB\n└ ƒ /api/users \ + 0.5 kB 89 kB\n○ (Static) prerendered as static content\n✓ Built in \ + 34.2s\n"; + let out = filter_next(input, 0); + + assert!(out.contains("Route (app)")); + assert!(out.contains("/dashboard")); + assert!(out.contains("Built in 34.2s")); + assert!(!out.contains("Creating an optimized")); + assert!(!out.contains("Collecting page data")); + } + + #[test] + fn prettier_check_preserves_unformatted_files() { + let input = "Checking formatting...\n[warn] src/app/page.tsx\n[warn] src/lib/data.ts\nCode \ + style issues found in 2 files. Forgot to run Prettier?\n"; + let out = filter_prettier(input, 1); + + assert!(out.contains("2 file(s) need formatting")); + assert!(out.contains("src/app/page.tsx")); + assert!(out.contains("src/lib/data.ts")); + assert!(!out.contains("Checking formatting")); + } + + #[test] + fn prettier_write_is_compact() { + let input = "src/app/page.tsx 42ms\nsrc/lib/data.ts 11ms\n"; + let out = filter_prettier(input, 0); + + assert!(out.contains("2 file(s) written")); + assert!(out.contains("src/app/page.tsx")); + assert!(!out.contains("42ms")); + } + + #[test] + fn prisma_generate_strips_boilerplate_but_keeps_result() { + let input = "Prisma schema loaded from prisma/schema.prisma\n█▀▀▀\n✔ Generated Prisma \ + Client (v5.7.0) to ./node_modules/@prisma/client in 234ms\nStart by importing \ + your Prisma Client:\nimport { PrismaClient } from '@prisma/client'\n"; + let out = filter_prisma(input, 0); + + assert!(out.contains("Generated Prisma Client")); + assert!(!out.contains("Prisma schema loaded")); + assert!(!out.contains("Start by importing")); + assert!(!out.contains("█")); + } + + #[test] + fn prisma_migrate_keeps_status_and_errors() { + let input = "Prisma schema loaded from prisma/schema.prisma\nDatasource \"db\": PostgreSQL \ + database\n3 migrations found in prisma/migrations\nFollowing migration have \ + not yet been applied:\n202604240501_add_accounts\nError: P3009\nfailed \ + migration detected\n"; + let out = filter_prisma(input, 1); + + assert!(out.contains("3 migrations found")); + assert!(out.contains("202604240501_add_accounts")); + assert!(out.contains("P3009")); + assert!(!out.contains("Datasource")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/lint.rs b/crates/pi-natives/src/shell/minimizer/filters/lint.rs new file mode 100644 index 000000000..c01261615 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/lint.rs @@ -0,0 +1,254 @@ +//! Type-checker and linter output filters. + +use std::collections::BTreeMap; + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(subcommand: Option<&str>) -> bool { + supports_program("", subcommand) +} + +pub fn supports_program(program: &str, subcommand: Option<&str>) -> bool { + matches!(program, "ruff" | "mypy" | "rubocop") + || matches!( + subcommand, + None | Some("check" | "lint" | "run" | "format" | "fmt" | "typecheck") + ) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let text = condense_lint_output(ctx.program, input, exit_code); + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +pub fn condense_lint_output(program: &str, input: &str, exit_code: i32) -> String { + let cleaned = primitives::strip_ansi(input); + let stripped = strip_lint_noise(program, &cleaned, exit_code); + let grouped = group_diagnostics(&stripped); + primitives::head_tail_lines(&grouped, 180, 100) +} + +fn strip_lint_noise(program: &str, input: &str, exit_code: i32) -> String { + let mut out = String::new(); + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || is_lint_noise(program, trimmed, exit_code) { + continue; + } + out.push_str(line.trim_end()); + out.push('\n'); + } + out +} + +fn is_lint_noise(program: &str, line: &str, exit_code: i32) -> bool { + if exit_code != 0 && contains_diagnostic_signal(line) { + return false; + } + let lower = line.to_ascii_lowercase(); + lower.starts_with("checked ") + || lower.starts_with("found 0") + || lower.starts_with("success:") + || lower.starts_with("all matched files") + || lower.starts_with("done in ") + || matches!(program, "eslint" | "biome") && lower.starts_with("warning: react version") + || matches!(program, "ruff") && lower.starts_with("all checks passed") + || matches!(program, "mypy") && lower.starts_with("success: no issues found") + || matches!(program, "rubocop") + && (lower.starts_with("inspecting ") + || lower == "offenses:" + || lower.ends_with(" files inspected, no offenses detected")) +} + +pub fn group_diagnostics(input: &str) -> String { + let mut grouped: BTreeMap> = BTreeMap::new(); + let mut ungrouped = Vec::new(); + let mut code_counts: BTreeMap = BTreeMap::new(); + + for line in input.lines() { + if let Some((file, rest)) = split_diagnostic(line) { + if let Some(code) = extract_code(rest) { + *code_counts.entry(code).or_default() += 1; + } + grouped + .entry(file.to_string()) + .or_default() + .push(rest.to_string()); + } else { + ungrouped.push(line.to_string()); + } + } + + if grouped.is_empty() { + return primitives::dedup_consecutive_lines(input); + } + + let mut files: Vec<_> = grouped.into_iter().collect(); + files.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0))); + + let mut out = String::new(); + let diag_count: usize = files.iter().map(|(_, entries)| entries.len()).sum(); + out.push_str(&diag_count.to_string()); + out.push_str(" diagnostics in "); + out.push_str(&files.len().to_string()); + out.push_str(" files\n"); + + let code_summary = format_code_summary(&code_counts); + if !code_summary.is_empty() { + out.push_str("Top codes: "); + out.push_str(&code_summary); + out.push('\n'); + } + + for (file, entries) in files { + out.push_str(&file); + out.push_str(" ("); + out.push_str(&entries.len().to_string()); + out.push_str(" diagnostics)\n"); + for entry in entries.iter().take(12) { + out.push_str(" "); + out.push_str(&truncate_line(entry, 180)); + out.push('\n'); + } + if entries.len() > 12 { + out.push_str(" … "); + out.push_str(&(entries.len() - 12).to_string()); + out.push_str(" more\n"); + } + } + + for line in ungrouped.iter().take(40) { + out.push_str(line); + out.push('\n'); + } + if ungrouped.len() > 40 { + out.push_str("… "); + out.push_str(&(ungrouped.len() - 40).to_string()); + out.push_str(" ungrouped lines omitted\n"); + } + out +} + +fn split_diagnostic(line: &str) -> Option<(&str, &str)> { + if let Some((file, rest)) = split_tsc_diagnostic(line) { + return Some((file, rest)); + } + let (file, rest) = line.split_once(':')?; + if !looks_like_path(file) || !starts_with_line_number(rest) { + return None; + } + Some((file, rest)) +} + +fn split_tsc_diagnostic(line: &str) -> Option<(&str, &str)> { + let paren = line.find('(')?; + let close = line[paren..].find(')')? + paren; + let file = &line[..paren]; + let loc = &line[paren + 1..close]; + if !looks_like_path(file) + || !loc + .split(',') + .all(|part| part.chars().all(|ch| ch.is_ascii_digit())) + { + return None; + } + let rest = line.get(close + 1..)?.trim_start_matches(':').trim_start(); + Some((file, rest)) +} + +fn looks_like_path(value: &str) -> bool { + !value.is_empty() + && !value.starts_with(' ') + && (value.contains('/') || value.contains('.') || value.ends_with(')')) +} + +fn starts_with_line_number(rest: &str) -> bool { + let rest = rest.trim_start(); + let mut chars = rest.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_digit() +} + +fn extract_code(text: &str) -> Option { + for token in text.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-') { + if token.len() >= 3 + && token.chars().any(|ch| ch.is_ascii_digit()) + && token.chars().any(|ch| ch.is_ascii_alphabetic()) + { + return Some(token.to_string()); + } + } + None +} + +fn format_code_summary(counts: &BTreeMap) -> String { + let mut counts: Vec<_> = counts.iter().collect(); + counts.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + counts + .iter() + .take(5) + .map(|(code, count)| format!("{code} ({count}x)")) + .collect::>() + .join(", ") +} + +fn truncate_line(line: &str, max_chars: usize) -> String { + if line.chars().count() <= max_chars { + return line.to_string(); + } + let mut out: String = line.chars().take(max_chars.saturating_sub(1)).collect(); + out.push('…'); + out +} + +fn contains_diagnostic_signal(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("error") + || lower.contains("warning") + || lower.contains("failed") + || lower.contains("panic") + || lower.contains("exception") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supports_common_lint_subcommands_for_future_dispatch() { + for subcommand in ["check", "lint", "run", "format", "typecheck"] { + assert!(supports(Some(subcommand)), "{subcommand} should be supported"); + } + } + + #[test] + fn groups_tsc_and_colon_diagnostics_by_file() { + let input = "src/a.ts(1,2): error TS2322: bad\nsrc/a.ts(2,1): error TS2322: \ + bad\nlib/b.py:4: error: no attr [attr-defined]\n"; + let out = group_diagnostics(input); + assert!(out.contains("3 diagnostics in 2 files")); + assert!(out.contains("src/a.ts (2 diagnostics)")); + assert!(out.contains("Top codes:")); + } + + #[test] + fn truncates_many_diagnostics_per_file() { + let mut input = String::new(); + for i in 0..20 { + input.push_str("src/main.rs:"); + input.push_str(&(i + 1).to_string()); + input.push_str(":1: warning: issue W"); + input.push_str(&i.to_string()); + input.push('\n'); + } + let out = group_diagnostics(&input); + assert!(out.contains("src/main.rs (20 diagnostics)")); + assert!(out.contains("… 8 more")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/listing.rs b/crates/pi-natives/src/shell/minimizer/filters/listing.rs new file mode 100644 index 000000000..98bf196b6 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/listing.rs @@ -0,0 +1,153 @@ +//! Filesystem listing and search filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, _exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = match ctx.program { + "grep" | "rg" => primitives::group_by_file(&cleaned, 12), + "ls" | "tree" | "find" => compact_listing_output(&cleaned), + "cat" | "read" => compact_content_output(&cleaned), + "stat" | "du" | "df" | "wc" => compact_summary_output(&cleaned), + "jq" | "json" => compact_jsonish_output(&cleaned), + _ => cleaned, + }; + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn compact_listing_output(input: &str) -> String { + primitives::compact_listing(input, 80) +} + +fn compact_content_output(input: &str) -> String { + if input.lines().count() <= 120 { + input.to_string() + } else { + primitives::head_tail_lines(input, 60, 40) + } +} + +fn compact_jsonish_output(input: &str) -> String { + if input.lines().count() <= 120 { + input.to_string() + } else { + primitives::head_tail_lines(input, 60, 40) + } +} + +fn compact_summary_output(input: &str) -> String { + let lines: Vec<&str> = input.lines().collect(); + if lines.len() <= 30 { + return input.to_string(); + } + + let windowed = primitives::head_tail_lines(input, 12, 12); + let mut out = String::new(); + for line in lines.iter().copied().filter(|line| is_summary_line(line)) { + if !windowed.lines().any(|existing| existing == line) + && !out.lines().any(|existing| existing == line) + { + out.push_str(line); + out.push('\n'); + } + } + out.push_str(&windowed); + out +} + +fn is_summary_line(line: &str) -> bool { + let trimmed = line.trim(); + let lower = trimmed.to_ascii_lowercase(); + trimmed == "total" + || lower.starts_with("total ") + || lower.ends_with(" total") + || lower.starts_with("filesystem") + || lower.contains(" mounted on") + || lower.contains(" files ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + fn ctx<'a>(program: &'a str, cfg: &'a MinimizerConfig) -> MinimizerCtx<'a> { + MinimizerCtx { program, subcommand: None, command: program, config: cfg } + } + + #[test] + fn groups_grep_by_file() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("rg", &cfg); + let out = filter(&ctx, "a.rs:1:foo\na.rs:2:bar\n", 0); + assert_eq!(out.text, "a.rs:\n 1:foo\n 2:bar\n"); + } + + #[test] + fn compacts_long_cat_output() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("cat", &cfg); + let input = numbered_lines(130); + let out = filter(&ctx, &input, 0); + assert!(out.text.contains("… 30 lines omitted …")); + assert!(out.text.contains("line 001")); + assert!(out.text.contains("line 130")); + } + + #[test] + fn preserves_short_read_output() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("read", &cfg); + let input = "alpha\nbravo\ncharlie\n"; + let out = filter(&ctx, input, 0); + assert_eq!(out.text, input); + } + + #[test] + fn compacts_df_output_without_losing_filesystem_header() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("df", &cfg); + let mut input = String::from("Filesystem 1K-blocks Used Available Use% Mounted on\n"); + for idx in 0..36 { + input.push_str("/dev/disk"); + input.push_str(&idx.to_string()); + input.push_str(" 100 50 50 50% /mnt/"); + input.push_str(&idx.to_string()); + input.push('\n'); + } + let out = filter(&ctx, &input, 0); + assert!( + out.text + .contains("Filesystem 1K-blocks Used Available Use% Mounted on") + ); + assert!(out.text.contains("… 13 lines omitted …")); + assert!(out.text.contains("/dev/disk35")); + } + + #[test] + fn json_only_strips_ansi_when_short() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("jq", &cfg); + let out = filter(&ctx, "\u{1b}[32m{\"ok\":true}\u{1b}[0m\n", 0); + assert_eq!(out.text, "{\"ok\":true}\n"); + } + + fn numbered_lines(count: usize) -> String { + let mut out = String::new(); + for idx in 1..=count { + out.push_str("line "); + if idx < 10 { + out.push_str("00"); + } else if idx < 100 { + out.push('0'); + } + out.push_str(&idx.to_string()); + out.push('\n'); + } + out + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/mod.rs b/crates/pi-natives/src/shell/minimizer/filters/mod.rs new file mode 100644 index 000000000..c79c2025d --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/mod.rs @@ -0,0 +1,104 @@ +//! Filter dispatch table for built-in minimizer strategies. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput}; + +pub mod cloud; + +pub mod bun; + +pub mod cargo; +pub mod docker; + +pub mod dotnet; + +pub mod generic; +pub mod gh; + +pub mod go; +pub mod gt; + +pub mod git; + +pub mod js_tools; + +pub mod lint; +pub mod listing; +pub mod node_tests; +pub mod pkg; + +pub mod python; +pub mod ruby; +pub mod system; + +pub fn supports(program: &str, subcommand: Option<&str>) -> bool { + match program { + "git" | "yadm" => git::supports(subcommand), + "gt" => gt::supports(program, subcommand), + "bun" | "bunx" => bun::supports(program, subcommand), + "cargo" => cargo::supports(subcommand), + "go" | "golangci-lint" => go::supports(program, subcommand), + "dotnet" => dotnet::supports(program, subcommand), + "ls" | "tree" | "find" | "grep" | "rg" | "wc" | "cat" | "read" | "stat" | "du" | "df" + | "jq" | "json" => true, + "aws" | "curl" | "wget" | "psql" => cloud::supports(program, subcommand), + "docker" | "kubectl" | "helm" => docker::supports(subcommand), + "gh" => gh::supports(subcommand), + "pytest" | "ruff" | "mypy" | "python" | "python3" | "py" => { + python::supports(program, subcommand) + }, + "rspec" | "rake" | "rails" | "rubocop" => ruby::supports(program, subcommand), + "tsc" | "eslint" | "biome" | "shellcheck" | "markdownlint" | "hadolint" | "yamllint" + | "oxlint" | "pyright" | "basedpyright" => { + lint::supports(subcommand) || lint::supports_program(program, subcommand) + }, + "jest" | "vitest" | "playwright" => true, + "next" | "prettier" | "prisma" => js_tools::supports(program, subcommand), + "npx" => { + matches!(subcommand, Some("tsc" | "eslint")) || js_tools::supports(program, subcommand) + }, + "npm" | "pnpm" | "yarn" | "pip" | "pip3" | "bundle" | "brew" | "composer" | "uv" + | "poetry" => pkg::supports(subcommand), + "env" | "log" | "deps" | "summary" | "err" | "test" | "diff" | "format" | "pipe" | "ps" + | "ping" | "ssh" | "sops" => system::supports(program), + _ => false, + } +} + +/// Apply the matching built-in filter. +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let _ = ctx.command; + let _ = ctx.config.per_command(ctx.program); + match ctx.program { + "git" | "yadm" => git::filter(ctx, input, exit_code), + "gt" => gt::filter(ctx, input, exit_code), + "bun" | "bunx" => bun::filter(ctx, input, exit_code), + "cargo" => cargo::filter(ctx, input, exit_code), + "go" | "golangci-lint" => go::filter(ctx, input, exit_code), + "dotnet" => dotnet::filter(ctx, input, exit_code), + "ls" | "tree" | "find" | "grep" | "rg" | "wc" | "cat" | "read" | "stat" | "du" | "df" + | "jq" | "json" => listing::filter(ctx, input, exit_code), + "aws" | "curl" | "wget" | "psql" => cloud::filter(ctx, input, exit_code), + "docker" | "kubectl" | "helm" => docker::filter(ctx, input, exit_code), + "gh" => gh::filter(ctx, input, exit_code), + "pytest" | "ruff" | "mypy" | "python" | "python3" | "py" => { + python::filter(ctx, input, exit_code) + }, + "rspec" | "rake" | "rails" | "rubocop" => ruby::filter(ctx, input, exit_code), + "tsc" | "eslint" | "biome" | "shellcheck" | "markdownlint" | "hadolint" | "yamllint" + | "oxlint" | "pyright" | "basedpyright" => lint::filter(ctx, input, exit_code), + "jest" | "vitest" | "playwright" => node_tests::filter(ctx, input, exit_code), + "next" | "prettier" | "prisma" => js_tools::filter(ctx, input, exit_code), + "npx" => { + if matches!(ctx.subcommand, Some("tsc" | "eslint")) { + lint::filter(ctx, input, exit_code) + } else { + js_tools::filter(ctx, input, exit_code) + } + }, + "npm" | "pnpm" | "yarn" | "pip" | "pip3" | "bundle" | "brew" | "composer" | "uv" + | "poetry" => pkg::filter(ctx, input, exit_code), + "env" | "log" | "deps" | "summary" | "err" | "test" | "diff" | "format" | "pipe" | "ps" + | "ping" | "ssh" | "sops" => system::filter(ctx, input, exit_code), + _ => generic::filter(ctx, input, exit_code), + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/node_tests.rs b/crates/pi-natives/src/shell/minimizer/filters/node_tests.rs new file mode 100644 index 000000000..58f45e9f4 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/node_tests.rs @@ -0,0 +1,238 @@ +//! Jest, Vitest, and Playwright output filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn filter(_ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = if exit_code == 0 { + drop_passed_lines(&cleaned) + } else { + failures_only(&cleaned) + }; + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn drop_passed_lines(input: &str) -> String { + let mut out = String::new(); + let mut summary = String::new(); + + for line in input.lines() { + let trimmed = line.trim_start(); + if is_summary_line(trimmed) { + push_line(&mut summary, line); + push_line(&mut out, line); + continue; + } + if is_pass_noise(trimmed) { + continue; + } + push_line(&mut out, line); + } + + if has_content(&out) { + out + } else if has_content(&summary) { + summary + } else { + primitives::head_tail_lines(input, 0, 20) + } +} + +fn failures_only(input: &str) -> String { + let mut out = String::new(); + let mut keeping_block = false; + let mut trailing_context = 0usize; + + for line in input.lines() { + let trimmed = line.trim_start(); + + if is_summary_line(trimmed) { + keeping_block = false; + trailing_context = 0; + push_line(&mut out, line); + continue; + } + + if starts_failure_block(trimmed) { + keeping_block = true; + trailing_context = 10; + push_line(&mut out, line); + continue; + } + + if keeping_block { + if is_pass_noise(trimmed) && !is_error_context_line(trimmed) { + keeping_block = false; + trailing_context = 0; + continue; + } + push_line(&mut out, line); + if trimmed.is_empty() { + continue; + } + if is_error_context_line(trimmed) { + trailing_context = 10; + } else if trailing_context > 0 { + trailing_context -= 1; + } else { + keeping_block = false; + } + } + } + + if has_content(&out) { + out + } else { + primitives::head_tail_lines(input, 80, 80) + } +} + +fn push_line(out: &mut String, line: &str) { + out.push_str(line); + out.push('\n'); +} + +fn has_content(text: &str) -> bool { + text.lines().any(|line| !line.trim().is_empty()) +} + +fn is_summary_line(trimmed: &str) -> bool { + trimmed.starts_with("Test Suites:") + || trimmed.starts_with("Tests:") + || trimmed.starts_with("Snapshots:") + || trimmed.starts_with("Time:") + || trimmed.starts_with("Ran all test suites") + || trimmed.starts_with("Test Files") + || trimmed.starts_with("Duration") + || trimmed.starts_with("Start at") + || trimmed.starts_with("% ") + || trimmed.starts_with("Failed Tests") + || trimmed.starts_with("Playwright Test Report") + || starts_count_summary(trimmed) +} + +fn starts_count_summary(trimmed: &str) -> bool { + let mut parts = trimmed.split_whitespace(); + let Some(count) = parts.next() else { + return false; + }; + if !count.chars().all(|ch| ch.is_ascii_digit()) { + return false; + } + matches!(parts.next(), Some("failed" | "passed" | "skipped" | "flaky")) +} + +fn is_pass_noise(trimmed: &str) -> bool { + trimmed.starts_with("PASS ") + || trimmed.starts_with("✓") + || trimmed.starts_with("✔") + || trimmed.starts_with("√") + || trimmed.starts_with("○") + || trimmed.starts_with(" RUN ") + || trimmed.starts_with("DEV ") +} + +fn starts_failure_block(trimmed: &str) -> bool { + trimmed.starts_with("FAIL ") + || trimmed.starts_with("FAILURES") + || trimmed.starts_with("Failed Tests") + || trimmed.starts_with("● ") + || trimmed.starts_with("✕") + || trimmed.starts_with("×") + || trimmed.starts_with("✗") + || trimmed.starts_with("❯") + || trimmed.starts_with("Error:") + || trimmed.starts_with("AssertionError") + || trimmed.starts_with("TimeoutError") + || is_playwright_numbered_failure(trimmed) +} + +fn is_error_context_line(trimmed: &str) -> bool { + trimmed.is_empty() + || trimmed.starts_with("at ") + || trimmed.starts_with("→") + || trimmed.starts_with('>') + || trimmed.starts_with('|') + || trimmed.starts_with("Expected") + || trimmed.starts_with("Received") + || trimmed.starts_with("Error:") + || trimmed.starts_with("AssertionError") + || trimmed.starts_with("TimeoutError") + || trimmed.contains(" › ") + || trimmed.contains(".spec.") + || trimmed.contains(".test.") +} + +fn is_playwright_numbered_failure(trimmed: &str) -> bool { + let mut chars = trimmed.chars(); + let mut saw_digit = false; + while let Some(ch) = chars.next() { + if ch.is_ascii_digit() { + saw_digit = true; + continue; + } + return saw_digit && ch == ')' && chars.next().is_some_and(|next| next.is_whitespace()); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn drops_passed_lines() { + assert_eq!(drop_passed_lines("PASS a.test.ts\n✓ ok\nTests 1 passed\n"), "Tests 1 passed\n"); + } + + #[test] + fn keeps_jest_failure_context_and_summary() { + let input = "PASS src/ok.test.ts\nFAIL src/bad.test.ts\n suite\n ✕ breaks (5 ms)\n\n ● \ + suite › breaks\n\n Expected: 1\n Received: 2\n\nTest Suites: 1 failed, 1 \ + passed, 2 total\nTests: 1 failed, 1 passed, 2 total\n"; + let filtered = failures_only(input); + + assert!(!filtered.contains("PASS src/ok.test.ts")); + assert!(filtered.contains("FAIL src/bad.test.ts")); + assert!(filtered.contains("● suite › breaks")); + assert!(filtered.contains("Expected: 1")); + assert!(filtered.contains("Test Suites: 1 failed")); + } + + #[test] + fn keeps_vitest_failure_and_drops_success_checks() { + let input = "✓ src/passing.test.ts (1)\n× src/failing.test.ts > thing > fails\n → expected \ + true to be false\n ❯ src/failing.test.ts:4:10\n\nTest Files 1 failed | 1 \ + passed (2)\nTests 1 failed | 1 passed (2)\n"; + let filtered = failures_only(input); + + assert!(!filtered.contains("src/passing.test.ts")); + assert!(filtered.contains("× src/failing.test.ts")); + assert!(filtered.contains("expected true to be false")); + assert!(filtered.contains("Test Files 1 failed")); + } + + #[test] + fn keeps_playwright_numbered_failure_and_summary() { + let input = " ✓ 1 [chromium] › tests/ok.spec.ts:3:1 › ok (120ms)\n 1) [chromium] › \ + tests/login.spec.ts:7:1 › login\n\n Error: expect(locator).toBeVisible() \ + failed\n at tests/login.spec.ts:9:11\n\n 1 failed\n [chromium] › \ + tests/login.spec.ts:7:1 › login\n 1 passed (2.3s)\n"; + let filtered = failures_only(input); + + assert!(!filtered.contains("tests/ok.spec.ts")); + assert!(filtered.contains("1) [chromium]")); + assert!(filtered.contains("toBeVisible")); + assert!(filtered.contains("1 failed")); + } + + #[test] + fn success_keeps_summary_when_everything_else_is_pass_noise() { + let filtered = drop_passed_lines("✓ one passed\n✓ two passed\n3 passed (1.2s)\n"); + assert_eq!(filtered, "3 passed (1.2s)\n"); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/pkg.rs b/crates/pi-natives/src/shell/minimizer/filters/pkg.rs new file mode 100644 index 000000000..04db0be3f --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/pkg.rs @@ -0,0 +1,176 @@ +//! Package manager output filters. + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(subcommand: Option<&str>) -> bool { + matches!( + subcommand, + Some( + "install" + | "i" | "ci" + | "add" | "update" + | "up" | "upgrade" + | "remove" + | "rm" | "uninstall" + | "list" | "ls" + | "outdated" + | "sync" | "lock" + | "run" | "exec" + | "audit" + | "check" + | "show" | "info" + | "why" + ) + ) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let stripped = strip_package_noise(ctx.program, &cleaned, exit_code); + let deduped = primitives::dedup_consecutive_lines(&stripped); + let text = primitives::head_tail_lines(&deduped, 120, 80); + + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn strip_package_noise(program: &str, input: &str, exit_code: i32) -> String { + let mut out = String::new(); + let mut previous_blank = false; + for line in input.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + if !previous_blank { + out.push('\n'); + } + previous_blank = true; + continue; + } + previous_blank = false; + + if is_noise_line(program, trimmed, exit_code) { + continue; + } + out.push_str(line.trim_end()); + out.push('\n'); + } + out +} + +fn is_noise_line(program: &str, line: &str, exit_code: i32) -> bool { + if exit_code != 0 && is_error_or_summary(line) { + return false; + } + + let lower = line.to_ascii_lowercase(); + is_generic_progress(line, &lower) + || is_js_package_noise(program, line, &lower) + || is_python_package_noise(program, line, &lower) + || is_ruby_php_brew_noise(program, line, &lower) +} + +fn is_generic_progress(line: &str, lower: &str) -> bool { + line.starts_with("Progress:") + || line.starts_with("Resolving:") + || line.starts_with("Downloading:") + || line.starts_with("Downloaded") + || lower.starts_with("resolving dependencies") + || lower.starts_with("installing dependencies") + || lower.starts_with("fetching packages") + || lower.contains("spinner") + || line + .chars() + .all(|ch| matches!(ch, '⠁' | '⠂' | '⠄' | '⡀' | '⢀' | '⠠' | '⠐' | '⠈' | ' ')) +} + +fn is_js_package_noise(program: &str, line: &str, lower: &str) -> bool { + if !matches!(program, "npm" | "pnpm" | "yarn" | "bun") { + return false; + } + line.starts_with('>') && line.contains('@') + || lower.starts_with("npm notice") + || lower.starts_with("npm warn deprecated") + || lower.starts_with("npm http fetch") + || lower.starts_with("pnpm: progress") + || lower.starts_with("packages:") + || lower.starts_with("resolved ") + || lower.starts_with("reused ") + || lower.starts_with("added ") && lower.contains("packages") + || lower.starts_with("done in ") + || lower.contains("already up-to-date") +} + +fn is_python_package_noise(program: &str, _line: &str, lower: &str) -> bool { + if !matches!(program, "pip" | "uv" | "poetry") { + return false; + } + lower.starts_with("collecting ") + || lower.starts_with("using cached ") + || lower.starts_with("downloading ") + || lower.starts_with("preparing metadata") + || lower.starts_with("installing build dependencies") + || lower.starts_with("resolving dependencies") + || lower.starts_with("writing lock file") + || lower.starts_with("package operations:") +} + +fn is_ruby_php_brew_noise(program: &str, _line: &str, lower: &str) -> bool { + if !matches!(program, "bundle" | "brew" | "composer") { + return false; + } + lower.starts_with("fetching ") + || lower.starts_with("installing ") && !lower.contains("error") + || lower.starts_with("using ") + || lower.starts_with("bundle complete") + || lower.starts_with("==> downloading") + || lower.starts_with("==> pouring") + || lower.starts_with("loading composer repositories") + || lower.starts_with("generating autoload files") +} + +fn is_error_or_summary(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("error") + || lower.contains("failed") + || lower.contains("warning") + || lower.contains("vulnerab") + || lower.contains("audited") + || lower.contains("found ") + || lower.contains("success") + || lower.contains("complete") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_progress_but_keeps_package_errors() { + let input = "Resolving: total 10\nDownloading: left-pad\nERROR failed to install \ + left-pad\nfound 1 vulnerability\n"; + let out = strip_package_noise("npm", input, 1); + assert!(!out.contains("Resolving:")); + assert!(!out.contains("Downloading:")); + assert!(out.contains("ERROR failed")); + assert!(out.contains("found 1 vulnerability")); + } + + #[test] + fn supports_common_package_subcommands_for_future_dispatch() { + for subcommand in ["ci", "add", "outdated", "sync", "audit", "why"] { + assert!(supports(Some(subcommand)), "{subcommand} should be supported"); + } + } + + #[test] + fn bun_install_noise_uses_js_package_rules() { + let input = "Resolving dependencies\nDownloaded foo\nerror: failed\n"; + let out = strip_package_noise("bun", input, 1); + assert!(!out.contains("Resolving dependencies")); + assert!(!out.contains("Downloaded foo")); + assert!(out.contains("error: failed")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/python.rs b/crates/pi-natives/src/shell/minimizer/filters/python.rs new file mode 100644 index 000000000..0646eb498 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/python.rs @@ -0,0 +1,217 @@ +//! Python test, type-check, and lint output filters. + +use super::lint; +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(program: &str, subcommand: Option<&str>) -> bool { + matches!(program, "pytest" | "ruff" | "mypy") + || matches!( + (program, subcommand), + ("python" | "python3" | "py", Some("pytest" | "ruff" | "mypy")) + ) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let tool = python_tool(ctx.program, ctx.subcommand); + let cleaned = primitives::strip_ansi(input); + let text = match tool { + Some("pytest") => filter_pytest(&cleaned, exit_code), + Some("ruff") => lint::condense_lint_output("ruff", &cleaned, exit_code), + Some("mypy") => lint::condense_lint_output("mypy", &cleaned, exit_code), + _ => cleaned, + }; + + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn python_tool<'a>(program: &'a str, subcommand: Option<&'a str>) -> Option<&'a str> { + match program { + "pytest" | "ruff" | "mypy" => Some(program), + "python" | "python3" | "py" => match subcommand { + Some("pytest" | "ruff" | "mypy") => subcommand, + _ => None, + }, + _ => None, + } +} + +fn filter_pytest(input: &str, exit_code: i32) -> String { + if exit_code == 0 { + return pytest_success(input); + } + + let mut out = String::new(); + let mut in_failure = false; + let mut saw_failure = false; + + for line in input.lines() { + let trimmed = line.trim(); + if is_pytest_summary_header(trimmed) || is_pytest_summary_line(trimmed) { + in_failure = false; + push_line(&mut out, line); + continue; + } + + if starts_pytest_failure(trimmed) { + in_failure = true; + saw_failure = true; + push_line(&mut out, line); + continue; + } + + if in_failure { + if is_pytest_section_delimiter(trimmed) && !starts_pytest_failure(trimmed) { + in_failure = false; + continue; + } + if !is_pytest_pass_noise(trimmed) { + push_line(&mut out, line); + } + continue; + } + + if trimmed.starts_with("FAILED ") || trimmed.starts_with("ERROR ") { + saw_failure = true; + push_line(&mut out, line); + } + } + + if saw_failure && has_content(&out) { + out + } else { + primitives::head_tail_lines(input, 80, 80) + } +} + +fn pytest_success(input: &str) -> String { + let mut out = String::new(); + let mut summary = String::new(); + + for line in input.lines() { + let trimmed = line.trim(); + if is_pytest_summary_line(trimmed) || is_pytest_summary_header(trimmed) { + push_line(&mut summary, line); + push_line(&mut out, line); + continue; + } + if is_pytest_pass_noise(trimmed) { + continue; + } + push_line(&mut out, line); + } + + if has_content(&out) { + out + } else if has_content(&summary) { + summary + } else { + primitives::head_tail_lines(input, 0, 20) + } +} + +fn starts_pytest_failure(trimmed: &str) -> bool { + (trimmed.starts_with('_') && trimmed.ends_with('_') && trimmed.contains("test")) + || trimmed.starts_with("E ") + || trimmed.starts_with("ERROR at ") + || trimmed.starts_with("FAILED ") +} + +fn is_pytest_summary_header(trimmed: &str) -> bool { + trimmed.contains("short test summary info") || trimmed.contains("warnings summary") +} + +fn is_pytest_summary_line(trimmed: &str) -> bool { + trimmed.starts_with('=') + && (trimmed.contains("passed") + || trimmed.contains("failed") + || trimmed.contains("error") + || trimmed.contains("skipped") + || trimmed.contains("warnings") + || trimmed.contains("no tests ran")) +} + +fn is_pytest_section_delimiter(trimmed: &str) -> bool { + trimmed.len() >= 6 + && trimmed + .chars() + .all(|ch| ch == '_' || ch == '=' || ch == '-') +} + +fn is_pytest_pass_noise(trimmed: &str) -> bool { + trimmed.is_empty() + || trimmed.starts_with("collecting ") + || trimmed.starts_with("collected ") + || trimmed.starts_with("rootdir:") + || trimmed.starts_with("configfile:") + || trimmed.starts_with("plugins:") + || trimmed.starts_with("platform ") + || trimmed.starts_with("cachedir:") + || trimmed + .chars() + .all(|ch| matches!(ch, '.' | 's' | 'S' | 'x' | 'X' | 'f' | 'F' | 'E')) +} + +fn push_line(out: &mut String, line: &str) { + out.push_str(line); + out.push('\n'); +} + +fn has_content(text: &str) -> bool { + text.lines().any(|line| !line.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + #[test] + fn supports_direct_and_python_module_tools() { + assert!(supports("pytest", None)); + assert!(supports("python3", Some("mypy"))); + assert!(!supports("python3", Some("pip"))); + } + + #[test] + fn pytest_failure_keeps_failure_and_summary() { + let input = "============================= test session starts \ + =============================\ncollected 2 items\ntests/test_math.py \ + .F\n\n______________________________ test_adds_badly \ + ______________________________\n\ndef test_adds_badly():\n> assert 1 + 1 \ + == 3\nE assert (1 + 1) == 3\n\ntests/test_math.py:4: \ + AssertionError\n=========================== short test summary info \ + ===========================\nFAILED tests/test_math.py::test_adds_badly - \ + assert (1 + 1) == 3\n========================= 1 failed, 1 passed in 0.02s \ + =========================\n"; + + let out = filter_pytest(input, 1); + + assert!(!out.contains("test session starts")); + assert!(out.contains("test_adds_badly")); + assert!(out.contains("AssertionError")); + assert!(out.contains("1 failed, 1 passed")); + } + + #[test] + fn ruff_routes_to_lint_grouping() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let context = MinimizerCtx { + program: "ruff", + subcommand: Some("check"), + command: "ruff check", + config: &cfg, + }; + let out = filter( + &context, + "src/a.py:1:1: F401 unused import\nsrc/a.py:2:1: E501 line too long\n", + 1, + ); + + assert!(out.text.contains("2 diagnostics in 1 files")); + assert!(out.text.contains("src/a.py (2 diagnostics)")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/ruby.rs b/crates/pi-natives/src/shell/minimizer/filters/ruby.rs new file mode 100644 index 000000000..8a8610a1d --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/ruby.rs @@ -0,0 +1,262 @@ +//! Ruby test and lint output filters. + +use super::lint; +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(program: &str, subcommand: Option<&str>) -> bool { + matches!(program, "rspec" | "rubocop") + || matches!((program, subcommand), ("rake" | "rails", Some("test"))) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let text = match ruby_tool(ctx.program, ctx.subcommand) { + Some("rspec") => filter_rspec(&cleaned, exit_code), + Some("minitest") => filter_minitest(&cleaned, exit_code), + Some("rubocop") => lint::condense_lint_output("rubocop", &cleaned, exit_code), + _ => cleaned, + }; + + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn ruby_tool<'a>(program: &'a str, subcommand: Option<&'a str>) -> Option<&'a str> { + match (program, subcommand) { + ("rspec", _) => Some("rspec"), + ("rubocop", _) => Some("rubocop"), + ("rake" | "rails", Some("test")) => Some("minitest"), + _ => None, + } +} + +fn filter_rspec(input: &str, exit_code: i32) -> String { + if exit_code == 0 { + return ruby_test_success(input); + } + + let mut out = String::new(); + let mut in_failure = false; + let mut in_failed_examples = false; + + for line in input.lines() { + let trimmed = line.trim(); + if trimmed == "Failures:" { + in_failure = true; + in_failed_examples = false; + push_line(&mut out, line); + continue; + } + if trimmed == "Failed examples:" { + in_failure = false; + in_failed_examples = true; + push_line(&mut out, line); + continue; + } + if is_rspec_summary_line(trimmed) { + in_failure = false; + in_failed_examples = false; + push_line(&mut out, line); + continue; + } + if in_failure { + if is_gem_backtrace(trimmed) || is_rspec_noise(trimmed) { + continue; + } + push_line(&mut out, line); + continue; + } + if in_failed_examples && !trimmed.is_empty() { + push_line(&mut out, line); + } + } + + if has_content(&out) { + out + } else { + primitives::head_tail_lines(input, 80, 80) + } +} + +fn filter_minitest(input: &str, exit_code: i32) -> String { + if exit_code == 0 { + return ruby_test_success(input); + } + + let mut out = String::new(); + let mut in_failure = false; + + for line in input.lines() { + let trimmed = line.trim(); + if starts_minitest_failure(trimmed) { + in_failure = true; + push_line(&mut out, line); + continue; + } + if is_minitest_summary_line(trimmed) { + in_failure = false; + push_line(&mut out, line); + continue; + } + if in_failure { + if trimmed.starts_with("Finished in ") { + in_failure = false; + continue; + } + if !trimmed.is_empty() { + push_line(&mut out, line); + } + } + } + + if has_content(&out) { + out + } else { + primitives::head_tail_lines(input, 80, 80) + } +} + +fn ruby_test_success(input: &str) -> String { + let mut out = String::new(); + let mut summary = String::new(); + + for line in input.lines() { + let trimmed = line.trim(); + if is_rspec_summary_line(trimmed) || is_minitest_summary_line(trimmed) { + push_line(&mut summary, line); + push_line(&mut out, line); + continue; + } + if is_ruby_pass_noise(trimmed) { + continue; + } + push_line(&mut out, line); + } + + if has_content(&out) { out } else { summary } +} + +fn starts_minitest_failure(trimmed: &str) -> bool { + let mut parts = trimmed.split_whitespace(); + let Some(number) = parts.next() else { + return false; + }; + let Some(kind) = parts.next() else { + return false; + }; + number.ends_with(')') && matches!(kind, "Failure:" | "Error:") +} + +fn is_rspec_summary_line(trimmed: &str) -> bool { + trimmed.contains(" examples, ") && (trimmed.contains(" failure") || trimmed.contains(" pending")) +} + +fn is_minitest_summary_line(trimmed: &str) -> bool { + trimmed.contains(" runs, ") + && trimmed.contains(" assertions, ") + && trimmed.contains(" failures, ") + && trimmed.contains(" errors") +} + +fn is_ruby_pass_noise(trimmed: &str) -> bool { + trimmed.is_empty() + || trimmed == "." + || trimmed + .chars() + .all(|ch| matches!(ch, '.' | 'S' | 'F' | 'E')) + || trimmed.starts_with("Run options:") + || trimmed.starts_with("Running:") + || trimmed.starts_with("Randomized with seed") + || trimmed.starts_with("Finished in ") +} + +fn is_rspec_noise(trimmed: &str) -> bool { + trimmed.starts_with("# ") && is_gem_backtrace(trimmed) +} + +fn is_gem_backtrace(trimmed: &str) -> bool { + trimmed.contains("/gems/") + || trimmed.contains("lib/rspec") + || trimmed.contains("lib/ruby/") + || trimmed.contains("vendor/bundle") +} + +fn push_line(out: &mut String, line: &str) { + out.push_str(line); + out.push('\n'); +} + +fn has_content(text: &str) -> bool { + text.lines().any(|line| !line.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + #[test] + fn supports_rspec_minitest_and_rubocop() { + assert!(supports("rspec", None)); + assert!(supports("rake", Some("test"))); + assert!(supports("rails", Some("test"))); + assert!(supports("rubocop", None)); + assert!(!supports("rake", Some("db:migrate"))); + } + + #[test] + fn rspec_failure_keeps_failure_context_and_summary() { + let input = "..F\n\nFailures:\n\n 1) User validates name\n Failure/Error: \ + expect(user).to be_valid\n expected valid? to return true, got false\n \ + # ./spec/models/user_spec.rb:12:in `block'\n # \ + ./vendor/bundle/ruby/3.3.0/gems/rspec-core/lib/rspec/core.rb:1\n\nFailed \ + examples:\n\nrspec ./spec/models/user_spec.rb:12 # User validates name\n\n3 \ + examples, 1 failure\n"; + let out = filter_rspec(input, 1); + + assert!(!out.contains("..F")); + assert!(out.contains("User validates name")); + assert!(out.contains("expected valid?")); + assert!(out.contains("spec/models/user_spec.rb:12")); + assert!(!out.contains("vendor/bundle")); + assert!(out.contains("3 examples, 1 failure")); + } + + #[test] + fn minitest_failure_keeps_failure_and_summary() { + let input = "Run options: --seed 1\n\n# Running:\n\n.F\n\nFinished in 0.001s, 2000 \ + runs/s\n\n 1) Failure:\nUserTest#test_name \ + [test/models/user_test.rb:8]:\nExpected false to be truthy.\n\n2 runs, 2 \ + assertions, 1 failures, 0 errors, 0 skips\n"; + let out = filter_minitest(input, 1); + + assert!(!out.contains("Run options")); + assert!(out.contains("1) Failure")); + assert!(out.contains("test/models/user_test.rb:8")); + assert!(out.contains("2 runs, 2 assertions, 1 failures")); + } + + #[test] + fn rubocop_routes_to_lint_grouping() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let context = MinimizerCtx { + program: "rubocop", + subcommand: None, + command: "rubocop", + config: &cfg, + }; + let out = filter( + &context, + "app/models/user.rb:1:1: C: Style/FrozenStringLiteralComment: Missing frozen string \ + literal comment.\napp/models/user.rb:2:7: W: Lint/UselessAssignment: Useless \ + assignment.\n", + 1, + ); + + assert!(out.text.contains("2 diagnostics in 1 files")); + assert!(out.text.contains("app/models/user.rb (2 diagnostics)")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/filters/system.rs b/crates/pi-natives/src/shell/minimizer/filters/system.rs new file mode 100644 index 000000000..a534b9353 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/filters/system.rs @@ -0,0 +1,733 @@ +//! Conservative text filters for system-style commands. + +use std::collections::HashMap; + +use crate::shell::minimizer::{MinimizerCtx, MinimizerOutput, primitives}; + +pub fn supports(program: &str) -> bool { + matches!( + program, + "env" + | "log" + | "deps" + | "summary" + | "err" + | "test" + | "diff" + | "format" + | "pipe" + | "ps" | "ping" + | "ssh" + | "sops" + ) +} + +pub fn filter(ctx: &MinimizerCtx<'_>, input: &str, exit_code: i32) -> MinimizerOutput { + let cleaned = primitives::strip_ansi(input); + let command = ctx.program; + let text = match command { + "env" => compact_env(&cleaned), + "log" => compact_log(&cleaned), + "deps" => compact_dependency_output(&cleaned), + "summary" => compact_summary_output(&cleaned, exit_code), + "err" => compact_error_output(&cleaned), + "test" => compact_test_output(&cleaned), + "diff" => compact_diff_output(&cleaned), + "format" => compact_format_output(&cleaned), + "pipe" => compact_pipe_like_output(&cleaned, exit_code), + "ps" => compact_ps_output(&cleaned), + "ping" => compact_ping_output(&cleaned), + "ssh" => compact_ssh_output(&cleaned), + "sops" => compact_sops_output(&cleaned), + _ => cleaned, + }; + if text == input { + MinimizerOutput::passthrough(input) + } else { + MinimizerOutput::transformed(text) + } +} + +fn compact_env(input: &str) -> String { + let mut out = String::new(); + let mut transformed = false; + let mut lines = 0usize; + + for line in input.lines() { + lines += 1; + let rendered_line = if let Some((prefix, key, value)) = split_env_assignment(line) { + let rendered = render_env_value(key, value); + if rendered != value { + transformed = true; + } + let mut line = String::new(); + line.push_str(prefix); + line.push_str(key); + line.push('='); + line.push_str(&rendered); + line + } else { + line.to_string() + }; + out.push_str(&rendered_line); + out.push('\n'); + } + + if lines > 80 { + let compacted = primitives::head_tail_lines(&out, 40, 25); + let mut with_header = format!("env output: {lines} lines\n"); + with_header.push_str(&compacted); + return with_header; + } + + if transformed { out } else { input.to_string() } +} + +fn split_env_assignment(line: &str) -> Option<(&str, &str, &str)> { + let trimmed = line.trim_start(); + let prefix = &line[..line.len().saturating_sub(trimmed.len())]; + let rest = trimmed + .strip_prefix("export ") + .map_or(trimmed, |value| value); + let export_prefix = if rest.len() == trimmed.len() { + "" + } else { + "export " + }; + let (key, value) = rest.split_once('=')?; + if key.is_empty() + || !key + .chars() + .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_') + { + return None; + } + Some(( + if export_prefix.is_empty() { + prefix + } else { + "export " + }, + key, + value, + )) +} + +fn render_env_value(key: &str, value: &str) -> String { + if is_sensitive_key(key) { + return mask_env_value(value); + } + let char_count = value.chars().count(); + if char_count > 160 { + let preview: String = value.chars().take(80).collect(); + format!("{preview}… ({char_count} chars)") + } else { + value.to_string() + } +} + +fn is_sensitive_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + [ + "token", + "secret", + "password", + "passwd", + "credential", + "apikey", + "api_key", + "access_key", + "private_key", + "jwt", + "auth", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +fn mask_env_value(value: &str) -> String { + let chars: Vec = value.chars().collect(); + if chars.len() <= 4 { + "[redacted]".to_string() + } else { + let prefix: String = chars.iter().take(2).collect(); + let suffix_start = chars.len().saturating_sub(2); + let suffix: String = chars.iter().skip(suffix_start).collect(); + format!("{prefix}[redacted]{suffix}") + } +} + +fn compact_log(input: &str) -> String { + let lines: Vec<&str> = input.lines().collect(); + if lines.is_empty() { + return input.to_string(); + } + + let mut unique: Vec = Vec::new(); + let mut by_normalized: HashMap = HashMap::new(); + let mut errors = 0usize; + let mut warnings = 0usize; + let mut info = 0usize; + + for line in &lines { + let lower = line.to_ascii_lowercase(); + if lower.contains("error") || lower.contains("fatal") || lower.contains("panic") { + errors += 1; + } else if lower.contains("warn") { + warnings += 1; + } else if lower.contains("info") { + info += 1; + } + + let normalized = normalize_log_line(line); + if let Some(index) = by_normalized.get(&normalized).copied() { + if let Some(entry) = unique.get_mut(index) { + entry.count += 1; + } + } else { + by_normalized.insert(normalized, unique.len()); + unique.push(LogLine { original: (*line).to_string(), count: 1 }); + } + } + + if unique.len() == lines.len() && lines.len() <= 80 { + return primitives::dedup_consecutive_lines(input); + } + + let mut out = format!( + "log summary: {} lines, {} unique, {} errors, {} warnings, {} info\n", + lines.len(), + unique.len(), + errors, + warnings, + info + ); + let rendered = render_counted_lines(&unique, 60, 20); + out.push_str(&rendered); + out +} + +struct LogLine { + original: String, + count: usize, +} + +fn normalize_log_line(line: &str) -> String { + let without_timestamp = strip_leading_timestamp(line.trim()); + let mut out = String::new(); + let mut digits = String::new(); + for ch in without_timestamp.chars() { + if ch.is_ascii_digit() { + digits.push(ch); + continue; + } + flush_digits(&mut out, &mut digits); + out.push(ch); + } + flush_digits(&mut out, &mut digits); + out.split_whitespace().collect::>().join(" ") +} + +fn strip_leading_timestamp(line: &str) -> &str { + let bytes = line.as_bytes(); + if bytes.len() >= 19 + && bytes.get(4) == Some(&b'-') + && bytes.get(7) == Some(&b'-') + && matches!(bytes.get(10).copied(), Some(b'T' | b' ')) + { + if let Some(rest) = line.get(19..) { + return rest.trim_start(); + } + return ""; + } + line +} + +fn flush_digits(out: &mut String, digits: &mut String) { + if digits.is_empty() { + return; + } + if digits.len() >= 4 { + out.push_str(""); + } else { + out.push_str(digits); + } + digits.clear(); +} + +fn render_counted_lines(lines: &[LogLine], head: usize, tail: usize) -> String { + let mut out = String::new(); + if lines.len() <= head + tail { + for line in lines { + push_counted_line(&mut out, &line.original, line.count); + } + return out; + } + for line in lines.iter().take(head) { + push_counted_line(&mut out, &line.original, line.count); + } + out.push_str("… "); + out.push_str(&(lines.len() - head - tail).to_string()); + out.push_str(" unique lines omitted …\n"); + for line in lines.iter().skip(lines.len() - tail) { + push_counted_line(&mut out, &line.original, line.count); + } + out +} + +fn push_counted_line(out: &mut String, line: &str, count: usize) { + out.push_str(line); + if count > 1 { + out.push_str(" (×"); + out.push_str(&count.to_string()); + out.push(')'); + } + out.push('\n'); +} + +fn compact_dependency_output(input: &str) -> String { + let lines: Vec<&str> = input.lines().collect(); + if lines.len() <= 80 { + return input.to_string(); + } + let mut out = String::from("dependency output summary\n"); + for line in lines + .iter() + .copied() + .filter(|line| is_dependency_heading(line)) + { + out.push_str(line); + out.push('\n'); + } + out.push_str(&primitives::head_tail_lines(input, 35, 25)); + out +} + +fn is_dependency_heading(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("dependencies") + || lower.contains("packages") + || lower.ends_with("package.json:") + || lower.ends_with("cargo.toml:") + || lower.ends_with("go.mod:") + || lower.ends_with("requirements.txt:") +} + +fn compact_summary_output(input: &str, exit_code: i32) -> String { + let lines: Vec<&str> = input.lines().collect(); + if lines.len() <= 100 { + return input.to_string(); + } + let mut out = format!("summary output: {} lines, exit {exit_code}\n", lines.len()); + push_important_lines(&mut out, input, 30); + out.push_str(&primitives::head_tail_lines(input, 35, 25)); + out +} + +fn compact_error_output(input: &str) -> String { + let lines: Vec<&str> = input.lines().collect(); + if lines.len() <= 80 { + return primitives::dedup_consecutive_lines(input); + } + let mut out = format!("error output: {} lines\n", lines.len()); + push_important_lines(&mut out, input, 80); + out.push_str(&primitives::head_tail_lines(input, 30, 30)); + out +} + +fn compact_test_output(input: &str) -> String { + let lines: Vec<&str> = input.lines().collect(); + if lines.len() <= 120 { + return primitives::dedup_consecutive_lines(input); + } + let mut out = format!("test output: {} lines\n", lines.len()); + push_important_lines(&mut out, input, 80); + out.push_str(&primitives::head_tail_lines(input, 35, 35)); + out +} + +fn push_important_lines(out: &mut String, input: &str, max: usize) { + let mut pushed = 0usize; + for line in input.lines() { + if pushed >= max { + break; + } + if is_important_line(line) && !out.lines().any(|existing| existing == line) { + out.push_str(line); + out.push('\n'); + pushed += 1; + } + } +} + +fn is_important_line(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("error") + || lower.contains("failed") + || lower.contains("failure") + || lower.contains("fatal") + || lower.contains("panic") + || lower.contains("warning") + || lower.contains("warn") + || lower.contains("passed") + || lower.contains("summary") +} + +fn compact_diff_output(input: &str) -> String { + let lines: Vec<&str> = input.lines().collect(); + if lines.len() <= 120 { + return input.to_string(); + } + + let mut out = format!("diff output: {} lines\n", lines.len()); + let mut hunk_body: Vec<&str> = Vec::new(); + let mut saw_hunk = false; + for line in &lines { + if is_diff_file_header(line) { + flush_hunk_body(&mut out, &hunk_body); + hunk_body.clear(); + out.push_str(line); + out.push('\n'); + } else if line.starts_with("@@") { + flush_hunk_body(&mut out, &hunk_body); + hunk_body.clear(); + saw_hunk = true; + out.push_str(line); + out.push('\n'); + } else if saw_hunk { + hunk_body.push(line); + } + } + flush_hunk_body(&mut out, &hunk_body); + + if saw_hunk { + out + } else { + primitives::head_tail_lines(input, 80, 40) + } +} + +fn is_diff_file_header(line: &str) -> bool { + line.starts_with("diff --git ") + || line.starts_with("--- ") + || line.starts_with("+++ ") + || line.starts_with("Index: ") +} + +fn flush_hunk_body(out: &mut String, lines: &[&str]) { + if lines.is_empty() { + return; + } + let changed: Vec<&str> = lines + .iter() + .copied() + .filter(|line| line.starts_with('+') || line.starts_with('-')) + .collect(); + let source = if changed.is_empty() { lines } else { &changed }; + if source.len() <= 36 { + for line in source { + out.push_str(line); + out.push('\n'); + } + return; + } + for line in source.iter().take(24) { + out.push_str(line); + out.push('\n'); + } + out.push_str("… "); + out.push_str(&(source.len() - 36).to_string()); + out.push_str(" changed lines omitted in hunk …\n"); + for line in source.iter().skip(source.len() - 12) { + out.push_str(line); + out.push('\n'); + } +} + +fn compact_format_output(input: &str) -> String { + let lines: Vec<&str> = input.lines().collect(); + if lines.len() <= 80 { + return input.to_string(); + } + + let mut errors = Vec::new(); + let mut files = Vec::new(); + let mut summary = Vec::new(); + for line in &lines { + let lower = line.to_ascii_lowercase(); + if lower.contains("error") || lower.contains("failed") || lower.contains("oh no") { + errors.push(*line); + } else if is_format_file_line(line) { + files.push(*line); + } else if lower.contains("formatted") + || lower.contains("reformatted") + || lower.contains("unchanged") + || lower.contains("checked") + { + summary.push(*line); + } + } + + let mut out = format!("format output: {} lines\n", lines.len()); + if !errors.is_empty() { + out.push_str("errors:\n"); + for line in errors.iter().take(40) { + out.push_str(line); + out.push('\n'); + } + } + if !summary.is_empty() { + out.push_str("summary:\n"); + for line in summary.iter().take(20) { + out.push_str(line); + out.push('\n'); + } + } + if !files.is_empty() { + out.push_str("files:\n"); + for line in files.iter().take(50) { + out.push_str(line); + out.push('\n'); + } + if files.len() > 50 { + out.push_str("… "); + out.push_str(&(files.len() - 50).to_string()); + out.push_str(" more files\n"); + } + } + if errors.is_empty() && summary.is_empty() && files.is_empty() { + out.push_str(&primitives::head_tail_lines(input, 40, 30)); + } + out +} + +fn is_format_file_line(line: &str) -> bool { + let trimmed = line.trim(); + let lower = trimmed.to_ascii_lowercase(); + let source_extensions = ["rs", "py", "js", "jsx", "ts", "tsx", "json", "css", "md"]; + let has_source_extension = std::path::Path::new(trimmed) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + source_extensions + .iter() + .any(|candidate| ext.eq_ignore_ascii_case(candidate)) + }); + has_source_extension || lower.contains("would reformat") || lower.contains("reformatted") +} + +fn compact_pipe_like_output(input: &str, exit_code: i32) -> String { + if looks_like_diff(input) { + return compact_diff_output(input); + } + if looks_like_file_diagnostics(input) { + return primitives::group_by_file(input, 12); + } + if looks_like_path_listing(input) { + return primitives::compact_listing(input, 80); + } + if exit_code != 0 || input.lines().any(is_important_line) { + return compact_error_output(input); + } + let deduped = primitives::dedup_consecutive_lines(input); + if deduped.lines().count() > 120 { + primitives::head_tail_lines(&deduped, 60, 40) + } else { + deduped + } +} + +fn looks_like_diff(input: &str) -> bool { + input + .lines() + .take(20) + .any(|line| line.starts_with("@@") || line.starts_with("diff --git ")) +} + +fn looks_like_file_diagnostics(input: &str) -> bool { + input.lines().take(10).any(|line| { + let mut parts = line.splitn(3, ':'); + let file = parts.next(); + let line_no = parts.next(); + file.is_some_and(|value| !value.is_empty()) + && line_no.is_some_and(|value| value.parse::().is_ok()) + && parts.next().is_some() + }) +} + +fn looks_like_path_listing(input: &str) -> bool { + let non_empty: Vec<&str> = input + .lines() + .filter(|line| !line.trim().is_empty()) + .take(20) + .collect(); + !non_empty.is_empty() + && non_empty.iter().all(|line| { + let trimmed = line.trim(); + !trimmed.contains(':') + && (trimmed.starts_with('.') || trimmed.starts_with('/') || trimmed.contains('/')) + }) +} + +fn compact_ps_output(input: &str) -> String { + let mut out = String::new(); + for line in input.lines() { + out.push_str(&truncate_chars(line, 120)); + out.push('\n'); + } + if out.lines().count() > 30 { + primitives::head_tail_lines(&out, 15, 15) + } else { + out + } +} + +fn truncate_chars(line: &str, max: usize) -> String { + if line.chars().count() <= max { + return line.to_string(); + } + let mut out: String = line.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out +} + +fn compact_ping_output(input: &str) -> String { + let mut kept = String::new(); + for line in input.lines() { + if is_ping_noise(line) { + continue; + } + if line.trim().is_empty() && kept.is_empty() { + continue; + } + kept.push_str(line); + kept.push('\n'); + } + if kept.is_empty() { + input.to_string() + } else { + kept + } +} + +fn is_ping_noise(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.starts_with("PING ") + || trimmed.starts_with("Pinging ") + || (trimmed.contains(" bytes from ") && trimmed.contains("icmp_seq")) + || trimmed.starts_with("Reply from ") +} + +fn compact_ssh_output(input: &str) -> String { + let mut out = String::new(); + for line in input.lines() { + if is_ssh_noise(line) { + continue; + } + out.push_str(&truncate_chars(line, 120)); + out.push('\n'); + } + if out.lines().count() > 200 { + primitives::head_tail_lines(&out, 100, 80) + } else if out.is_empty() { + input.to_string() + } else { + out + } +} + +fn is_ssh_noise(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.is_empty() + || trimmed.starts_with("Warning: Permanently added") + || trimmed.starts_with("Connection to ") && trimmed.ends_with(" closed.") + || trimmed.starts_with("Authenticated to ") + || trimmed.starts_with("debug1:") + || trimmed.starts_with("OpenSSH_") + || trimmed.starts_with("Pseudo-terminal") +} + +fn compact_sops_output(input: &str) -> String { + let mut out = String::new(); + for line in input.lines() { + if !line.trim().is_empty() { + out.push_str(line); + out.push('\n'); + } + } + if out.lines().count() > 40 { + primitives::head_tail_lines(&out, 20, 20) + } else if out.is_empty() && !input.is_empty() { + input.to_string() + } else { + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shell::minimizer::MinimizerConfig; + + fn ctx<'a>(program: &'a str, cfg: &'a MinimizerConfig) -> MinimizerCtx<'a> { + MinimizerCtx { program, subcommand: None, command: program, config: cfg } + } + + #[test] + fn log_dedups_repeated_normalized_lines() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("log", &cfg); + let input = "2026-01-01 10:00:00 ERROR worker 12345 failed\n2026-01-01 10:00:01 ERROR \ + worker 67890 failed\nINFO ready\n"; + let out = filter(&ctx, input, 1); + assert!(out.text.contains("3 lines, 2 unique")); + assert!(out.text.contains("(×2)")); + } + + #[test] + fn env_masks_secrets_and_compacts_long_values() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("env", &cfg); + let input = format!("API_TOKEN=supersecrettoken\nPATH={}\n", "a".repeat(170)); + let out = filter(&ctx, &input, 0); + assert!(out.text.contains("API_TOKEN=su[redacted]en")); + assert!(out.text.contains("(170 chars)")); + assert!(!out.text.contains("supersecrettoken")); + } + + #[test] + fn diff_compaction_preserves_hunk_headers() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("diff", &cfg); + let mut input = + String::from("diff --git a/a.rs b/a.rs\n--- a/a.rs\n+++ b/a.rs\n@@ -1,140 +1,140 @@\n"); + for idx in 0..140 { + input.push_str("-old "); + input.push_str(&idx.to_string()); + input.push_str("\n+new "); + input.push_str(&idx.to_string()); + input.push('\n'); + } + let out = filter(&ctx, &input, 0); + assert!(out.text.contains("@@ -1,140 +1,140 @@")); + assert!(out.text.contains("changed lines omitted in hunk")); + assert!(out.text.contains("+new 139")); + } + + #[test] + fn format_compaction_preserves_errors_and_files() { + let cfg = MinimizerConfig { enabled: true, ..Default::default() }; + let ctx = ctx("format", &cfg); + let mut input = String::from("error: failed to parse src/bad.py\n"); + for idx in 0..100 { + input.push_str("would reformat src/file_"); + input.push_str(&idx.to_string()); + input.push_str(".py\n"); + } + let out = filter(&ctx, &input, 1); + assert!(out.text.contains("errors:")); + assert!(out.text.contains("failed to parse src/bad.py")); + assert!(out.text.contains("files:")); + assert!(out.text.contains("more files")); + } +} diff --git a/crates/pi-natives/src/shell/minimizer/primitives.rs b/crates/pi-natives/src/shell/minimizer/primitives.rs new file mode 100644 index 000000000..7fecedde8 --- /dev/null +++ b/crates/pi-natives/src/shell/minimizer/primitives.rs @@ -0,0 +1,193 @@ +//! Reusable text transforms shared by minimizer filters. + +use std::collections::BTreeMap; + +/// Remove ANSI CSI escape sequences and carriage-return progress frames. +pub fn strip_ansi(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\x1b' && chars.peek().is_some_and(|next| *next == '[') { + let _ = chars.next(); + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } + } + continue; + } + if ch == '\r' { + out.push('\n'); + continue; + } + out.push(ch); + } + out +} + +/// Collapse consecutive identical lines as `line (×N)`. +pub fn dedup_consecutive_lines(input: &str) -> String { + let mut out = String::new(); + let mut previous: Option<&str> = None; + let mut count = 0usize; + for line in input.lines() { + if previous == Some(line) { + count += 1; + continue; + } + flush_repeated(&mut out, previous, count); + previous = Some(line); + count = 1; + } + flush_repeated(&mut out, previous, count); + out +} + +fn flush_repeated(out: &mut String, line: Option<&str>, count: usize) { + let Some(line) = line else { + return; + }; + out.push_str(line); + if count > 1 { + out.push_str(" (×"); + out.push_str(&count.to_string()); + out.push(')'); + } + out.push('\n'); +} + +/// Keep the first `head` and last `tail` lines with an omission marker. +pub fn head_tail_lines(input: &str, head: usize, tail: usize) -> String { + let lines: Vec<&str> = input.lines().collect(); + if lines.len() <= head + tail { + return input.to_string(); + } + let omitted = lines.len() - head - tail; + let mut out = String::new(); + for line in lines.iter().take(head) { + out.push_str(line); + out.push('\n'); + } + out.push_str("… "); + out.push_str(&omitted.to_string()); + out.push_str(" lines omitted …\n"); + for line in lines.iter().skip(lines.len() - tail) { + out.push_str(line); + out.push('\n'); + } + out +} + +/// Drop lines matching any of the supplied predicates. +pub fn strip_lines(input: &str, predicates: &[fn(&str) -> bool]) -> String { + let mut out = String::new(); + for line in input.lines() { + if predicates.iter().any(|predicate| predicate(line)) { + continue; + } + out.push_str(line); + out.push('\n'); + } + out +} + +/// Group `file:line:message` style diagnostics by file. +pub fn group_by_file(input: &str, max_per_file: usize) -> String { + let mut grouped: BTreeMap> = BTreeMap::new(); + let mut ungrouped = Vec::new(); + for line in input.lines() { + if let Some((file, rest)) = split_file_line(line) { + grouped + .entry(file.to_string()) + .or_default() + .push(rest.to_string()); + } else { + ungrouped.push(line.to_string()); + } + } + if grouped.is_empty() { + return input.to_string(); + } + let mut out = String::new(); + for (file, entries) in grouped { + out.push_str(&file); + out.push_str(":\n"); + for entry in entries.iter().take(max_per_file) { + out.push_str(" "); + out.push_str(entry); + out.push('\n'); + } + if entries.len() > max_per_file { + out.push_str(" … "); + out.push_str(&(entries.len() - max_per_file).to_string()); + out.push_str(" more\n"); + } + } + for line in ungrouped { + out.push_str(&line); + out.push('\n'); + } + out +} + +fn split_file_line(line: &str) -> Option<(&str, &str)> { + let (file, rest) = line.split_once(':')?; + if file.is_empty() + || file.starts_with(' ') + || !rest.chars().next().is_some_and(|c| c.is_ascii_digit()) + { + return None; + } + Some((file, rest)) +} + +/// Compact a long plain listing to head/tail form. +pub fn compact_listing(input: &str, max_lines: usize) -> String { + let lines: Vec<&str> = input + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + if lines.len() <= max_lines { + return input.to_string(); + } + let mut out = String::new(); + out.push_str(&lines.len().to_string()); + out.push_str(" entries\n"); + for line in lines.iter().take(max_lines / 2) { + out.push_str(line); + out.push('\n'); + } + out.push_str("…\n"); + for line in lines.iter().skip(lines.len() - max_lines / 2) { + out.push_str(line); + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_ansi_sequences() { + assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red"); + } + + #[test] + fn dedups_consecutive_lines() { + assert_eq!(dedup_consecutive_lines("a\na\nb\n"), "a (×2)\nb\n"); + } + + #[test] + fn head_tail_marks_omitted_lines() { + let out = head_tail_lines("1\n2\n3\n4\n5\n", 2, 1); + assert_eq!(out, "1\n2\n… 2 lines omitted …\n5\n"); + } + + #[test] + fn groups_file_diagnostics() { + let out = group_by_file("src/a.ts:1:2 error one\nsrc/a.ts:2:3 error two\n", 10); + assert_eq!(out, "src/a.ts:\n 1:2 error one\n 2:3 error two\n"); + } +} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d6d0ac918..9e19857c2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added + +- Added `shellMinimizer` configuration options (`enabled`, `settingsPath`, `only`, `except`, and `maxCaptureBytes`) so users can control shell output minimization behavior + +### Changed + +- Changed shell execution in both interactive and non-interactive modes to route command output through the configured shell output minimizer +- Changed default behavior so shell output minimization can now be toggled from settings without code changes ### Fixed @@ -7132,4 +7140,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections +- HTML export with syntax highlighting and collapsible sections \ No newline at end of file diff --git a/packages/coding-agent/src/cli/shell-cli.ts b/packages/coding-agent/src/cli/shell-cli.ts index de579d057..9213a0b22 100644 --- a/packages/coding-agent/src/cli/shell-cli.ts +++ b/packages/coding-agent/src/cli/shell-cli.ts @@ -5,10 +5,10 @@ */ import * as path from "node:path"; import { createInterface } from "node:readline/promises"; -import { Shell } from "@oh-my-pi/pi-natives"; +import { type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives"; import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; -import { Settings } from "../config/settings"; +import { Settings, type ShellMinimizerSettings } from "../config/settings"; import { getOrCreateSnapshot } from "../utils/shell-snapshot"; export interface ShellCommandArgs { @@ -41,6 +41,17 @@ export function parseShellArgs(args: string[]): ShellCommandArgs | undefined { return result; } +function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined { + if (!group.enabled) return undefined; + return { + enabled: true, + settingsPath: group.settingsPath || undefined, + only: group.only.length > 0 ? group.only : undefined, + except: group.except.length > 0 ? group.except : undefined, + maxCaptureBytes: group.maxCaptureBytes, + }; +} + export async function runShellCommand(cmd: ShellCommandArgs): Promise { if (!process.stdin.isTTY) { process.stderr.write("Error: shell console requires an interactive TTY.\n"); @@ -51,7 +62,8 @@ export async function runShellCommand(cmd: ShellCommandArgs): Promise { const settings = await Settings.init({ cwd }); const { shell, env: shellEnv } = settings.getShellConfig(); const snapshotPath = cmd.noSnapshot || !shell.includes("bash") ? null : await getOrCreateSnapshot(shell, shellEnv); - const shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined }); + const minimizer = buildMinimizerOptions(settings.getGroup("shellMinimizer")); + const shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined, minimizer }); let active = false; let lastChar: string | null = null; diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 858a67079..7ddf86a9e 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1123,6 +1123,39 @@ export const SETTINGS_SCHEMA = { }, "bashInterceptor.patterns": { type: "array", default: DEFAULT_BASH_INTERCEPTOR_RULES }, + // Shell output minimizer + "shellMinimizer.enabled": { + type: "boolean", + default: true, + ui: { + tab: "editing", + label: "Shell Minimizer", + description: "Compress verbose shell output (git, npm, cargo, etc.) before returning it to the agent", + }, + }, + "shellMinimizer.settingsPath": { + type: "string", + default: undefined, + ui: { + tab: "editing", + label: "Minimizer Settings Path", + description: "Optional TOML file with per-command minimizer overrides", + submenu: true, + }, + }, + "shellMinimizer.only": { type: "array", default: EMPTY_STRING_ARRAY }, + "shellMinimizer.except": { type: "array", default: EMPTY_STRING_ARRAY }, + "shellMinimizer.maxCaptureBytes": { + type: "number", + default: 4 * 1024 * 1024, + ui: { + tab: "editing", + label: "Minimizer Capture Limit", + description: "Maximum captured output bytes before falling back to raw streaming", + submenu: true, + }, + }, + // Python "python.toolMode": { type: "enum", @@ -1972,6 +2005,14 @@ export interface BashInterceptorRule { allowSubcommands?: string[]; } +export interface ShellMinimizerSettings { + enabled: boolean; + settingsPath: string | undefined; + only: string[]; + except: string[]; + maxCaptureBytes: number; +} + /** Map group prefix -> typed settings interface */ export interface GroupTypeMap { compaction: CompactionSettings; @@ -1989,6 +2030,7 @@ export interface GroupTypeMap { modelRoles: Record; modelTags: ModelTagsSettings; cycleOrder: string[]; + shellMinimizer: ShellMinimizerSettings; } export type GroupPrefix = keyof GroupTypeMap; diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index c53b83d88..1d8e193fb 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -4,8 +4,8 @@ * Uses brush-core via native bindings for shell execution. */ import * as fs from "node:fs/promises"; -import { executeShell, Shell } from "@oh-my-pi/pi-natives"; -import { Settings } from "../config/settings"; +import { executeShell, type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives"; +import { Settings, type ShellMinimizerSettings } from "../config/settings"; import { OutputSink } from "../session/streaming-output"; import { getOrCreateSnapshot } from "../utils/shell-snapshot"; import { NON_INTERACTIVE_ENV } from "./non-interactive-env"; @@ -53,10 +53,24 @@ async function resolveShellCwd(cwd: string | undefined): Promise 0 ? group.only : undefined, + except: group.except.length > 0 ? group.except : undefined, + maxCaptureBytes: group.maxCaptureBytes, + }; +} + export async function executeBash(command: string, options?: BashExecutorOptions): Promise { const settings = await Settings.init(); const { shell, env: shellEnv, prefix } = settings.getShellConfig(); const snapshotPath = shell.includes("bash") ? await getOrCreateSnapshot(shell, shellEnv) : null; + + const minimizer = buildMinimizerOptions(settings.getGroup("shellMinimizer")); + const commandCwd = await resolveShellCwd(options?.cwd); const commandEnv = options?.env ? { ...NON_INTERACTIVE_ENV, ...options.env } : NON_INTERACTIVE_ENV; @@ -89,7 +103,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions }; } - const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey); + const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey, minimizer); const persistentSessionBroken = brokenShellSessions.has(sessionKey); if (persistentSessionBroken) { shellSessions.delete(sessionKey); @@ -97,7 +111,11 @@ export async function executeBash(command: string, options?: BashExecutorOptions let shellSession = persistentSessionBroken ? undefined : shellSessions.get(sessionKey); if (!shellSession && !persistentSessionBroken) { - shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined }); + shellSession = new Shell({ + sessionEnv: shellEnv, + snapshotPath: snapshotPath ?? undefined, + minimizer, + }); shellSessions.set(sessionKey, shellSession); } const userSignal = options?.signal; @@ -152,6 +170,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions env: commandEnv, sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined, + minimizer, timeoutMs: options?.timeout, signal: runAbortController.signal, }, @@ -232,9 +251,13 @@ function buildSessionKey( snapshotPath: string | null, env: Record, agentSessionKey?: string, + minimizer?: MinimizerOptions, ): string { const entries = Object.entries(env); entries.sort(([a], [b]) => a.localeCompare(b)); const envSerialized = entries.map(([key, value]) => `${key}=${value}`).join("\n"); - return [agentSessionKey ?? "", shell, prefix ?? "", snapshotPath ?? "", envSerialized].join("\n"); + const minimizerSerialized = minimizer ? JSON.stringify(minimizer) : ""; + return [agentSessionKey ?? "", shell, prefix ?? "", snapshotPath ?? "", envSerialized, minimizerSerialized].join( + "\n", + ); } diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index a47f5cd65..ef98e1346 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## [Unreleased] +### Added + +- Added a new `minimizer` option to `ShellExecuteOptions` and `ShellOptions` to configure per-command output minimization +- Added the `MinimizerOptions` API with controls for enabling minimization, overriding settings via `settingsPath`, allow/deny lists (`only`, `except`), and `maxCaptureBytes` capture limits ## [14.2.0] - 2026-04-23 @@ -437,4 +441,4 @@ ### Fixed -- Fixed potential crashes when updating native binaries by using safe copy strategy that avoids overwriting in-memory binaries +- Fixed potential crashes when updating native binaries by using safe copy strategy that avoids overwriting in-memory binaries \ No newline at end of file diff --git a/packages/natives/native/index.d.ts b/packages/natives/native/index.d.ts index 8dbdea6e6..0ccb1cce5 100644 --- a/packages/natives/native/index.d.ts +++ b/packages/natives/native/index.d.ts @@ -1007,6 +1007,29 @@ export declare function matchesKittySequence(data: string, expectedCodepoint: nu */ export declare function matchesLegacySequence(data: string, keyName: string): boolean +/** N-API opt-in handle for the minimizer. */ +export interface MinimizerOptions { + /** Master switch. Absent / false = disabled. */ + enabled?: boolean + /** + * Optional path to a TOML settings file whose values override + * field-level defaults. `~` is expanded. + */ + settingsPath?: string + /** + * Opt-in allowlist of program names (e.g. `"git"`). When empty or + * absent, all built-in filters are active. + */ + only?: Array + /** Program names explicitly excluded from minimization. */ + except?: Array + /** + * Maximum captured bytes per command before the engine falls back to + * the raw, un-minimized output. Default 4 MiB. + */ + maxCaptureBytes?: number +} + /** Parsed Kitty keyboard protocol sequence result for a Kitty input sequence. */ export interface ParsedKittyResult { /** Primary codepoint associated with the key. */ @@ -1245,6 +1268,8 @@ export interface ShellExecuteOptions { timeoutMs?: number /** Optional snapshot file to source on session creation. */ snapshotPath?: string + /** Optional per-command output minimizer configuration. */ + minimizer?: MinimizerOptions /** Abort signal for cancelling the operation. */ signal?: unknown } @@ -1265,6 +1290,8 @@ export interface ShellOptions { sessionEnv?: Record /** Optional snapshot file to source on session creation. */ snapshotPath?: string + /** Optional per-command output minimizer configuration. */ + minimizer?: MinimizerOptions } /** Options for running a shell command. */