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(())
}