cliai 0.2.0

A small Rust library for invoking AI tools through CLI backends like Ollama and GitHub Copilot CLI.
Documentation
use std::io::Write;
use std::process::{Command, Stdio};

use crate::{AiBackend, AiError, GenerateRequest, GenerateResponse};

/// AI backend that invokes the `ollama` CLI.
///
/// By default this backend executes the `ollama` binary from `PATH` and runs
/// the configured model with `ollama run <model>`.
#[derive(Debug, Clone)]
pub struct Ollama {
    model: String,
    bin: String,
}

impl Ollama {
    /// Creates a new Ollama backend for the given model.
    pub fn new(model: impl Into<String>) -> Self {
        Self {
            model: model.into(),
            bin: "ollama".into(),
        }
    }

    /// Overrides the model name.
    pub fn with_model(mut self, model: impl Into<String>) -> Self {
        self.model = model.into();
        self
    }

    /// Overrides the executable name or path.
    pub fn with_bin(mut self, bin: impl Into<String>) -> Self {
        self.bin = bin.into();
        self
    }

    /// Returns the configured model.
    pub fn model(&self) -> &str {
        &self.model
    }

    /// Returns the configured executable name or path.
    pub fn bin(&self) -> &str {
        &self.bin
    }

    fn build_prompt(&self, request: &GenerateRequest) -> String {
        let mut parts = Vec::new();

        if let Some(persona) = &request.persona {
            if !persona.trim().is_empty() {
                parts.push(format!("Persona:\n{}", persona));
            }
        }

        if let Some(instructions) = &request.instructions {
            if !instructions.trim().is_empty() {
                parts.push(format!("Instructions:\n{}", instructions));
            }
        }

        if let Some(memory) = &request.memory {
            if !memory.trim().is_empty() {
                parts.push(format!("Memory:\n{}", memory));
            }
        }

        parts.push(format!("Prompt:\n{}", request.prompt));
        parts.join("\n\n")
    }
}

impl Default for Ollama {
    /// Creates a new Ollama backend with the default model `qwen2.5-coder:1.5b`.
    fn default() -> Self {
        Self::new("qwen2.5-coder:1.5b")
    }
}

impl AiBackend for Ollama {
    fn name(&self) -> &'static str {
        "ollama"
    }

    fn generate(&self, request: &GenerateRequest) -> Result<GenerateResponse, AiError> {
        let full_prompt = self.build_prompt(request);

        let mut child = Command::new(&self.bin)
            .args(["run", &self.model])
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .spawn()?;

        if let Some(stdin) = child.stdin.as_mut() {
            stdin.write_all(full_prompt.as_bytes())?;
        }

        let output = child.wait_with_output()?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();

            return Err(AiError::CommandFailed(format!(
                "{} exited with status {}{}",
                self.bin,
                output.status,
                if stderr.is_empty() {
                    String::new()
                } else {
                    format!(": {}", stderr)
                }
            )));
        }

        Ok(GenerateResponse {
            text: String::from_utf8(output.stdout)?.trim().to_string(),
        })
    }
}