ryo-executor 0.1.0

[experimental] Mutation execution engine for RYO - parallel execution, conflict detection, workspace management
Documentation
//! CoreExecutor: 基本ツール(Read, Grep, Glob, List)の実行

use super::context::ExecutionContext;
use super::traits::{Executor, ExecutorError};
use crate::decider::{Action, ActionKind, ActionResult};
use std::process::Command;
use std::time::Instant;

/// 基本ツールの Executor
/// - Read: ファイル読み取り
/// - Grep: パターン検索
/// - Glob: ファイルパターン検索
/// - List: ディレクトリ一覧
pub struct CoreExecutor;

impl CoreExecutor {
    /// 新しい CoreExecutor を作成
    pub fn new() -> Self {
        Self
    }

    /// Read アクションを実行
    fn execute_read(
        &self,
        action: &Action,
        ctx: &ExecutionContext,
    ) -> Result<ActionResult, ExecutorError> {
        let start = Instant::now();

        let path = action
            .target
            .as_ref()
            .ok_or_else(|| ExecutorError::Other("Read requires a target path".to_string()))?;

        let full_path = ctx.resolve_path(path);

        let output = Command::new("cat")
            .arg("-n")
            .arg(&full_path)
            .current_dir(&ctx.working_dir)
            .output()
            .map_err(|e| ExecutorError::CommandFailed(e.to_string()))?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);

        let duration = start.elapsed();

        if output.status.success() {
            Ok(ActionResult::success(action.clone())
                .with_output(truncate_lines(&stdout, ctx.max_output_lines))
                .with_duration(duration.as_micros() as u64))
        } else {
            Ok(ActionResult::failure(action.clone(), stderr.to_string())
                .with_duration(duration.as_micros() as u64))
        }
    }

    /// Grep アクションを実行
    fn execute_grep(
        &self,
        action: &Action,
        ctx: &ExecutionContext,
    ) -> Result<ActionResult, ExecutorError> {
        let start = Instant::now();

        let pattern = action
            .args
            .get("pattern")
            .ok_or_else(|| ExecutorError::Other("Grep requires a pattern".to_string()))?;

        let path = action.target.as_deref().unwrap_or(".");

        let mut cmd = Command::new(&ctx.rg_path);
        cmd.arg("--color=never")
            .arg("--line-number")
            .arg("--no-heading")
            .arg(pattern)
            .arg(path)
            .current_dir(&ctx.working_dir);

        let output = cmd
            .output()
            .map_err(|e| ExecutorError::CommandFailed(e.to_string()))?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        let duration = start.elapsed();

        // rg: exit 0=match, 1=no match, 2=error
        // 0と1は成功として扱う
        let success = matches!(output.status.code(), Some(0) | Some(1));

        if success {
            Ok(ActionResult::success(action.clone())
                .with_output(truncate_lines(&stdout, ctx.max_output_lines))
                .with_duration(duration.as_micros() as u64))
        } else {
            Ok(ActionResult::failure(action.clone(), stderr.to_string())
                .with_duration(duration.as_micros() as u64))
        }
    }

    /// Glob アクションを実行
    fn execute_glob(
        &self,
        action: &Action,
        ctx: &ExecutionContext,
    ) -> Result<ActionResult, ExecutorError> {
        let start = Instant::now();

        let pattern = action
            .args
            .get("pattern")
            .ok_or_else(|| ExecutorError::Other("Glob requires a pattern".to_string()))?;

        let path = action.target.as_deref().unwrap_or(".");

        // fd があれば fd、なければ find
        let output = if is_command_available(&ctx.fd_path) {
            Command::new(&ctx.fd_path)
                .arg("--type=f")
                .arg("--glob")
                .arg(pattern)
                .arg(path)
                .current_dir(&ctx.working_dir)
                .output()
        } else {
            Command::new("find")
                .arg(path)
                .arg("-type")
                .arg("f")
                .arg("-name")
                .arg(pattern)
                .current_dir(&ctx.working_dir)
                .output()
        }
        .map_err(|e| ExecutorError::CommandFailed(e.to_string()))?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        let duration = start.elapsed();

        if output.status.success() {
            Ok(ActionResult::success(action.clone())
                .with_output(truncate_lines(&stdout, ctx.max_output_lines))
                .with_duration(duration.as_micros() as u64))
        } else {
            Ok(ActionResult::failure(action.clone(), stderr.to_string())
                .with_duration(duration.as_micros() as u64))
        }
    }

    /// List アクションを実行
    fn execute_list(
        &self,
        action: &Action,
        ctx: &ExecutionContext,
    ) -> Result<ActionResult, ExecutorError> {
        let start = Instant::now();

        let path = action.target.as_deref().unwrap_or(".");

        let output = Command::new("ls")
            .arg("-la")
            .arg(path)
            .current_dir(&ctx.working_dir)
            .output()
            .map_err(|e| ExecutorError::CommandFailed(e.to_string()))?;

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        let duration = start.elapsed();

        if output.status.success() {
            Ok(ActionResult::success(action.clone())
                .with_output(truncate_lines(&stdout, ctx.max_output_lines))
                .with_duration(duration.as_micros() as u64))
        } else {
            Ok(ActionResult::failure(action.clone(), stderr.to_string())
                .with_duration(duration.as_micros() as u64))
        }
    }
}

impl Default for CoreExecutor {
    fn default() -> Self {
        Self::new()
    }
}

impl Executor for CoreExecutor {
    fn execute(
        &self,
        action: &Action,
        ctx: &ExecutionContext,
    ) -> Result<ActionResult, ExecutorError> {
        match action.kind {
            ActionKind::Read => self.execute_read(action, ctx),
            ActionKind::Grep => self.execute_grep(action, ctx),
            ActionKind::Glob => self.execute_glob(action, ctx),
            ActionKind::List => self.execute_list(action, ctx),
            // Rest/Done は何もしない
            ActionKind::Rest | ActionKind::Done => {
                Ok(ActionResult::success(action.clone()).with_output("OK"))
            }
            kind => Err(ExecutorError::UnsupportedAction(kind)),
        }
    }

    fn supported_kinds(&self) -> &[ActionKind] {
        &[
            ActionKind::Read,
            ActionKind::Grep,
            ActionKind::Glob,
            ActionKind::List,
            ActionKind::Rest,
            ActionKind::Done,
        ]
    }

    fn name(&self) -> &'static str {
        "CoreExecutor"
    }
}

/// コマンドが利用可能かチェック
fn is_command_available(cmd: &str) -> bool {
    Command::new("which")
        .arg(cmd)
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// 行数で切り詰め
fn truncate_lines(s: &str, max_lines: usize) -> String {
    let lines: Vec<&str> = s.lines().collect();
    if lines.len() <= max_lines {
        s.to_string()
    } else {
        let truncated: String = lines[..max_lines].join("\n");
        format!(
            "{}\n... ({} more lines)",
            truncated,
            lines.len() - max_lines
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_core_executor_supported_kinds() {
        let executor = CoreExecutor::new();
        assert!(executor.can_execute(&Action::read("test.rs")));
        assert!(executor.can_execute(&Action::grep("pattern")));
        assert!(executor.can_execute(&Action::glob("*.rs")));
        assert!(!executor.can_execute(&Action::mutate("AddFn", "target")));
    }

    #[test]
    fn test_truncate_lines() {
        let s = "line1\nline2\nline3\nline4\nline5";

        // 切り詰めなし
        let result = truncate_lines(s, 10);
        assert_eq!(result, s);

        // 切り詰めあり
        let result = truncate_lines(s, 3);
        assert!(result.contains("line1"));
        assert!(result.contains("line3"));
        assert!(result.contains("2 more lines"));
    }
}