prx 0.5.9

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

#[derive(thiserror::Error, Debug)]
#[allow(dead_code)]
pub enum AgError {
    #[error("file not found: {path}")]
    FileNotFound { path: String },

    #[error("parse error in {path}: {message}")]
    ParseError {
        path: String,
        language: String,
        message: String,
    },

    #[error("invalid argument {flag}: {message}")]
    InvalidArgument { flag: String, message: String },

    #[error("index corrupted at {path}: {reason}")]
    IndexCorrupted { path: String, reason: String },

    #[error("git error: {message}")]
    GitError { message: String },

    #[error(transparent)]
    Io(#[from] std::io::Error),

    #[error("internal error: {message}")]
    Internal { message: String },
}

impl AgError {
    fn code(&self) -> &str {
        match self {
            Self::FileNotFound { .. } => "file_not_found",
            Self::ParseError { .. } => "parse_error",
            Self::InvalidArgument { .. } => "invalid_argument",
            Self::IndexCorrupted { .. } => "index_corrupted",
            Self::GitError { .. } => "git_error",
            Self::Io(_) => "io_error",
            Self::Internal { .. } => "internal_error",
        }
    }

    fn suggestion(&self) -> Option<&str> {
        match self {
            Self::FileNotFound { .. } => {
                Some("Check the file path. Use `prx find` to discover files.")
            }
            Self::ParseError { .. } => {
                Some("Verify the file is valid source code for the detected language.")
            }
            Self::IndexCorrupted { .. } => {
                Some("Run `prx index --rebuild` to regenerate the index.")
            }
            _ => None,
        }
    }
}

#[derive(Serialize)]
struct Envelope<T: Serialize> {
    version: String,
    command: String,
    status: String,
    tokens: usize,
    data: T,
}

#[derive(Serialize)]
struct ErrorEnvelope {
    version: String,
    command: String,
    status: String,
    error: ErrorDetail,
}

#[derive(Serialize)]
struct ErrorDetail {
    code: String,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    suggestion: Option<String>,
}

pub fn write_envelope(command: &str, data: serde_json::Value, plain: bool) {
    if plain {
        write_plain(command, &data);
        return;
    }

    let json_data = serde_json::to_string(&data).unwrap_or_default();
    let tokens = json_data.len() / 4;

    let envelope = Envelope {
        version: env!("CARGO_PKG_VERSION").to_string(),
        command: command.to_string(),
        status: "ok".to_string(),
        tokens,
        data,
    };

    let mut stdout = std::io::stdout().lock();
    let _ = serde_json::to_writer(&mut stdout, &envelope);
    let _ = writeln!(stdout);
}

pub fn write_error(command: &str, error: &AgError, plain: bool) {
    if plain {
        eprintln!("error: {error}");
        std::process::exit(1);
    }

    let envelope = ErrorEnvelope {
        version: env!("CARGO_PKG_VERSION").to_string(),
        command: command.to_string(),
        status: "error".to_string(),
        error: ErrorDetail {
            code: error.code().to_string(),
            message: error.to_string(),
            suggestion: error.suggestion().map(String::from),
        },
    };

    let mut stdout = std::io::stdout().lock();
    let _ = serde_json::to_writer(&mut stdout, &envelope);
    let _ = writeln!(stdout);
    std::process::exit(1);
}

fn write_plain(_command: &str, data: &serde_json::Value) {
    let mut stdout = std::io::stdout().lock();
    let _ = serde_json::to_writer_pretty(&mut stdout, data);
    let _ = writeln!(stdout);
}

pub fn build_fallback_envelope(command: &str, data: serde_json::Value) -> serde_json::Value {
    let json_data = serde_json::to_string(&data).unwrap_or_default();
    let tokens = json_data.len() / 4;

    serde_json::json!({
        "version": env!("CARGO_PKG_VERSION"),
        "command": command,
        "status": "ok",
        "fallback": true,
        "tokens": tokens,
        "data": data,
    })
}

pub fn write_fallback_envelope(command: &str, data: serde_json::Value, plain: bool) {
    if plain {
        write_plain(command, &data);
        return;
    }

    let envelope = build_fallback_envelope(command, data);
    let mut stdout = std::io::stdout().lock();
    let _ = serde_json::to_writer(&mut stdout, &envelope);
    let _ = writeln!(stdout);
}

pub fn build_envelope(command: &str, data: serde_json::Value) -> serde_json::Value {
    let json_data = serde_json::to_string(&data).unwrap_or_default();
    let tokens = json_data.len() / 4;

    serde_json::to_value(Envelope {
        version: env!("CARGO_PKG_VERSION").to_string(),
        command: command.to_string(),
        status: "ok".to_string(),
        tokens,
        data,
    })
    .unwrap_or_default()
}

pub fn build_error_envelope(command: &str, error: &AgError) -> serde_json::Value {
    serde_json::to_value(ErrorEnvelope {
        version: env!("CARGO_PKG_VERSION").to_string(),
        command: command.to_string(),
        status: "error".to_string(),
        error: ErrorDetail {
            code: error.code().to_string(),
            message: error.to_string(),
            suggestion: error.suggestion().map(String::from),
        },
    })
    .unwrap_or_default()
}

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

    #[test]
    fn envelope_has_version() {
        let env = build_envelope("search", serde_json::json!({"test": true}));
        assert_eq!(env["version"], env!("CARGO_PKG_VERSION"));
        assert_eq!(env["command"], "search");
        assert_eq!(env["status"], "ok");
    }

    #[test]
    fn envelope_token_count() {
        let data = serde_json::json!({"hello": "world"});
        let env = build_envelope("read", data);
        assert!(env["tokens"].as_u64().unwrap() > 0);
    }

    #[test]
    fn error_envelope_has_code() {
        let err = AgError::FileNotFound {
            path: "/missing.rs".to_string(),
        };
        let env = build_error_envelope("read", &err);
        assert_eq!(env["status"], "error");
        assert_eq!(env["error"]["code"], "file_not_found");
        assert!(
            env["error"]["message"]
                .as_str()
                .unwrap()
                .contains("/missing.rs")
        );
    }

    #[test]
    fn error_suggestion_present_for_file_not_found() {
        let err = AgError::FileNotFound {
            path: "x".to_string(),
        };
        assert!(err.suggestion().is_some());
    }

    #[test]
    fn error_suggestion_absent_for_io() {
        let err = AgError::Io(std::io::Error::other("test"));
        assert!(err.suggestion().is_none());
    }

    #[test]
    fn all_error_codes_are_distinct() {
        let errors: Vec<AgError> = vec![
            AgError::FileNotFound { path: "x".into() },
            AgError::ParseError {
                path: "x".into(),
                language: "rs".into(),
                message: "m".into(),
            },
            AgError::InvalidArgument {
                flag: "f".into(),
                message: "m".into(),
            },
            AgError::IndexCorrupted {
                path: "x".into(),
                reason: "r".into(),
            },
            AgError::GitError {
                message: "m".into(),
            },
            AgError::Internal {
                message: "m".into(),
            },
        ];
        let codes: Vec<&str> = errors.iter().map(|e| e.code()).collect();
        let unique: std::collections::HashSet<&&str> = codes.iter().collect();
        assert_eq!(
            codes.len(),
            unique.len(),
            "duplicate error codes: {codes:?}"
        );
    }

    #[test]
    fn error_envelope_parse_error_has_suggestion() {
        let err = AgError::ParseError {
            path: "x".into(),
            language: "rs".into(),
            message: "m".into(),
        };
        assert!(err.suggestion().is_some());
    }

    #[test]
    fn error_envelope_index_corrupted_has_suggestion() {
        let err = AgError::IndexCorrupted {
            path: "x".into(),
            reason: "r".into(),
        };
        assert!(err.suggestion().is_some());
    }
}