use std::io::{self, BufRead, Write};
use serde::Serialize;
use tirith_core::engine::{self, AnalysisContext};
use tirith_core::extract::ScanContext;
use tirith_core::safe_command::{self, SafeSuggestion};
use tirith_core::tokenize::ShellType;
use tirith_core::verdict::Action;
pub fn run(command_parts: &[String], shell: &str, non_interactive: bool, json: bool) -> i32 {
let cmd = command_parts.join(" ");
if cmd.trim().is_empty() {
if json || non_interactive {
if !emit_no_findings_envelope(&FixEnvelope {
applied: false,
reason: "no_findings",
verdict: "allow",
command: "",
}) {
return 2;
}
} else {
println!("no fix needed");
}
return 0;
}
let shell_type = match shell.parse::<ShellType>() {
Ok(s) => s,
Err(_) => {
eprintln!("tirith fix: warning: unknown shell '{shell}', falling back to posix");
ShellType::Posix
}
};
let ctx = AnalysisContext {
input: cmd.clone(),
shell: shell_type,
scan_context: ScanContext::Exec,
raw_bytes: None,
interactive: false,
cwd: std::env::current_dir()
.ok()
.map(|p| p.display().to_string()),
file_path: None,
repo_root: None,
is_config_override: false,
clipboard_html: None,
card_ref: None,
clipboard_source: tirith_core::clipboard::ClipboardSourceState::Unread,
};
let verdict = engine::analyze(&ctx);
if verdict.action == Action::Allow {
if json || non_interactive {
if !emit_no_findings_envelope(&FixEnvelope {
applied: false,
reason: "no_findings",
verdict: action_str(verdict.action),
command: &cmd,
}) {
return 2;
}
} else {
println!("no fix needed");
}
return 0;
}
let suggestions = safe_command::suggest(&cmd, shell_type, &verdict);
if json || non_interactive {
let has_rewrite = suggestions.iter().any(|s| s.safe_command.is_some());
if !emit_suggestions_array(&suggestions) {
return 2;
}
return if has_rewrite { 2 } else { 1 };
}
let (with_rewrite, guidance_only): (Vec<&SafeSuggestion>, Vec<&SafeSuggestion>) =
suggestions.iter().partition(|s| s.safe_command.is_some());
if with_rewrite.is_empty() {
eprintln!(
"tirith fix: no mechanical rewrite available — see guidance below ({} finding(s))",
verdict.findings.len()
);
for s in &guidance_only {
eprintln!(" rule={}", s.rule_id);
eprintln!(" rationale: {}", s.rationale);
eprintln!(" remediation: {}", s.remediation);
}
return 1;
}
if !is_tty_pair() {
eprintln!(
"tirith fix: stdin/stdout is not a TTY — re-run with --non-interactive --json \
to capture suggestions, or attach a TTY to apply one."
);
for (i, s) in with_rewrite.iter().enumerate() {
eprintln!(
" [{}] rule={} rewrite={} — {}",
i + 1,
s.rule_id,
s.safe_command.as_deref().unwrap_or(""),
s.rationale
);
}
return 2;
}
eprintln!("tirith fix: {} finding(s) in:", verdict.findings.len());
eprintln!(" {cmd}");
eprintln!("verdict: {}", action_str(verdict.action));
eprintln!();
eprintln!("Suggestions:");
for (i, s) in with_rewrite.iter().enumerate() {
let sc = s.safe_command.as_deref().unwrap_or("");
eprintln!(
" [{}] rule={} rewrite={} — {}",
i + 1,
s.rule_id,
sc,
s.rationale
);
}
if !guidance_only.is_empty() {
eprintln!();
eprintln!("Guidance (no mechanical rewrite):");
for s in &guidance_only {
eprintln!(" rule={} — {}", s.rule_id, s.remediation);
}
}
let n = with_rewrite.len();
eprint!("\nApply (1-{n})? [n] ");
let _ = io::stderr().flush();
let stdin = io::stdin();
let mut handle = stdin.lock();
let mut buf = String::new();
match handle.read_line(&mut buf) {
Ok(0) => {
eprintln!("tirith fix: no input (EOF) — declining to apply");
2
}
Err(e) => {
eprintln!("tirith fix: stdin read failed: {e}");
2
}
Ok(_) => {
let trimmed = buf.trim();
if trimmed.is_empty() || matches!(trimmed, "n" | "N" | "no" | "No") {
eprintln!("tirith fix: declined");
return 2;
}
match trimmed.parse::<usize>() {
Ok(choice) if choice >= 1 && choice <= n => {
let sc = with_rewrite[choice - 1]
.safe_command
.as_deref()
.expect("partition guarantees safe_command is Some");
println!("{sc}");
0
}
_ => {
eprintln!("tirith fix: invalid choice '{trimmed}' — declined");
2
}
}
}
}
}
fn action_str(a: Action) -> &'static str {
match a {
Action::Allow => "allow",
Action::Warn | Action::WarnAck => "warn",
Action::Block => "block",
}
}
fn is_tty_pair() -> bool {
is_terminal::is_terminal(std::io::stdin()) && is_terminal::is_terminal(std::io::stderr())
}
#[derive(Serialize)]
struct FixEnvelope<'a> {
applied: bool,
reason: &'a str,
verdict: &'a str,
command: &'a str,
}
fn emit_no_findings_envelope(envelope: &FixEnvelope<'_>) -> bool {
let mut out = io::stdout().lock();
if serde_json::to_writer_pretty(&mut out, envelope).is_err() || writeln!(out).is_err() {
eprintln!("tirith fix: failed to write JSON output");
return false;
}
true
}
fn emit_suggestions_array(suggestions: &[SafeSuggestion]) -> bool {
let mut out = io::stdout().lock();
if serde_json::to_writer_pretty(&mut out, suggestions).is_err() || writeln!(out).is_err() {
eprintln!("tirith fix: failed to write JSON output");
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_str_collapses_warn_ack() {
assert_eq!(action_str(Action::Allow), "allow");
assert_eq!(action_str(Action::Warn), "warn");
assert_eq!(action_str(Action::WarnAck), "warn");
assert_eq!(action_str(Action::Block), "block");
}
#[test]
fn no_findings_envelope_serializes_with_stable_keys() {
let envelope = FixEnvelope {
applied: false,
reason: "no_findings",
verdict: "allow",
command: "ls",
};
let json = serde_json::to_value(&envelope).unwrap();
assert_eq!(json["applied"], serde_json::Value::Bool(false));
assert_eq!(json["reason"], "no_findings");
assert_eq!(json["verdict"], "allow");
assert_eq!(json["command"], "ls");
}
}