Files
harmony/harmony_execution/src/command.rs
Jean-Gabriel Gill-Couture 50f62b6437 chore: warning sweep — auto-fix pass + scoped allows for generated code
Workspace warning count: 408 → 105.

Three buckets cleared:

* Auto-fixable (`cargo fix` + `cargo clippy --fix`): unused imports
  removed, unused variables prefixed with `_`, deprecated method
  calls updated. Applied across harmony, harmony-k8s, harmony-agent,
  harmony_inventory_agent, the fleet/ workspace, and ~15 examples.
* Generated code (opnsense-api/src/generated/): 269 snake_case
  warnings + ~10 unreachable-pattern warnings come from
  CamelCase-preserving bindings to OPNsense's HAProxy/Caddy XML
  schemas. Scoped a single `#[allow(non_snake_case,
  unreachable_patterns)]` at `pub mod generated;` rather than
  fighting the codegen — renaming would break serde round-trips
  and the codegen would regenerate them anyway.
* opnsense-codegen parser's defensive `let...else` guards on
  `XmlNode` (currently single-variant): file-level
  `#![allow(irrefutable_let_patterns)]` with a comment explaining
  why we keep the `else` arms (they re-arm if the IR grows a
  second variant).

`harmony_inventory_agent::local_presence::{DiscoveryEvent,
discover_agents}` re-exports were stripped twice by the auto-fix
passes (consumers live in another crate, so the local crate looks
"unused" to lint). Anchored with explicit `pub use` + an
`#[allow(unused_imports)]` annotation noting why.

All 151 harmony lib tests still pass. Remaining ~105 warnings are
mostly real dead code in non-fleet modules + a handful of
unused-imports/variables clippy couldn't auto-resolve; cleared in
the next pass.
2026-05-06 22:51:44 -04:00

471 lines
14 KiB
Rust

use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::thread;
/// Captured output from a command execution
#[derive(Debug, Clone)]
pub struct CommandOutput {
/// Captured stdout content
pub stdout: String,
/// Captured stderr content
pub stderr: String,
/// Exit status of the command
pub status: CommandStatus,
}
impl CommandOutput {
/// Returns true if the command succeeded
pub fn is_success(&self) -> bool {
self.status.is_success()
}
/// Formats the complete output for display
pub fn format_output(&self) -> String {
format!(
"Stdout:\n{}\n\nStderr:\n{}",
if self.stdout.is_empty() {
"<empty>"
} else {
&self.stdout
},
if self.stderr.is_empty() {
"<empty>"
} else {
&self.stderr
}
)
}
}
/// Result status of a command execution
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandStatus {
/// Command executed successfully (exit code 0)
Success,
/// Command failed with an exit code
Failed(i32),
/// Command was terminated by a signal
Terminated(i32),
/// Command execution could not be started
Error(String),
}
impl CommandStatus {
pub fn is_success(&self) -> bool {
matches!(self, CommandStatus::Success)
}
}
impl From<std::process::ExitStatus> for CommandStatus {
fn from(status: std::process::ExitStatus) -> Self {
if status.success() {
CommandStatus::Success
} else if let Some(code) = status.code() {
CommandStatus::Failed(code)
} else {
CommandStatus::Terminated(0) // Signal codes are platform-specific
}
}
}
type Callback = Arc<dyn Fn(&str) + Send + Sync>;
/// Options for configuring command execution
#[derive(Clone)]
pub struct RunnerOptions {
/// Whether to print stdout to console in real-time
pub print_stdout: bool,
/// Whether to print stderr to console in real-time
pub print_stderr: bool,
/// Optional callback for each stdout line
pub stdout_callback: Callback,
/// Optional callback for each stderr line
pub stderr_callback: Callback,
}
impl RunnerOptions {
fn empty_callback() -> Callback {
Arc::new(|_| {})
}
/// Create default options with real-time printing enabled
pub fn print_to_console() -> Self {
Self {
print_stdout: true,
print_stderr: true,
..Default::default()
}
}
/// Create options that capture output silently
pub fn silent() -> Self {
Self {
print_stdout: false,
print_stderr: false,
..Default::default()
}
}
/// Set custom callbacks for stdout and stderr lines
pub fn with_callbacks<F1, F2>(mut self, stdout_callback: F1, stderr_callback: F2) -> Self
where
F1: Fn(&str) + Send + Sync + 'static,
F2: Fn(&str) + Send + Sync + 'static,
{
self.stdout_callback = Arc::new(stdout_callback);
self.stderr_callback = Arc::new(stderr_callback);
self
}
}
impl Default for RunnerOptions {
fn default() -> Self {
Self {
print_stdout: true,
print_stderr: true,
stdout_callback: Self::empty_callback(),
stderr_callback: Self::empty_callback(),
}
}
}
/// Error type for command execution failures
#[derive(Debug)]
pub struct CommandError {
/// Human-readable error description
pub message: String,
/// Captured output if execution started
pub output: Option<CommandOutput>,
}
impl std::fmt::Display for CommandError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)?;
if let Some(output) = &self.output {
write!(f, "\n{}", output.format_output())?;
}
Ok(())
}
}
impl std::error::Error for CommandError {}
/// Runs a command and captures its output while streaming to console
///
/// # Example
///
/// ```
/// use harmony_execution::command::{run_command, RunnerOptions};
/// use std::process::Command;
///
/// let output = run_command(
/// Command::new("echo").arg("hello"),
/// RunnerOptions::print_to_console()
/// ).unwrap();
/// assert!(output.is_success());
/// assert_eq!(output.stdout, "hello\n");
/// ```
pub fn run_command(
command: &mut Command,
options: RunnerOptions,
) -> Result<CommandOutput, CommandError> {
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| CommandError {
message: format!("Failed to spawn command: {}", e),
output: None,
})?;
let stdout = child.stdout.take().ok_or_else(|| CommandError {
message: "Failed to capture stdout".to_string(),
output: None,
})?;
let stderr = child.stderr.take().ok_or_else(|| CommandError {
message: "Failed to capture stderr".to_string(),
output: None,
})?;
let stdout_reader = BufReader::new(stdout);
let stderr_reader = BufReader::new(stderr);
let (stdout_sender, stdout_receiver) = std::sync::mpsc::channel();
let (stderr_sender, stderr_receiver) = std::sync::mpsc::channel();
// Spawn thread to handle stdout
let stdout_handle = thread::spawn(move || {
let mut output = String::new();
for line in stdout_reader.lines() {
match line {
Ok(line_content) => {
if options.print_stdout {
println!("{}", line_content);
}
(options.stdout_callback)(&line_content);
output.push_str(&line_content);
output.push('\n');
}
Err(e) => {
// Silently handle read errors - corrupted data at end is common
log::trace!("Error reading stdout line: {}", e);
}
}
}
let _ = stdout_sender.send(output);
});
// Spawn thread to handle stderr
let stderr_handle = thread::spawn(move || {
let mut output = String::new();
for line in stderr_reader.lines() {
match line {
Ok(line_content) => {
if options.print_stderr {
eprintln!("{}", line_content);
}
(options.stderr_callback)(&line_content);
output.push_str(&line_content);
output.push('\n');
}
Err(e) => {
log::trace!("Error reading stderr line: {}", e);
}
}
}
let _ = stderr_sender.send(output);
});
let status = child.wait().map_err(|e| CommandError {
message: format!("Failed to wait for command process: {}", e),
output: None,
})?;
let stdout_lines = stdout_handle
.join()
.map_err(|e| CommandError {
message: format!("Stdout thread panicked: {:?}", e),
output: None,
})
.and_then(|_| {
stdout_receiver.recv().map_err(|e| CommandError {
message: format!("Failed to receive stdout: {}", e),
output: None,
})
})?;
let stderr_lines = stderr_handle
.join()
.map_err(|e| CommandError {
message: format!("Stderr thread panicked: {:?}", e),
output: None,
})
.and_then(|_| {
stderr_receiver.recv().map_err(|e| CommandError {
message: format!("Failed to receive stderr: {}", e),
output: None,
})
})?;
Ok(CommandOutput {
stdout: stdout_lines,
stderr: stderr_lines,
status: status.into(),
})
}
/// Convenience function to run a command with default options (print to console)
pub fn run(command: &mut Command) -> Result<CommandOutput, CommandError> {
run_command(command, RunnerOptions::print_to_console())
}
/// Convenience function to run a command silently (capture output only)
pub fn run_silent(command: &mut Command) -> Result<CommandOutput, CommandError> {
run_command(command, RunnerOptions::silent())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
#[test]
fn test_simple_echo_command() {
let output = run_silent(Command::new("echo").arg("hello world")).unwrap();
assert!(output.is_success());
assert_eq!(output.stdout.trim(), "hello world");
assert!(output.stderr.is_empty());
}
#[test]
fn test_command_failure() {
let output = run_silent(Command::new("sh").args(["-c", "exit 42"])).unwrap();
assert!(!output.is_success());
assert_eq!(output.status, CommandStatus::Failed(42));
}
#[test]
fn test_command_output_format() {
let output = run_silent(Command::new("echo").arg("test")).unwrap();
let formatted = output.format_output();
assert!(formatted.contains("Stdout:"));
assert!(formatted.contains("test"));
}
#[test]
fn test_runner_options() {
let opts = RunnerOptions::print_to_console();
assert!(opts.print_stdout);
assert!(opts.print_stderr);
let opts = RunnerOptions::silent();
assert!(!opts.print_stdout);
assert!(!opts.print_stderr);
}
#[test]
fn test_command_status_from_exit_status() {
let output = run_silent(&mut Command::new("true")).unwrap();
assert_eq!(output.status, CommandStatus::Success);
let output = run_silent(&mut Command::new("false")).unwrap();
assert_eq!(output.status, CommandStatus::Failed(1));
}
#[test]
fn test_stdout_callback_receives_lines() {
use std::sync::{Arc, Mutex};
let captured = Arc::new(Mutex::new(Vec::new()));
let captured_clone = Arc::clone(&captured);
let opts = RunnerOptions::silent().with_callbacks(
move |line| captured_clone.lock().unwrap().push(line.to_string()),
|_| {},
);
run_command(Command::new("echo").arg("hello world"), opts).unwrap();
let lines = captured.lock().unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], "hello world");
}
#[test]
fn test_stderr_callback_receives_lines() {
use std::sync::{Arc, Mutex};
let captured = Arc::new(Mutex::new(Vec::new()));
let captured_clone = Arc::clone(&captured);
let opts = RunnerOptions::silent().with_callbacks(
|_| {},
move |line| captured_clone.lock().unwrap().push(line.to_string()),
);
run_command(Command::new("sh").args(["-c", "echo error >&2"]), opts).unwrap();
let lines = captured.lock().unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], "error");
}
#[test]
fn test_callback_and_capture_both_work() {
use std::sync::{Arc, Mutex};
let callback_lines = Arc::new(Mutex::new(Vec::new()));
let callback_clone = Arc::clone(&callback_lines);
let opts = RunnerOptions::silent().with_callbacks(
move |line| callback_clone.lock().unwrap().push(line.to_string()),
|_| {},
);
let output =
run_command(Command::new("printf").args(["line1\nline2\nline3\n"]), opts).unwrap();
// Verify captured output
assert_eq!(output.stdout, "line1\nline2\nline3\n");
// Verify callback received all lines
let lines = callback_lines.lock().unwrap();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "line1");
assert_eq!(lines[1], "line2");
assert_eq!(lines[2], "line3");
}
#[test]
fn test_multiline_output_capture() {
let output = run_silent(Command::new("printf").args(["line1\nline2\nline3\n"])).unwrap();
assert_eq!(output.stdout, "line1\nline2\nline3\n");
assert!(output.stderr.trim().is_empty());
}
#[test]
fn test_mixed_stdout_stderr_capture() {
let output = run_silent(Command::new("sh").args([
"-c",
"echo stdout1 && echo stderr1 >&2 && echo stdout2 && echo stderr2 >&2",
]))
.unwrap();
assert!(output.stdout.contains("stdout1"));
assert!(output.stdout.contains("stdout2"));
assert!(output.stderr.contains("stderr1"));
assert!(output.stderr.contains("stderr2"));
}
#[test]
fn test_empty_output_command() {
let output = run_silent(&mut Command::new("true")).unwrap();
assert!(output.stdout.is_empty());
assert!(output.stderr.is_empty());
assert!(output.is_success());
}
#[test]
fn test_command_output_format_with_empty_streams() {
let output = run_silent(&mut Command::new("true")).unwrap();
let formatted = output.format_output();
assert!(formatted.contains("Stdout:"));
assert!(formatted.contains("<empty>"));
assert!(formatted.contains("Stderr:"));
}
#[test]
fn test_error_contains_message_and_output() {
let error = CommandError {
message: "Test error".to_string(),
output: Some(CommandOutput {
stdout: "captured stdout".to_string(),
stderr: "captured stderr".to_string(),
status: CommandStatus::Success,
}),
};
let display = format!("{}", error);
assert!(display.contains("Test error"));
assert!(display.contains("captured stdout"));
assert!(display.contains("captured stderr"));
}
#[test]
fn test_error_without_output() {
let error = CommandError {
message: "Spawn failed".to_string(),
output: None,
};
let display = format!("{}", error);
assert!(display.contains("Spawn failed"));
assert!(!display.contains("Stdout:"));
assert!(!display.contains("Stderr:"));
}
}