codio 0.1.1

Production-ready commit message generator using local Ollama LLM
Documentation
mod cli;
mod editor;
mod error;
mod git;
mod ollama;
mod prompt;
mod security;

use clap::Parser;
use cli::{Cli, Commands, CommonOptions};
use error::AppError;
use indicatif::{ProgressBar, ProgressStyle};
use prompt::{
    coerce_conventional_message, infer_scope, normalize_generated_message, resolve_scope,
    truncate_middle, validate_commit_message,
};
use std::io::IsTerminal;

pub async fn run() -> Result<(), AppError> {
    let cli = Cli::parse();
    run_with(cli).await
}

async fn run_with(cli: Cli) -> Result<(), AppError> {
    git::ensure_inside_git_repo()?;
    match cli.command {
        Commands::Add { paths } => run_add_flow(cli.options, paths),
        Commands::Gen { edit } => run_gen_flow(cli.options, edit).await,
        Commands::Git { edit } => run_git_flow(cli.options, edit).await,
        Commands::Push => run_push_flow(cli.options),
        Commands::Status => run_status_flow(cli.options).await,
    }
}

fn run_add_flow(options: CommonOptions, paths: Vec<String>) -> Result<(), AppError> {
    git::stage_paths(&paths, options.verbose)?;
    eprintln!("Completed: staged paths");
    eprintln!("paths: {}", paths.join(" "));
    Ok(())
}

async fn run_gen_flow(options: CommonOptions, edit: bool) -> Result<(), AppError> {
    let mut progress = GenProgress::new(!options.no_progress);

    progress.set_message("Generating commit message with Ollama");
    let generated = generate_message(&options, true).await?;
    validate_commit_message(&generated, options.no_body)?;
    print_message_preview("Generated draft message", &generated);

    if edit {
        progress.print_step("Opening editor");
    }
    let final_message = if edit {
        editor::edit_message(&generated, options.verbose)?
    } else {
        generated
    };
    validate_commit_message(&final_message, options.no_body)?;

    complete_commit(&options, &mut progress, &final_message, false)
}

fn run_push_flow(options: CommonOptions) -> Result<(), AppError> {
    git::push_current_branch(options.verbose)?;
    eprintln!("Completed: pushed current branch");
    print_summary(true);
    Ok(())
}

async fn run_status_flow(options: CommonOptions) -> Result<(), AppError> {
    let branch = git::current_branch_name().unwrap_or_else(|_| "unknown".to_string());
    let upstream = git::current_upstream()
        .ok()
        .unwrap_or_else(|| "not configured".to_string());
    let porcelain = git::porcelain_status().unwrap_or_default();
    let (staged, unstaged, untracked) = summarize_porcelain(&porcelain);
    let editor = editor::detect_editor_for_status();
    let model_status = ollama::query_model_status(&options.model).await?;

    println!("Codio Status");
    println!("------------");
    println!("command: codio");
    println!("model: {}", options.model);
    println!("scope: {}", options.scope);
    println!("max_chars: {}", options.max_chars);
    println!("staged_only: {}", options.staged_only);
    println!("dry_run: {}", options.dry_run);
    println!("no_body: {}", options.no_body);
    println!("no_progress: {}", options.no_progress);
    println!("verbose: {}", options.verbose);
    println!();
    println!("Git");
    println!("---");
    println!("branch: {branch}");
    println!("upstream: {upstream}");
    println!("staged_files: {staged}");
    println!("unstaged_files: {unstaged}");
    println!("untracked_files: {untracked}");
    println!();
    println!("Editor");
    println!("------");
    println!("resolved: {editor}");
    println!();
    println!("Ollama");
    println!("------");
    println!("url: {}", ollama::OLLAMA_URL);
    println!("reachable: {}", model_status.reachable);
    println!(
        "selected_model_available: {}",
        model_status.selected_model_available
    );
    println!(
        "installed_models_count: {}",
        model_status.installed_models.len()
    );
    if !model_status.installed_models.is_empty() {
        println!(
            "installed_models: {}",
            model_status.installed_models.join(", ")
        );
    }

    Ok(())
}

async fn run_git_flow(options: CommonOptions, edit: bool) -> Result<(), AppError> {
    let mut progress = GenProgress::new(!options.no_progress);

    progress.set_message("Staging all changes (git add .)");
    git::stage_paths(&[".".to_string()], options.verbose)?;

    progress.set_message("Generating commit message with Ollama");
    let generated = generate_message(&options, true).await?;
    validate_commit_message(&generated, options.no_body)?;
    print_message_preview("Generated draft message", &generated);

    if edit {
        progress.print_step("Opening editor");
    }
    let final_message = if edit {
        editor::edit_message(&generated, options.verbose)?
    } else {
        generated
    };
    validate_commit_message(&final_message, options.no_body)?;

    complete_commit(&options, &mut progress, &final_message, true)
}

async fn generate_message(options: &CommonOptions, staged_only: bool) -> Result<String, AppError> {
    let git = git::collect_git_context(staged_only || options.staged_only, options.verbose)?;

    if git.files.is_empty() {
        return Err(AppError::Message(
            "No changed files found. Stage or modify files first.".to_string(),
        ));
    }

    let scope_input = if options.scope.eq_ignore_ascii_case("auto") {
        infer_scope(&git.files)
    } else {
        options.scope.clone()
    };
    let scope = resolve_scope(&scope_input)?;

    verbose(
        options.verbose,
        format!(
            "collecting {} changes from {} files",
            if git.using_staged {
                "staged"
            } else {
                "unstaged"
            },
            git.files.len()
        ),
    );

    let sanitized = security::sanitize_diff(&git.diff, &git.files);
    verbose(
        options.verbose,
        format!(
            "sanitized diff: {} secret lines redacted, {} sensitive files omitted",
            sanitized.redacted_lines,
            sanitized.omitted_files.len()
        ),
    );

    let truncated_diff = truncate_middle(&sanitized.diff, options.max_chars);
    let prompt = prompt::build_prompt(
        &git.status,
        &git.files,
        &truncated_diff,
        &scope,
        options.no_body,
    );

    verbose(options.verbose, format!("using scope: {scope}"));
    verbose(
        options.verbose,
        format!("prompt size: {} chars", prompt.len()),
    );

    let generated =
        ollama::generate_with_ollama(ollama::OLLAMA_URL, &options.model, &prompt, options.verbose)
            .await?;

    let normalized = normalize_generated_message(&generated, options.no_body)?;
    let coerced = coerce_conventional_message(&normalized, &scope, options.no_body);
    validate_commit_message(&coerced, options.no_body)?;
    Ok(coerced)
}

fn verbose(enabled: bool, message: String) {
    if enabled {
        eprintln!("[verbose] {message}");
    }
}

struct GenProgress {
    bar: Option<ProgressBar>,
}

impl GenProgress {
    fn new(enabled: bool) -> Self {
        if enabled && std::io::stderr().is_terminal() {
            let bar = ProgressBar::new_spinner();
            bar.enable_steady_tick(std::time::Duration::from_millis(120));
            bar.set_style(
                ProgressStyle::with_template("{spinner} {msg}")
                    .unwrap_or_else(|_| ProgressStyle::default_spinner()),
            );
            return Self { bar: Some(bar) };
        }

        Self { bar: None }
    }

    fn set_message(&self, message: &str) {
        if let Some(bar) = &self.bar {
            bar.set_message(message.to_string());
        } else {
            eprintln!("-> {message}");
        }
    }

    fn finish_with_message(&mut self, message: &str) {
        if let Some(bar) = &self.bar {
            bar.finish_and_clear();
            self.bar = None;
        }
        eprintln!("{message}");
    }

    fn print_step(&mut self, message: &str) {
        if let Some(bar) = &self.bar {
            bar.finish_and_clear();
            self.bar = None;
        }
        eprintln!("-> {message}");
    }
}

fn print_summary(pushed: bool) {
    let commit = git::last_commit_short_sha().unwrap_or_else(|_| "unknown".to_string());
    let branch = git::current_branch_name().unwrap_or_else(|_| "unknown".to_string());
    let upstream = git::current_upstream().ok();

    eprintln!("Summary:");
    eprintln!("commit: {commit}");
    eprintln!("branch: {branch}");
    if pushed {
        if let Some(upstream) = upstream {
            eprintln!("upstream: {upstream}");
        } else {
            eprintln!("upstream: not configured");
        }
    }
}

fn print_message_preview(title: &str, message: &str) {
    eprintln!("{title}:");
    eprintln!("---");
    for line in message.lines() {
        eprintln!("{line}");
    }
    eprintln!("---");
}

fn summarize_porcelain(status: &str) -> (usize, usize, usize) {
    let mut staged = 0usize;
    let mut unstaged = 0usize;
    let mut untracked = 0usize;

    for line in status.lines() {
        if line.starts_with("?? ") {
            untracked += 1;
            continue;
        }

        let mut chars = line.chars();
        let x = chars.next().unwrap_or(' ');
        let y = chars.next().unwrap_or(' ');
        if x != ' ' {
            staged += 1;
        }
        if y != ' ' {
            unstaged += 1;
        }
    }

    (staged, unstaged, untracked)
}

fn complete_commit(
    options: &CommonOptions,
    progress: &mut GenProgress,
    final_message: &str,
    push_after: bool,
) -> Result<(), AppError> {
    if options.dry_run {
        progress.finish_with_message("Dry-run completed");
        println!("{final_message}");
        return Ok(());
    }

    progress.print_step("Creating commit");
    git::commit_with_message(final_message, options.verbose)?;

    if push_after {
        progress.print_step("Pushing to remote");
        git::push_current_branch(options.verbose)?;
        progress.finish_with_message("Completed: staged, committed, and pushed");
    } else {
        progress.finish_with_message("Completed: committed staged changes");
    }

    print_summary(push_after);
    println!("{final_message}");
    Ok(())
}