yoo 0.6.3

A tiny developer companion for better coding sessions.
use std::{
    io::Read,
    path::Path,
    process::{Command, Stdio},
    thread,
    time::{Duration, Instant},
};

const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitInfo {
    pub branch: String,
    pub changed_files: usize,
}

pub fn inspect(directory: &Path) -> Option<GitInfo> {
    let branch = run_git(directory, &["rev-parse", "--abbrev-ref", "HEAD"])?;

    if branch.is_empty() {
        return None;
    }

    let changed_files = run_git(directory, &["status", "--porcelain"])
        .map(|status| status.lines().count())
        .unwrap_or(0);

    Some(GitInfo {
        branch,
        changed_files,
    })
}

pub fn change_status(changed_files: usize) -> String {
    if changed_files == 0 {
        "clean".to_owned()
    } else {
        format!("{changed_files} changed file(s)")
    }
}

pub fn commit_count(directory: &Path) -> Option<usize> {
    run_git(directory, &["rev-list", "--count", "HEAD"])
        .and_then(|value| value.parse::<usize>().ok())
}

pub fn latest_tag(directory: &Path) -> Option<String> {
    run_git(directory, &["describe", "--tags", "--abbrev=0"])
}

fn run_git(directory: &Path, arguments: &[&str]) -> Option<String> {
    run_command_in(directory, "git", arguments)
}

pub fn run_command(program: &str, arguments: &[&str]) -> Option<String> {
    command_stdout(Command::new(program).args(arguments), COMMAND_TIMEOUT)
}

pub fn run_command_in(directory: &Path, program: &str, arguments: &[&str]) -> Option<String> {
    command_stdout(
        Command::new(program).args(arguments).current_dir(directory),
        COMMAND_TIMEOUT,
    )
}

fn command_stdout(command: &mut Command, timeout: Duration) -> Option<String> {
    let mut child = command
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .ok()?;
    let mut stdout = child.stdout.take()?;
    let output_reader = thread::spawn(move || {
        let mut output = Vec::new();
        stdout.read_to_end(&mut output).map(|_| output)
    });
    let deadline = Instant::now() + timeout;

    let status = loop {
        match child.try_wait() {
            Ok(Some(status)) => break status,
            Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(10)),
            Ok(None) | Err(_) => {
                let _ = child.kill();
                let _ = child.wait();
                let _ = output_reader.join();
                return None;
            }
        }
    };
    let output = output_reader.join().ok()?.ok()?;

    if !status.success() {
        return None;
    }

    String::from_utf8(output)
        .ok()
        .map(|text| text.trim().to_owned())
}

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

    #[test]
    fn inspect_never_panics_for_a_temp_directory() {
        let directory = std::env::temp_dir();
        let _ = inspect(&directory);
    }

    #[test]
    fn git_helpers_never_panic_for_a_temp_directory() {
        let directory = std::env::temp_dir();
        let _ = commit_count(&directory);
        let _ = latest_tag(&directory);
    }

    #[test]
    fn formats_change_status() {
        assert_eq!(change_status(0), "clean");
        assert_eq!(change_status(3), "3 changed file(s)");
    }

    #[test]
    fn missing_commands_return_none() {
        assert_eq!(
            run_command("yoo-command-that-does-not-exist", &["--version"]),
            None
        );
    }
}