prx 0.5.9

Praxis — agent-native Unix tools. Single binary replacing grep, cat, find, sed, diff for AI coding agents.
use std::io::BufRead;

use clap::Args;
use serde::Serialize;

use crate::output::AgError;

#[derive(Args)]
pub struct BatchArgs {}

#[derive(Serialize)]
struct BatchResult {
    #[serde(skip_serializing_if = "Option::is_none")]
    id: Option<String>,
    status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    data: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<String>,
}

pub fn run(_args: BatchArgs) -> Result<serde_json::Value, AgError> {
    let stdin = std::io::stdin();
    let mut results = Vec::new();

    for line in stdin.lock().lines() {
        let line = match line {
            Ok(l) => l,
            Err(e) => {
                results.push(BatchResult {
                    id: None,
                    status: "error".to_string(),
                    data: None,
                    error: Some(format!("read error: {e}")),
                });
                continue;
            }
        };

        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }

        let cmd: serde_json::Value = match serde_json::from_str(trimmed) {
            Ok(v) => v,
            Err(e) => {
                results.push(BatchResult {
                    id: None,
                    status: "error".to_string(),
                    data: None,
                    error: Some(format!("invalid JSON: {e}")),
                });
                continue;
            }
        };

        let id = cmd.get("id").and_then(|v| v.as_str()).map(String::from);
        let result = dispatch_command(&cmd);

        match result {
            Ok(data) => results.push(BatchResult {
                id,
                status: "ok".to_string(),
                data: Some(data),
                error: None,
            }),
            Err(e) => results.push(BatchResult {
                id,
                status: "error".to_string(),
                data: None,
                error: Some(e.to_string()),
            }),
        }
    }

    serde_json::to_value(serde_json::json!({"results": results})).map_err(|e| AgError::Internal {
        message: e.to_string(),
    })
}

fn dispatch_command(cmd: &serde_json::Value) -> Result<serde_json::Value, AgError> {
    let command =
        cmd.get("cmd")
            .and_then(|v| v.as_str())
            .ok_or_else(|| AgError::InvalidArgument {
                flag: "cmd".to_string(),
                message: "missing 'cmd' field".to_string(),
            })?;

    match command {
        "search" => {
            let query = cmd.get("query").and_then(|v| v.as_str()).unwrap_or("");
            let path = cmd.get("path").and_then(|v| v.as_str()).unwrap_or(".");
            let top_k = cmd.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
            let budget = cmd
                .get("budget")
                .and_then(|v| v.as_u64())
                .map(|v| v as usize);
            super::search::run(super::search::SearchArgs {
                query: query.to_string(),
                path: path.to_string(),
                literal: false,
                semantic: false,
                structural: false,
                top_k,
                budget,
                context: None,
                exists: false,
                continue_token: None,
                alpha: None,
            })
        }
        "read" => {
            let file = cmd
                .get("file")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let skeleton = cmd
                .get("skeleton")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);
            let hash = cmd.get("hash").and_then(|v| v.as_bool()).unwrap_or(false);
            let outline = cmd
                .get("outline")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);
            super::read::run(super::read::ReadArgs {
                file,
                lines: None,
                snap: None,
                skeleton,
                outline,
                hash,
                budget: cmd
                    .get("budget")
                    .and_then(|v| v.as_u64())
                    .map(|v| v as usize),
                meta: false,
                if_changed: cmd
                    .get("if_changed")
                    .and_then(|v| v.as_str())
                    .map(String::from),
                mode: cmd.get("mode").and_then(|v| v.as_str()).map(String::from),
            })
        }
        "exists" => {
            let pattern = cmd
                .get("pattern")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let path = cmd
                .get("path")
                .and_then(|v| v.as_str())
                .unwrap_or(".")
                .to_string();
            super::exists::run(super::exists::ExistsArgs { pattern, path })
        }
        "find" => {
            let path = cmd
                .get("path")
                .and_then(|v| v.as_str())
                .unwrap_or(".")
                .to_string();
            super::find::run(super::find::FindArgs {
                path,
                pattern: cmd
                    .get("pattern")
                    .and_then(|v| v.as_str())
                    .map(String::from),
                depth: cmd
                    .get("depth")
                    .and_then(|v| v.as_u64())
                    .map(|v| v as usize),
                related_to: None,
                changed_since: None,
                outline: false,
                tree: false,
                flat: false,
                budget: cmd
                    .get("budget")
                    .and_then(|v| v.as_u64())
                    .map(|v| v as usize),
            })
        }
        "outline" => {
            let path = cmd
                .get("path")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            super::outline::run(super::outline::OutlineArgs {
                path,
                depth: None,
                kind: None,
            })
        }
        _ => Err(AgError::InvalidArgument {
            flag: "cmd".to_string(),
            message: format!("unknown command: {command}"),
        }),
    }
}

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

    #[test]
    fn dispatch_unknown_command() {
        let cmd = serde_json::json!({"cmd": "unknown_xyz"});
        assert!(dispatch_command(&cmd).is_err());
    }

    #[test]
    fn dispatch_missing_cmd() {
        let cmd = serde_json::json!({"query": "test"});
        assert!(dispatch_command(&cmd).is_err());
    }

    #[test]
    fn dispatch_find() {
        let dir = tempfile::TempDir::new().unwrap();
        std::fs::write(dir.path().join("test.rs"), "fn main() {}").unwrap();
        let cmd = serde_json::json!({"cmd": "find", "path": dir.path().to_str().unwrap()});
        let result = dispatch_command(&cmd).unwrap();
        assert!(result["stats"]["total_files"].as_u64().unwrap() >= 1);
    }
}