agent-runbook 0.1.3

Generate a local runbook for AI coding agents.
Documentation
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::Path;
use std::process::Command;

pub struct CommandOutput {
    pub status: bool,
    pub first_line: String,
}

pub struct CommandIndex {
    commands: HashMap<String, String>,
}

impl CommandIndex {
    pub fn new() -> Self {
        let mut commands = HashMap::new();
        let path_exts = path_exts();

        if let Some(paths) = env::var_os("PATH") {
            for directory in env::split_paths(&paths) {
                index_directory(&mut commands, &directory, &path_exts);
            }
        }

        Self { commands }
    }

    pub fn resolve(&self, command: &str) -> Option<String> {
        self.commands.get(&normalize(command)).cloned()
    }
}

pub fn run_command(command: &str, args: &[&str]) -> CommandOutput {
    let Ok(output) = Command::new(command).args(args).output() else {
        return CommandOutput {
            status: false,
            first_line: "failed to start command".to_string(),
        };
    };

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let text = if output.status.success() {
        format!("{stdout}{stderr}")
    } else {
        format!("{stderr}{stdout}")
    };

    CommandOutput {
        status: output.status.success(),
        first_line: text
            .replace('\0', "")
            .trim()
            .lines()
            .next()
            .unwrap_or("")
            .to_string(),
    }
}

fn index_directory(commands: &mut HashMap<String, String>, directory: &Path, path_exts: &[String]) {
    let Ok(entries) = fs::read_dir(directory) else {
        return;
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if !is_command_path(&path, path_exts) {
            continue;
        }

        let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
            continue;
        };

        let path_value = path.display().to_string();
        commands
            .entry(normalize(file_name))
            .or_insert_with(|| path_value.clone());

        if should_index_stem(&path, path_exts)
            && let Some(stem) = path.file_stem().and_then(|value| value.to_str())
        {
            commands
                .entry(normalize(stem))
                .or_insert_with(|| path_value.clone());
        }
    }
}

#[cfg(windows)]
fn path_exts() -> Vec<String> {
    env::var("PATHEXT")
        .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
        .split(';')
        .filter(|value| !value.is_empty())
        .map(normalize)
        .collect()
}

#[cfg(not(windows))]
fn path_exts() -> Vec<String> {
    Vec::new()
}

#[cfg(windows)]
fn is_command_path(path: &Path, path_exts: &[String]) -> bool {
    path.extension()
        .and_then(|value| value.to_str())
        .map(|extension| path_exts.contains(&format!(".{}", normalize(extension))))
        .unwrap_or(false)
}

#[cfg(not(windows))]
fn is_command_path(path: &Path, _path_exts: &[String]) -> bool {
    use std::os::unix::fs::PermissionsExt;

    path.metadata()
        .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}

#[cfg(windows)]
fn should_index_stem(path: &Path, path_exts: &[String]) -> bool {
    path.extension()
        .and_then(|value| value.to_str())
        .map(|extension| path_exts.contains(&format!(".{}", normalize(extension))))
        .unwrap_or(false)
}

#[cfg(not(windows))]
fn should_index_stem(_path: &Path, _path_exts: &[String]) -> bool {
    false
}

#[cfg(windows)]
fn normalize(value: &str) -> String {
    value.to_ascii_lowercase()
}

#[cfg(not(windows))]
fn normalize(value: &str) -> String {
    value.to_string()
}