use crate::error::{Error, Result};
use crate::recursive::executor::{CodeExecutor, ExecutionResult};
use crate::recursive::tool::Tool;
use crate::recursive::validate::{Score, Validate};
use smallvec::SmallVec;
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[inline]
pub fn cli(command: &str) -> Cli {
Cli::new(command)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CliCapture {
pub stage: String,
pub command: String,
pub stdout: String,
pub stderr: String,
pub success: bool,
pub exit_code: Option<i32>,
pub duration_ms: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CliError {
pub severity: &'static str,
pub message: String,
pub line: Option<u32>,
}
impl CliCapture {
pub fn combined(&self) -> String {
if self.stderr.is_empty() {
self.stdout.clone()
} else if self.stdout.is_empty() {
self.stderr.clone()
} else {
format!("{}\n{}", self.stdout, self.stderr)
}
}
pub fn errors(&self) -> Vec<&str> {
self.stderr
.lines()
.filter(|l| !l.trim().is_empty())
.collect()
}
pub fn extract_errors(&self) -> SmallVec<[CliError; 4]> {
let mut errors = SmallVec::new();
let source = if self.stderr.trim().is_empty() {
&self.stdout
} else {
&self.stderr
};
for line in source.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let lower = trimmed.to_lowercase();
let severity = if lower.starts_with("error") || lower.contains(": error") {
"error"
} else if lower.starts_with("warning") || lower.contains(": warning") {
"warning"
} else if lower.starts_with("note") || lower.contains(": note") {
"note"
} else {
continue;
};
let line_num = trimmed
.split(':')
.nth(1)
.and_then(|s| s.trim().parse::<u32>().ok());
errors.push(CliError {
severity,
message: trimmed.to_string(),
line: line_num,
});
}
errors
}
}
#[derive(Clone)]
struct Stage {
command: String,
args: SmallVec<[String; 8]>,
weight: f64,
required: bool,
}
impl Stage {
fn new(command: &str) -> Self {
Self {
command: command.to_string(),
args: SmallVec::new(),
weight: 1.0,
required: false,
}
}
}
pub struct Cli {
stages: SmallVec<[Stage; 4]>,
extension: String,
inherit_env: bool,
env_vars: SmallVec<[(String, String); 8]>,
env_passthrough: SmallVec<[String; 8]>,
workdir: Option<PathBuf>,
timeout: Duration,
capture: bool,
use_stdin: bool,
strip_fences: bool,
captures: Option<std::sync::Arc<std::sync::Mutex<SmallVec<[CliCapture; 4]>>>>,
}
impl Clone for Cli {
fn clone(&self) -> Self {
Self {
stages: self.stages.clone(),
extension: self.extension.clone(),
inherit_env: self.inherit_env,
env_vars: self.env_vars.clone(),
env_passthrough: self.env_passthrough.clone(),
workdir: self.workdir.clone(),
timeout: self.timeout,
capture: self.capture,
use_stdin: self.use_stdin,
strip_fences: self.strip_fences,
captures: self.captures.clone(),
}
}
}
impl Cli {
pub fn new(command: &str) -> Self {
let mut stages = SmallVec::new();
stages.push(Stage::new(command));
Self {
stages,
extension: "txt".to_string(),
inherit_env: true,
env_vars: SmallVec::new(),
env_passthrough: SmallVec::new(),
workdir: None,
timeout: Duration::from_secs(120),
capture: false,
use_stdin: false,
strip_fences: false,
captures: None,
}
}
pub fn arg(mut self, arg: &str) -> Self {
if let Some(stage) = self.stages.last_mut() {
stage.args.push(arg.to_string());
}
self
}
pub fn args(mut self, args: &[&str]) -> Self {
if let Some(stage) = self.stages.last_mut() {
for arg in args {
stage.args.push((*arg).to_string());
}
}
self
}
pub fn weight(mut self, w: f64) -> Self {
if let Some(stage) = self.stages.last_mut() {
stage.weight = w;
}
self
}
pub fn required(mut self) -> Self {
if let Some(stage) = self.stages.last_mut() {
stage.required = true;
}
self
}
pub fn then(mut self, command: &str) -> Self {
self.stages.push(Stage::new(command));
self
}
pub fn ext(mut self, extension: &str) -> Self {
self.extension = extension.to_string();
self
}
pub fn env(mut self, key: &str, value: &str) -> Self {
self.env_vars.push((key.to_string(), value.to_string()));
self
}
pub fn env_from(mut self, key: &str) -> Self {
self.env_passthrough.push(key.to_string());
self
}
pub fn inherit_env(mut self, inherit: bool) -> Self {
self.inherit_env = inherit;
self
}
pub fn workdir(mut self, path: &str) -> Self {
self.workdir = Some(PathBuf::from(path));
self
}
pub fn timeout(mut self, secs: u64) -> Self {
self.timeout = Duration::from_secs(secs);
self
}
pub fn capture(mut self) -> Self {
self.capture = true;
if self.captures.is_none() {
self.captures = Some(std::sync::Arc::new(std::sync::Mutex::new(SmallVec::new())));
}
self
}
pub fn stdin(mut self) -> Self {
self.use_stdin = true;
self
}
pub fn strip_fences(mut self) -> Self {
self.strip_fences = true;
self
}
pub fn get_captures(&self) -> SmallVec<[CliCapture; 4]> {
match &self.captures {
Some(arc) => arc.lock().expect("CLI captures lock poisoned").clone(),
None => SmallVec::new(),
}
}
pub fn validate_capturing(&self, text: &str) -> (Score<'static>, SmallVec<[CliCapture; 4]>) {
let text = if self.strip_fences {
use crate::recursive::rewrite::extract_code;
extract_code(text, &self.extension)
.map(|s| s.to_string())
.unwrap_or_else(|| text.to_string())
} else {
text.to_string()
};
let text: &str = &text;
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join(format!("kkachi_validate.{}", self.extension));
if !self.use_stdin {
if let Err(e) = std::fs::write(&temp_file, text) {
return (
Score::with_feedback(0.0, format!("Failed to write temp file: {}", e)),
SmallVec::new(),
);
}
}
let mut total_weight = 0.0f64;
let mut weighted_score = 0.0f64;
let mut errors: Vec<String> = Vec::new();
let mut all_captures: SmallVec<[CliCapture; 4]> = SmallVec::new();
let mut required_failed = false;
for stage in &self.stages {
let capture = match self.execute_stage(stage, text, &temp_file) {
Ok(c) => c,
Err(e) => {
let _ = std::fs::remove_file(&temp_file);
return (Score::with_feedback(0.0, e.to_string()), SmallVec::new());
}
};
let stage_score = if capture.success { 1.0 } else { 0.0 };
weighted_score += stage_score * stage.weight;
total_weight += stage.weight;
if !capture.success {
if stage.required {
required_failed = true;
}
let error_source = if capture.stderr.trim().is_empty() {
&capture.stdout
} else {
&capture.stderr
};
for line in error_source.lines().filter(|l| !l.trim().is_empty()) {
errors.push(line.to_string());
}
}
all_captures.push(capture);
}
let _ = std::fs::remove_file(&temp_file);
let final_score = if required_failed {
0.0
} else if total_weight > 0.0 {
weighted_score / total_weight
} else {
1.0
};
let score = if errors.is_empty() {
Score::new(final_score)
} else {
Score::with_feedback(final_score, errors.join("\n"))
};
(score, all_captures)
}
pub fn push_arg(&mut self, arg: &str) {
if let Some(stage) = self.stages.last_mut() {
stage.args.push(arg.to_string());
}
}
pub fn push_args(&mut self, args: &[&str]) {
if let Some(stage) = self.stages.last_mut() {
for arg in args {
stage.args.push((*arg).to_string());
}
}
}
pub fn set_weight(&mut self, w: f64) {
if let Some(stage) = self.stages.last_mut() {
stage.weight = w;
}
}
pub fn set_required(&mut self) {
if let Some(stage) = self.stages.last_mut() {
stage.required = true;
}
}
pub fn push_stage(&mut self, command: &str) {
self.stages.push(Stage::new(command));
}
pub fn set_ext(&mut self, extension: &str) {
self.extension = extension.to_string();
}
pub fn push_env(&mut self, key: &str, value: &str) {
self.env_vars.push((key.to_string(), value.to_string()));
}
pub fn push_env_from(&mut self, key: &str) {
self.env_passthrough.push(key.to_string());
}
pub fn set_workdir(&mut self, path: &str) {
self.workdir = Some(PathBuf::from(path));
}
pub fn set_timeout(&mut self, secs: u64) {
self.timeout = Duration::from_secs(secs);
}
pub fn set_capture(&mut self) {
self.capture = true;
if self.captures.is_none() {
self.captures = Some(std::sync::Arc::new(std::sync::Mutex::new(SmallVec::new())));
}
}
pub fn set_strip_fences(&mut self) {
self.strip_fences = true;
}
fn execute_stage(
&self,
stage: &Stage,
content: &str,
temp_file: &std::path::Path,
) -> Result<CliCapture> {
let start = Instant::now();
let mut cmd = Command::new(&stage.command);
for arg in &stage.args {
cmd.arg(arg);
}
if !self.use_stdin {
cmd.arg(temp_file);
}
if !self.inherit_env {
cmd.env_clear();
}
for (key, value) in &self.env_vars {
cmd.env(key, value);
}
for key in &self.env_passthrough {
if let Ok(value) = std::env::var(key) {
cmd.env(key, value);
}
}
if let Some(ref dir) = self.workdir {
cmd.current_dir(dir);
}
if self.use_stdin {
cmd.stdin(Stdio::piped());
}
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let output = if self.use_stdin {
let mut child = cmd
.spawn()
.map_err(|e| Error::Other(format!("Failed to spawn '{}': {}", stage.command, e)))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(content.as_bytes())
.map_err(|e| Error::Other(format!("Failed to write to stdin: {}", e)))?;
}
child.wait_with_output().map_err(|e| {
Error::Other(format!("Failed to wait for '{}': {}", stage.command, e))
})?
} else {
cmd.output().map_err(|e| {
Error::Other(format!("Failed to execute '{}': {}", stage.command, e))
})?
};
let duration = start.elapsed();
Ok(CliCapture {
stage: stage.command.clone(),
command: format!("{} {}", stage.command, stage.args.join(" ")),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
success: output.status.success(),
exit_code: output.status.code(),
duration_ms: duration.as_millis() as u64,
})
}
}
impl Validate for Cli {
fn validate(&self, text: &str) -> Score<'static> {
let text = if self.strip_fences {
use crate::recursive::rewrite::extract_code;
extract_code(text, &self.extension)
.map(|s| s.to_string())
.unwrap_or_else(|| text.to_string())
} else {
text.to_string()
};
let text: &str = &text;
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join(format!("kkachi_validate.{}", self.extension));
if !self.use_stdin {
if let Err(e) = std::fs::write(&temp_file, text) {
return Score::with_feedback(0.0, format!("Failed to write temp file: {}", e));
}
}
let mut total_weight = 0.0f64;
let mut weighted_score = 0.0f64;
let mut errors: Vec<String> = Vec::new();
let mut all_captures: SmallVec<[CliCapture; 4]> = SmallVec::new();
let mut required_failed = false;
for stage in &self.stages {
let capture = match self.execute_stage(stage, text, &temp_file) {
Ok(c) => c,
Err(e) => {
let _ = std::fs::remove_file(&temp_file);
return Score::with_feedback(0.0, e.to_string());
}
};
let stage_score = if capture.success { 1.0 } else { 0.0 };
weighted_score += stage_score * stage.weight;
total_weight += stage.weight;
if !capture.success {
if stage.required {
required_failed = true;
}
let error_source = if capture.stderr.trim().is_empty() {
&capture.stdout
} else {
&capture.stderr
};
for line in error_source.lines().filter(|l| !l.trim().is_empty()) {
errors.push(line.to_string());
}
}
if self.capture {
all_captures.push(capture);
}
}
let _ = std::fs::remove_file(&temp_file);
if self.capture {
if let Some(ref arc) = self.captures {
arc.lock()
.expect("CLI captures lock poisoned")
.extend(all_captures);
}
}
let final_score = if required_failed {
0.0
} else if total_weight > 0.0 {
weighted_score / total_weight
} else {
1.0
};
if errors.is_empty() {
Score::new(final_score)
} else {
Score::with_feedback(final_score, errors.join("\n"))
}
}
fn name(&self) -> &'static str {
"cli"
}
}
impl CodeExecutor for Cli {
type ExecuteFut<'a>
= std::future::Ready<ExecutionResult>
where
Self: 'a;
fn language(&self) -> &str {
self.stages
.first()
.map(|s| s.command.as_str())
.unwrap_or("bash")
}
fn extension(&self) -> &str {
&self.extension
}
fn execute<'a>(&'a self, code: &'a str) -> Self::ExecuteFut<'a> {
std::future::ready(self.execute_code(code))
}
}
impl Cli {
fn execute_code(&self, code: &str) -> ExecutionResult {
let start = std::time::Instant::now();
let stage = match self.stages.first() {
Some(s) => s,
None => {
return ExecutionResult {
stdout: String::new(),
stderr: "No CLI stages configured".to_string(),
success: false,
exit_code: None,
duration: start.elapsed(),
};
}
};
let temp_file = if !self.use_stdin {
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join(format!("kkachi_exec.{}", self.extension));
if let Err(e) = std::fs::write(&file_path, code) {
return ExecutionResult {
stdout: String::new(),
stderr: format!("Failed to write temp file: {}", e),
success: false,
exit_code: None,
duration: start.elapsed(),
};
}
Some(file_path)
} else {
None
};
let mut cmd = Command::new(&stage.command);
for arg in &stage.args {
cmd.arg(arg);
}
if let Some(ref file) = temp_file {
cmd.arg(file);
} else {
cmd.stdin(Stdio::piped());
}
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
if let Some(ref dir) = self.workdir {
cmd.current_dir(dir);
}
if !self.inherit_env {
cmd.env_clear();
}
for (key, value) in &self.env_vars {
cmd.env(key, value);
}
for key in &self.env_passthrough {
if let Ok(value) = std::env::var(key) {
cmd.env(key, value);
}
}
let result = if self.use_stdin {
match cmd.spawn() {
Ok(mut child) => {
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(code.as_bytes());
}
match child.wait_with_output() {
Ok(output) => ExecutionResult {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
success: output.status.success(),
exit_code: output.status.code(),
duration: start.elapsed(),
},
Err(e) => ExecutionResult {
stdout: String::new(),
stderr: format!("Failed to wait for '{}': {}", stage.command, e),
success: false,
exit_code: None,
duration: start.elapsed(),
},
}
}
Err(e) => ExecutionResult {
stdout: String::new(),
stderr: format!("Failed to spawn '{}': {}", stage.command, e),
success: false,
exit_code: None,
duration: start.elapsed(),
},
}
} else {
match cmd.output() {
Ok(output) => ExecutionResult {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
success: output.status.success(),
exit_code: output.status.code(),
duration: start.elapsed(),
},
Err(e) => ExecutionResult {
stdout: String::new(),
stderr: format!("Failed to execute '{}': {}", stage.command, e),
success: false,
exit_code: None,
duration: start.elapsed(),
},
}
};
if let Some(file) = temp_file {
let _ = std::fs::remove_file(file);
}
result
}
}
pub struct CliTool {
cli: Cli,
tool_name: &'static str,
tool_description: &'static str,
}
impl Cli {
pub fn as_tool(mut self, name: &'static str, description: &'static str) -> CliTool {
self.use_stdin = true;
CliTool {
cli: self,
tool_name: name,
tool_description: description,
}
}
}
impl Tool for CliTool {
type ExecuteFut<'a>
= std::future::Ready<Result<String>>
where
Self: 'a;
fn name(&self) -> &str {
self.tool_name
}
fn description(&self) -> &str {
self.tool_description
}
fn execute<'a>(&'a self, input: &'a str) -> Self::ExecuteFut<'a> {
std::future::ready(self.run(input))
}
}
impl CliTool {
fn run(&self, input: &str) -> Result<String> {
let stage = self
.cli
.stages
.first()
.ok_or_else(|| Error::Other("No CLI stages configured".to_string()))?;
let capture = self
.cli
.execute_stage(stage, input, std::path::Path::new(""))?;
if capture.success {
Ok(capture.stdout.trim().to_string())
} else {
let err = if capture.stderr.is_empty() {
format!(
"Command '{}' failed with exit code {:?}",
capture.command, capture.exit_code
)
} else {
capture.stderr.trim().to_string()
};
Err(Error::Other(err))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cli_builder() {
let c = cli("echo").arg("hello").arg("world").ext("txt");
assert_eq!(c.stages.len(), 1);
assert_eq!(c.stages[0].command, "echo");
assert_eq!(c.stages[0].args.len(), 2);
assert_eq!(c.extension, "txt");
}
#[test]
#[cfg(unix)]
fn test_cli_multi_stage() {
let c = cli("echo")
.arg("1")
.then("echo")
.arg("2")
.required()
.then("echo")
.arg("3");
assert_eq!(c.stages.len(), 3);
assert!(c.stages[1].required);
}
#[test]
fn test_cli_env() {
let c = cli("echo").env("FOO", "bar").env_from("PATH");
assert_eq!(c.env_vars.len(), 1);
assert_eq!(c.env_passthrough.len(), 1);
}
#[test]
#[ignore] fn test_cli_validate_echo() {
let v = cli("echo").arg("ok").stdin();
let score = v.validate("test input");
assert!(score.is_perfect());
}
#[test]
#[cfg(unix)]
fn test_cli_validate_false() {
let v = cli("false");
let score = v.validate("anything");
assert!(!score.is_perfect());
}
#[test]
#[cfg(unix)]
fn test_cli_capture() {
let v = cli("cat").stdin().capture();
let _ = v.validate("captured");
let captures = v.get_captures();
assert_eq!(captures.len(), 1);
assert!(captures[0].stdout.contains("captured"));
}
#[test]
fn test_cli_capture_errors() {
let capture = CliCapture {
stage: "test".to_string(),
command: "test".to_string(),
stdout: String::new(),
stderr: "error: something failed\nwarning: be careful\n".to_string(),
success: false,
exit_code: Some(1),
duration_ms: 100,
};
let errors = capture.errors();
assert_eq!(errors.len(), 2);
assert!(errors[0].contains("error"));
}
#[test]
#[cfg(unix)]
fn test_cli_weighted_stages() {
let c = cli("true").weight(0.3).then("true").weight(0.7);
let score = c.validate("test");
assert!(score.is_perfect());
}
#[test]
#[cfg(unix)]
fn test_cli_as_tool() {
let t = cli("cat").stdin().as_tool("cat_tool", "Reads stdin");
assert_eq!(Tool::name(&t), "cat_tool");
assert_eq!(Tool::description(&t), "Reads stdin");
let result = futures::executor::block_on(Tool::execute(&t, "hello"));
assert!(result.is_ok());
assert!(result.unwrap().contains("hello"));
}
#[test]
#[cfg(unix)]
fn test_cli_tool_failure() {
let t = cli("false").stdin().as_tool("fail", "Always fails");
let result = futures::executor::block_on(Tool::execute(&t, "input"));
assert!(result.is_err());
}
}