Skip to content
//! AI invocation layer.
//!
//! Eris does not talk to any AI provider directly. Instead the user supplies a
//! command (e.g. `opencode -p`, `llm`, `sgpt`) that reads a prompt on stdin and
//! writes the finished text to stdout. This keeps Eris dependency-free and lets
//! users pick whatever tool they already have configured.

use std::fmt;
use std::io::Write;
use std::process::{Command, Stdio};

/// Errors that can occur while running the configured AI command.
#[derive(Debug)]
pub enum AiError {
    /// The command string was empty or could not be tokenised.
    EmptyCommand,
    /// Spawning the process failed.
    Spawn {
        command: String,
        source: std::io::Error,
    },
    /// Writing the prompt to stdin failed.
    Write(std::io::Error),
    /// Reading output / waiting on the process failed.
    Io(std::io::Error),
    /// The command exited with a non-zero status.
    NonZeroExit { code: Option<i32>, stderr: String },
}

impl fmt::Display for AiError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AiError::EmptyCommand => write!(f, "the AI command is empty"),
            AiError::Spawn { command, source } => {
                write!(f, "failed to run AI command '{command}': {source}")
            }
            AiError::Write(e) => write!(f, "failed to send prompt to AI command: {e}"),
            AiError::Io(e) => write!(f, "failed while running AI command: {e}"),
            AiError::NonZeroExit { code, stderr } => {
                let code = code.map_or_else(|| "signal".to_string(), |c| c.to_string());
                write!(f, "AI command failed (exit {code}): {}", stderr.trim())
            }
        }
    }
}

impl std::error::Error for AiError {}

/// Split a shell-style command string into a program and its arguments.
///
/// Supports single and double quotes so users can write commands like
/// `--ai "my-tool --model 'gpt-4o'"`. This is intentionally minimal: it handles
/// quoting and whitespace but not shell features like pipes or variable
/// expansion.
pub fn tokenize(command: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut current = String::new();
    let mut in_single = false;
    let mut in_double = false;
    let mut has_token = false;

    for ch in command.chars() {
        match ch {
            '\'' if !in_double => {
                in_single = !in_single;
                has_token = true;
            }
            '"' if !in_single => {
                in_double = !in_double;
                has_token = true;
            }
            c if c.is_whitespace() && !in_single && !in_double => {
                if has_token {
                    tokens.push(std::mem::take(&mut current));
                    has_token = false;
                }
            }
            c => {
                current.push(c);
                has_token = true;
            }
        }
    }

    if has_token {
        tokens.push(current);
    }

    tokens
}

/// Run the configured AI `command`, feeding `prompt` on stdin and returning
/// the captured stdout as a `String`.
pub fn run(command: &str, prompt: &str) -> Result<String, AiError> {
    let tokens = tokenize(command);
    let (program, args) = tokens.split_first().ok_or(AiError::EmptyCommand)?;
    if program.is_empty() {
        return Err(AiError::EmptyCommand);
    }

    let mut child = Command::new(program)
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|source| AiError::Spawn {
            command: command.to_string(),
            source,
        })?;

    {
        let mut stdin = child.stdin.take().ok_or_else(|| {
            AiError::Io(std::io::Error::other("failed to open stdin for AI command"))
        })?;
        // A BrokenPipe here means the child exited without reading its input
        // (e.g. it errored out immediately). That's not fatal on its own — we
        // let the exit-status check below surface the real failure.
        match stdin.write_all(prompt.as_bytes()) {
            Ok(()) => {}
            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
            Err(e) => return Err(AiError::Write(e)),
        }
        // stdin dropped here, closing the pipe so the child sees EOF.
    }

    let output = child.wait_with_output().map_err(AiError::Io)?;

    if !output.status.success() {
        return Err(AiError::NonZeroExit {
            code: output.status.code(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        });
    }

    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

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

    #[test]
    fn tokenize_simple() {
        assert_eq!(tokenize("opencode run"), vec!["opencode", "run"]);
    }

    #[test]
    fn tokenize_handles_double_quotes() {
        assert_eq!(
            tokenize("my-tool --model \"gpt 4o\" --fast"),
            vec!["my-tool", "--model", "gpt 4o", "--fast"]
        );
    }

    #[test]
    fn tokenize_handles_single_quotes() {
        assert_eq!(tokenize("tool --x 'a b c'"), vec!["tool", "--x", "a b c"]);
    }

    #[test]
    fn tokenize_empty_string() {
        assert!(tokenize("   ").is_empty());
    }

    #[test]
    fn tokenize_preserves_empty_quoted_arg() {
        assert_eq!(tokenize("tool \"\""), vec!["tool", ""]);
    }

    #[test]
    fn run_pipes_stdin_to_stdout() {
        // `cat` echoes stdin straight back to stdout.
        let out = run("cat", "hello changelog").expect("run cat");
        assert_eq!(out, "hello changelog");
    }

    #[test]
    fn run_reports_non_zero_exit() {
        let err = run("false", "anything").unwrap_err();
        assert!(matches!(err, AiError::NonZeroExit { .. }));
    }

    #[test]
    fn run_empty_command_errors() {
        let err = run("", "x").unwrap_err();
        assert!(matches!(err, AiError::EmptyCommand));
    }

    #[test]
    fn run_missing_program_errors() {
        let err = run("this-command-does-not-exist-eris", "x").unwrap_err();
        assert!(matches!(err, AiError::Spawn { .. }));
    }
}