diff --git a/src/ai_prompt.rs b/src/ai_prompt.rs index 49fe8e4..5af748f 100644 --- a/src/ai_prompt.rs +++ b/src/ai_prompt.rs @@ -1,6 +1,6 @@ use crate::{ command::{draft::DraftCommand, explain::ExplainCommand}, - git_entity::GitEntity, + git_entity::{diff::Diff, GitEntity}, }; use indoc::{formatdoc, indoc}; use thiserror::Error; @@ -39,15 +39,14 @@ impl AIPrompt { diff = commit.diff } } - GitEntity::Diff(diff) => { + GitEntity::Diff(Diff::WorkingTree { diff, .. } | Diff::CommitsRange { diff, .. }) => { formatdoc! {" Context - Changes: ```diff {diff} ``` - ", - diff = diff.diff + " } } }; @@ -72,7 +71,7 @@ impl AIPrompt { 2. Direct impact " }, - GitEntity::Diff(_) => formatdoc! {" + GitEntity::Diff(Diff::WorkingTree { .. }) => formatdoc! {" {base_content} Provide: @@ -80,6 +79,14 @@ impl AIPrompt { 2. Notable concerns (if any) " }, + GitEntity::Diff(Diff::CommitsRange { .. }) => formatdoc! {" + {base_content} + + Provide: + 1. Core changes made + 2. Direct impact + " + }, }, }; @@ -90,8 +97,10 @@ impl AIPrompt { } pub fn build_draft_prompt(command: &DraftCommand) -> Result { - let GitEntity::Diff(diff) = &command.git_entity else { - return Err(AIPromptError("`draft` is only supported for diffs".into())); + let GitEntity::Diff(Diff::WorkingTree { diff, .. }) = &command.git_entity else { + return Err(AIPromptError( + "`draft` is only supported for working tree diffs".into(), + )); }; let system_prompt = String::from(indoc! {" @@ -131,7 +140,6 @@ impl AIPrompt { ``` ", commit_types = command.draft_config.commit_types, - diff = diff.diff }); Ok(AIPrompt { diff --git a/src/commit_reference.rs b/src/commit_reference.rs new file mode 100644 index 0000000..e80d625 --- /dev/null +++ b/src/commit_reference.rs @@ -0,0 +1,124 @@ +use std::str::FromStr; +use thiserror::Error; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum CommitReference { + Single(String), + Range { from: String, to: String }, +} + +#[derive(Debug, Error)] +pub enum ReferenceParseError { + #[error("empty reference string")] + Empty, +} + +impl FromStr for CommitReference { + type Err = ReferenceParseError; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err(ReferenceParseError::Empty); + } + + // Handle the .. cases + if let Some((from, to)) = s.split_once("..") { + let from = if from.is_empty() { "HEAD" } else { from }; + let to = if to.is_empty() { "HEAD" } else { to }; + + Ok(CommitReference::Range { + from: from.to_string(), + to: to.to_string(), + }) + } else { + Ok(CommitReference::Single(s.to_string())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser, Debug)] + struct TestCli { + reference: CommitReference, + } + + #[test] + fn test_single_commit() { + assert_eq!( + "HEAD".parse::().unwrap(), + CommitReference::Single("HEAD".to_string()) + ); + } + + #[test] + fn test_full_range() { + assert_eq!( + "main..feature".parse::().unwrap(), + CommitReference::Range { + from: "main".to_string(), + to: "feature".to_string(), + } + ); + } + + #[test] + fn test_from_only_range() { + assert_eq!( + "develop..".parse::().unwrap(), + CommitReference::Range { + from: "develop".to_string(), + to: "HEAD".to_string(), + } + ); + } + + #[test] + fn test_to_only_range() { + assert_eq!( + "..feature".parse::().unwrap(), + CommitReference::Range { + from: "HEAD".to_string(), + to: "feature".to_string(), + } + ); + } + + #[test] + fn test_clap_integration() { + // Test full range + let cli = TestCli::try_parse_from(&["test", "main..feature"]).unwrap(); + assert!(matches!( + cli.reference, + CommitReference::Range { from, to } + if from == "main" && to == "feature" + )); + + // Test from-only range + let cli = TestCli::try_parse_from(&["test", "develop.."]).unwrap(); + assert!(matches!( + cli.reference, + CommitReference::Range { from, to } + if from == "develop" && to == "HEAD" + )); + + // Test to-only range + let cli = TestCli::try_parse_from(&["test", "..feature"]).unwrap(); + assert!(matches!( + cli.reference, + CommitReference::Range { from, to } + if from == "HEAD" && to == "feature" + )); + } + + #[test] + fn test_empty_reference() { + assert!(matches!( + "".parse::(), + Err(ReferenceParseError::Empty) + )); + } +} diff --git a/src/config/cli.rs b/src/config/cli.rs index 608ad80..2363f9f 100644 --- a/src/config/cli.rs +++ b/src/config/cli.rs @@ -1,6 +1,8 @@ use clap::{command, Parser, Subcommand, ValueEnum}; use std::str::FromStr; +use crate::commit_reference::CommitReference; + #[derive(Parser)] #[command(name = "lumen")] #[command(about = "AI-powered CLI tool for git commit summaries", long_about = None)] @@ -30,7 +32,7 @@ pub enum ProviderType { Groq, Claude, Ollama, - Openrouter + Openrouter, } impl FromStr for ProviderType { @@ -54,8 +56,8 @@ pub enum Commands { /// Explain the changes in a commit, or the current diff Explain { /// The commit hash to use - #[arg(group = "target")] - sha: Option, + #[arg(group = "target", value_parser = clap::value_parser!(CommitReference))] + reference: Option, /// Explain current diff #[arg(long, group = "target")] diff --git a/src/config/mod.rs b/src/config/mod.rs index 49effa7..c0066d7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,4 @@ -pub mod configuration; pub mod cli; +pub mod configuration; pub use configuration::LumenConfig; diff --git a/src/git_entity/diff.rs b/src/git_entity/diff.rs index 42924dd..ecdd6d8 100644 --- a/src/git_entity/diff.rs +++ b/src/git_entity/diff.rs @@ -8,9 +8,16 @@ pub enum DiffError { } #[derive(Clone, Debug)] -pub struct Diff { - pub staged: bool, - pub diff: String, +pub enum Diff { + WorkingTree { + staged: bool, + diff: String, + }, + CommitsRange { + from: String, + to: String, + diff: String, + }, } impl Diff { @@ -28,6 +35,23 @@ impl Diff { return Err(DiffError::EmptyDiff { staged }.into()); } - Ok(Diff { staged, diff }) + Ok(Diff::WorkingTree { staged, diff }) + } + + pub fn from_commits_range(from: &str, to: &str) -> Result { + let output = std::process::Command::new("git") + .args(["diff", from, to]) + .output()?; + + let diff = String::from_utf8(output.stdout)?; + if diff.is_empty() { + return Err(DiffError::EmptyDiff { staged: false }.into()); + } + + Ok(Diff::CommitsRange { + from: from.to_string(), + to: to.to_string(), + diff, + }) } } diff --git a/src/git_entity/mod.rs b/src/git_entity/mod.rs index 3de74c7..bf47d70 100644 --- a/src/git_entity/mod.rs +++ b/src/git_entity/mod.rs @@ -26,10 +26,14 @@ impl GitEntity { date = commit.date, message = commit.message, }, - GitEntity::Diff(diff) => formatdoc! {" - # Entity: Diff{staged}", - staged = if diff.staged { " (staged)" } else { "" } + GitEntity::Diff(Diff::WorkingTree { staged, .. }) => formatdoc! {" + # Entity: Working Tree Diff{staged}", + staged = if *staged { " (staged)" } else { "" } }, + GitEntity::Diff(Diff::CommitsRange { from, to, .. }) => formatdoc! {" + # Entity: Range + `{from}` -> `{to}` + "}, } } } diff --git a/src/main.rs b/src/main.rs index a782dda..0e68e91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use clap::Parser; +use commit_reference::CommitReference; use config::cli::{Cli, Commands}; use config::LumenConfig; use error::LumenError; @@ -7,6 +8,7 @@ use std::process; mod ai_prompt; mod command; +mod commit_reference; mod config; mod error; mod git_entity; @@ -35,15 +37,17 @@ async fn run() -> Result<(), LumenError> { match cli.command { Commands::Explain { - sha, + reference, diff, staged, query, } => { let git_entity = if diff { GitEntity::Diff(Diff::from_working_tree(staged)?) - } else if let Some(sha) = sha { + } else if let Some(CommitReference::Single(sha)) = reference { GitEntity::Commit(Commit::new(sha)?) + } else if let Some(CommitReference::Range { from, to }) = reference { + GitEntity::Diff(Diff::from_commits_range(&from, &to)?) } else { return Err(LumenError::InvalidArguments( "`explain` expects SHA-1 or --diff to be present".into(),