Skip to content

Commit 509a33e

Browse files
Copilotoleander
andcommitted
Create unified multi_step module structure with delegation to old modules
Co-authored-by: oleander <220827+oleander@users.noreply.github.com>
1 parent 7e9bc76 commit 509a33e

File tree

9 files changed

+349
-7
lines changed

9 files changed

+349
-7
lines changed

src/commit.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use async_openai::Client;
66
use crate::{config, debug_output, openai, profile};
77
use crate::model::Model;
88
use crate::config::AppConfig;
9-
use crate::multi_step_integration::{generate_commit_message_local, generate_commit_message_multi_step};
9+
use crate::generation::multi_step::{generate_with_api, generate_local};
1010

1111
/// The instruction template included at compile time
1212
const INSTRUCTION_TEMPLATE: &str = include_str!("../resources/prompt.md");
@@ -117,7 +117,7 @@ pub async fn generate(patch: String, remaining_tokens: usize, model: Model, sett
117117
let client = Client::with_config(config);
118118
let model_str = model.to_string();
119119

120-
match generate_commit_message_multi_step(&client, &model_str, &patch, max_length).await {
120+
match generate_with_api(&client, &model_str, &patch, max_length).await {
121121
Ok(message) => return Ok(openai::Response { response: message }),
122122
Err(e) => {
123123
// Check if it's an API key error
@@ -145,7 +145,7 @@ pub async fn generate(patch: String, remaining_tokens: usize, model: Model, sett
145145
let client = Client::new();
146146
let model_str = model.to_string();
147147

148-
match generate_commit_message_multi_step(&client, &model_str, &patch, max_length).await {
148+
match generate_with_api(&client, &model_str, &patch, max_length).await {
149149
Ok(message) => return Ok(openai::Response { response: message }),
150150
Err(e) => {
151151
// Check if it's an API key error
@@ -163,7 +163,7 @@ pub async fn generate(patch: String, remaining_tokens: usize, model: Model, sett
163163
}
164164

165165
// Try local multi-step generation
166-
match generate_commit_message_local(&patch, max_length) {
166+
match generate_local(&patch, max_length) {
167167
Ok(message) => return Ok(openai::Response { response: message }),
168168
Err(e) => {
169169
log::warn!("Local multi-step generation failed: {e}");

src/generation/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
pub mod types;
2+
pub mod multi_step;
23

34
pub use types::{CommitResponse, FileCategory, FileChange, OperationType};
5+
pub use multi_step::{generate_with_api, generate_local};

src/generation/multi_step.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//! Multi-step commit message generation.
2+
//!
3+
//! Implements a sophisticated analysis pipeline:
4+
//! 1. Parse diff into individual files
5+
//! 2. Analyze each file (lines changed, category, impact)
6+
//! 3. Score files by impact
7+
//! 4. Generate message candidates
8+
//! 5. Select best candidate
9+
10+
use anyhow::Result;
11+
use async_openai::config::OpenAIConfig;
12+
use async_openai::Client;
13+
14+
pub mod analysis;
15+
pub mod scoring;
16+
pub mod candidates;
17+
pub mod local;
18+
19+
// Re-export commonly used types and functions
20+
pub use analysis::{FileAnalysis, analyze_file, analyze_file_via_api, ParsedFile};
21+
pub use scoring::{calculate_impact_scores, ImpactScore};
22+
pub use candidates::{generate_candidates, select_best_candidate};
23+
24+
/// Main entry point for multi-step generation with API
25+
pub async fn generate_with_api(
26+
client: &Client<OpenAIConfig>,
27+
model: &str,
28+
diff: &str,
29+
max_length: Option<usize>,
30+
) -> Result<String> {
31+
// This will be moved from multi_step_integration.rs generate_commit_message_multi_step
32+
crate::multi_step_integration::generate_commit_message_multi_step(client, model, diff, max_length).await
33+
}
34+
35+
/// Main entry point for local multi-step generation (no API)
36+
pub fn generate_local(
37+
diff: &str,
38+
max_length: Option<usize>,
39+
) -> Result<String> {
40+
// This will be moved from multi_step_integration.rs generate_commit_message_local
41+
crate::multi_step_integration::generate_commit_message_local(diff, max_length)
42+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
//! File analysis for multi-step generation.
2+
3+
use anyhow::Result;
4+
use serde::{Deserialize, Serialize};
5+
use async_openai::config::OpenAIConfig;
6+
use async_openai::Client;
7+
use serde_json::Value;
8+
9+
/// Represents a parsed file from the git diff
10+
#[derive(Debug)]
11+
pub struct ParsedFile {
12+
pub path: String,
13+
pub operation: String,
14+
pub diff_content: String,
15+
}
16+
17+
#[derive(Debug, Clone, Serialize, Deserialize)]
18+
pub struct FileAnalysis {
19+
pub lines_added: u32,
20+
pub lines_removed: u32,
21+
pub category: FileCategory,
22+
pub summary: String,
23+
}
24+
25+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
26+
pub enum FileCategory {
27+
Source,
28+
Test,
29+
Config,
30+
Docs,
31+
Binary,
32+
Build,
33+
}
34+
35+
impl FileCategory {
36+
pub fn as_str(&self) -> &'static str {
37+
match self {
38+
FileCategory::Source => "source",
39+
FileCategory::Test => "test",
40+
FileCategory::Config => "config",
41+
FileCategory::Docs => "docs",
42+
FileCategory::Binary => "binary",
43+
FileCategory::Build => "build",
44+
}
45+
}
46+
}
47+
48+
impl From<&str> for FileCategory {
49+
fn from(s: &str) -> Self {
50+
match s {
51+
"source" => FileCategory::Source,
52+
"test" => FileCategory::Test,
53+
"config" => FileCategory::Config,
54+
"docs" => FileCategory::Docs,
55+
"binary" => FileCategory::Binary,
56+
"build" => FileCategory::Build,
57+
_ => FileCategory::Source, // default fallback
58+
}
59+
}
60+
}
61+
62+
/// Analyze a file locally without API
63+
pub fn analyze_file(
64+
path: &str,
65+
diff_content: &str,
66+
operation: &str,
67+
) -> FileAnalysis {
68+
// This will be moved from multi_step_analysis.rs analyze_file function
69+
crate::multi_step_analysis::analyze_file(path, diff_content, operation).into()
70+
}
71+
72+
/// Analyze a file using OpenAI API
73+
pub async fn analyze_file_via_api(
74+
client: &Client<OpenAIConfig>,
75+
model: &str,
76+
file: &crate::multi_step_integration::ParsedFile,
77+
) -> Result<Value> {
78+
// Delegate to the existing function for now
79+
crate::multi_step_integration::call_analyze_function(client, model, file).await
80+
}
81+
82+
/// Helper: Categorize file by path
83+
pub fn categorize_file(path: &str) -> FileCategory {
84+
// Implement locally for now to avoid private function call
85+
let path_lower = path.to_lowercase();
86+
87+
if path_lower.ends_with("test.rs")
88+
|| path_lower.ends_with("_test.rs")
89+
|| path_lower.contains("tests/")
90+
|| path_lower.ends_with(".test.js")
91+
|| path_lower.ends_with(".spec.js")
92+
{
93+
FileCategory::Test
94+
} else if path_lower.ends_with(".md") || path_lower.ends_with(".rst") || path_lower.ends_with(".txt") {
95+
FileCategory::Docs
96+
} else if path_lower.ends_with("Cargo.toml")
97+
|| path_lower.ends_with("package.json")
98+
|| path_lower.ends_with("Makefile")
99+
|| path_lower.ends_with("build.gradle")
100+
|| path_lower.contains("cmake")
101+
{
102+
FileCategory::Build
103+
} else if path_lower.ends_with(".yml")
104+
|| path_lower.ends_with(".yaml")
105+
|| path_lower.ends_with(".json")
106+
|| path_lower.ends_with(".toml")
107+
|| path_lower.ends_with(".ini")
108+
|| path_lower.ends_with(".cfg")
109+
|| path_lower.ends_with(".conf")
110+
|| path_lower.contains("config")
111+
|| path_lower.contains(".github/")
112+
{
113+
FileCategory::Config
114+
} else if path_lower.ends_with(".png")
115+
|| path_lower.ends_with(".jpg")
116+
|| path_lower.ends_with(".gif")
117+
|| path_lower.ends_with(".ico")
118+
|| path_lower.ends_with(".pdf")
119+
|| path_lower.ends_with(".zip")
120+
{
121+
FileCategory::Binary
122+
} else {
123+
FileCategory::Source
124+
}
125+
}
126+
127+
// Conversion from old FileAnalysisResult to new FileAnalysis
128+
impl From<crate::multi_step_analysis::FileAnalysisResult> for FileAnalysis {
129+
fn from(result: crate::multi_step_analysis::FileAnalysisResult) -> Self {
130+
FileAnalysis {
131+
lines_added: result.lines_added,
132+
lines_removed: result.lines_removed,
133+
category: FileCategory::from(result.file_category.as_str()),
134+
summary: result.summary,
135+
}
136+
}
137+
}
138+
139+
#[cfg(test)]
140+
mod tests {
141+
use super::*;
142+
143+
#[test]
144+
fn test_file_categorization() {
145+
assert_eq!(categorize_file("src/main.rs"), FileCategory::Source);
146+
assert_eq!(categorize_file("tests/integration_test.rs"), FileCategory::Test);
147+
assert_eq!(categorize_file("package.json"), FileCategory::Build);
148+
assert_eq!(categorize_file(".github/workflows/ci.yml"), FileCategory::Config);
149+
assert_eq!(categorize_file("README.md"), FileCategory::Docs);
150+
assert_eq!(categorize_file("logo.png"), FileCategory::Binary);
151+
}
152+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//! Commit message candidate generation and selection.
2+
3+
use super::scoring::ImpactScore;
4+
5+
pub struct Candidate {
6+
pub message: String,
7+
pub style: CandidateStyle,
8+
}
9+
10+
pub enum CandidateStyle {
11+
Action, // "Add authentication"
12+
Component, // "auth: implementation"
13+
Impact, // "New feature for authentication"
14+
}
15+
16+
pub fn generate_candidates(
17+
scored_files: &[ImpactScore],
18+
max_length: usize,
19+
) -> Vec<Candidate> {
20+
// This will be moved from multi_step_analysis.rs generate_commit_messages
21+
// For now, delegate to the old implementation
22+
let files_with_scores: Vec<crate::multi_step_analysis::FileWithScore> = scored_files
23+
.iter()
24+
.map(|impact_score| crate::multi_step_analysis::FileWithScore {
25+
file_path: impact_score.file_path.clone(),
26+
operation_type: impact_score.operation.clone(),
27+
lines_added: impact_score.analysis.lines_added,
28+
lines_removed: impact_score.analysis.lines_removed,
29+
file_category: impact_score.analysis.category.as_str().to_string(),
30+
summary: impact_score.analysis.summary.clone(),
31+
impact_score: impact_score.score,
32+
})
33+
.collect();
34+
35+
let generate_result = crate::multi_step_analysis::generate_commit_messages(files_with_scores, max_length);
36+
37+
// Convert to new Candidate format
38+
generate_result
39+
.candidates
40+
.into_iter()
41+
.enumerate()
42+
.map(|(i, message)| {
43+
let style = match i % 3 {
44+
0 => CandidateStyle::Action,
45+
1 => CandidateStyle::Component,
46+
_ => CandidateStyle::Impact,
47+
};
48+
Candidate { message, style }
49+
})
50+
.collect()
51+
}
52+
53+
pub fn select_best_candidate(candidates: &[Candidate]) -> Option<String> {
54+
// For now, select the first candidate (action-focused)
55+
candidates.first().map(|c| c.message.clone())
56+
}

src/generation/multi_step/local.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//! Local generation fallback (no API required).
2+
3+
use anyhow::Result;
4+
5+
pub fn generate_simple(
6+
diff: &str,
7+
max_length: usize,
8+
) -> Result<String> {
9+
// This will be moved from simple_multi_step.rs generate_commit_message_simple_local
10+
// For now, use the local multi-step approach
11+
crate::multi_step_integration::generate_commit_message_local(diff, Some(max_length))
12+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//! Impact scoring for analyzed files.
2+
3+
use super::analysis::FileAnalysis;
4+
5+
pub struct ImpactScore {
6+
pub file_path: String,
7+
pub operation: String,
8+
pub analysis: FileAnalysis,
9+
pub score: f32,
10+
}
11+
12+
pub fn calculate_impact_scores(
13+
files: Vec<(String, String, FileAnalysis)>,
14+
) -> Vec<ImpactScore> {
15+
// This will be moved from multi_step_analysis.rs calculate_impact_scores
16+
// For now, delegate to the old implementation
17+
let files_data: Vec<crate::multi_step_analysis::FileDataForScoring> = files
18+
.iter()
19+
.map(|(path, operation, analysis)| {
20+
crate::multi_step_analysis::FileDataForScoring {
21+
file_path: path.clone(),
22+
operation_type: operation.clone(),
23+
lines_added: analysis.lines_added,
24+
lines_removed: analysis.lines_removed,
25+
file_category: analysis.category.as_str().to_string(),
26+
summary: analysis.summary.clone(),
27+
}
28+
})
29+
.collect();
30+
31+
let score_result = crate::multi_step_analysis::calculate_impact_scores(files_data);
32+
33+
score_result
34+
.files_with_scores
35+
.into_iter()
36+
.map(|file_with_score| ImpactScore {
37+
file_path: file_with_score.file_path,
38+
operation: file_with_score.operation_type,
39+
analysis: FileAnalysis {
40+
lines_added: file_with_score.lines_added,
41+
lines_removed: file_with_score.lines_removed,
42+
category: super::analysis::FileCategory::from(file_with_score.file_category.as_str()),
43+
summary: file_with_score.summary,
44+
},
45+
score: file_with_score.impact_score,
46+
})
47+
.collect()
48+
}
49+
50+
#[allow(dead_code)]
51+
fn calculate_single_score(
52+
operation: &str,
53+
analysis: &FileAnalysis,
54+
) -> f32 {
55+
// Implement locally for now to avoid private function call
56+
let operation_weight = match operation {
57+
"added" => 0.3,
58+
"modified" => 0.2,
59+
"deleted" => 0.25,
60+
"renamed" => 0.1,
61+
"binary" => 0.05,
62+
_ => 0.2, // default for unknown operations
63+
};
64+
65+
let category_weight = match analysis.category {
66+
super::analysis::FileCategory::Source => 0.4,
67+
super::analysis::FileCategory::Test => 0.2,
68+
super::analysis::FileCategory::Config => 0.25,
69+
super::analysis::FileCategory::Build => 0.3,
70+
super::analysis::FileCategory::Docs => 0.1,
71+
super::analysis::FileCategory::Binary => 0.05,
72+
};
73+
74+
let total_lines = analysis.lines_added + analysis.lines_removed;
75+
let lines_normalized = (total_lines as f32 / 100.0).min(1.0);
76+
77+
(operation_weight + category_weight + lines_normalized).min(1.0)
78+
}

0 commit comments

Comments
 (0)