codio 0.1.1

Production-ready commit message generator using local Ollama LLM
Documentation
use crate::error::AppError;
use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::NamedTempFile;

pub fn edit_message(initial_message: &str, verbose_flag: bool) -> Result<String, AppError> {
    let mut temp = NamedTempFile::new()?;
    temp.write_all(initial_message.as_bytes())?;
    temp.write_all(b"\n")?;
    temp.flush()?;

    open_editor(temp.path(), verbose_flag)?;

    let edited = fs::read_to_string(temp.path())?;
    let cleaned = clean_editor_message(&edited);

    if cleaned.is_empty() {
        return Err(AppError::Message(
            "Commit message is empty after edit. Aborting.".to_string(),
        ));
    }

    Ok(cleaned)
}

pub fn detect_editor_for_status() -> String {
    match resolve_editor() {
        Ok((program, args)) => {
            if args.is_empty() {
                program
            } else {
                format!("{} {}", program, args.join(" "))
            }
        }
        Err(err) => format!("unavailable ({err})"),
    }
}

fn open_editor(path: &Path, verbose_flag: bool) -> Result<(), AppError> {
    let (program, args) = resolve_editor()?;

    if verbose_flag {
        eprintln!(
            "[verbose] opening editor '{program}' for {}",
            path.display()
        );
    }

    let status = Command::new(&program)
        .args(args)
        .arg(path)
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .map_err(|err| AppError::Message(format!("Failed to open editor '{program}': {err}")))?;

    if status.success() {
        Ok(())
    } else {
        Err(AppError::Message(format!(
            "Editor '{program}' exited with non-zero status: {status}"
        )))
    }
}

fn resolve_editor() -> Result<(String, Vec<String>), AppError> {
    if let Some(editor_raw) = env::var_os("EDITOR") {
        let editor = editor_raw.to_string_lossy().trim().to_string();
        if !editor.is_empty() {
            let parsed = shlex::split(&editor).ok_or_else(|| {
                AppError::Message(
                    "Failed to parse $EDITOR value. Set a simple command.".to_string(),
                )
            })?;
            if !parsed.is_empty() {
                return Ok((parsed[0].clone(), parsed[1..].to_vec()));
            }
        }
    }

    if command_exists("nano") {
        return Ok(("nano".to_string(), Vec::new()));
    }
    if command_exists("vim") {
        return Ok(("vim".to_string(), Vec::new()));
    }

    Err(AppError::Message(
        "No editor found. Set $EDITOR, or install nano/vim.".to_string(),
    ))
}

fn command_exists(program: &str) -> bool {
    if program.contains('/') {
        return Path::new(program).exists();
    }

    let Some(paths) = env::var_os("PATH") else {
        return false;
    };

    env::split_paths(&paths)
        .map(|dir| dir.join(program))
        .any(|candidate| is_executable_file(&candidate))
}

fn is_executable_file(path: &PathBuf) -> bool {
    if !path.is_file() {
        return false;
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        match fs::metadata(path) {
            Ok(meta) => meta.permissions().mode() & 0o111 != 0,
            Err(_) => false,
        }
    }

    #[cfg(not(unix))]
    {
        true
    }
}

fn clean_editor_message(input: &str) -> String {
    let mut lines: Vec<&str> = input.lines().collect();

    while lines.first().is_some_and(|line| line.trim().is_empty()) {
        lines.remove(0);
    }
    while lines.last().is_some_and(|line| line.trim().is_empty()) {
        lines.pop();
    }

    lines.join("\n").trim().to_string()
}

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

    #[test]
    fn strips_extra_blank_lines() {
        let out = clean_editor_message("\n\nfeat(core): add test\n\n- body\n\n");
        assert_eq!(out, "feat(core): add test\n\n- body");
    }
}