//! 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 { .. }));
}
}