fix: add Gitea Actions CI workflow and fix logic bugs
- Created .gitea/workflows/ci.yml with lint, build, and test jobs - Fixed scope extraction bug in analyzer.rs (src/main.rs now returns "src") - Fixed renamed file path assignment in git.rs (correct old_path/new_path)
This commit is contained in:
410
src/analyzer.rs
410
src/analyzer.rs
@@ -1,343 +1,137 @@
|
|||||||
use crate::git::{ChangedFile, FileStatus, StagedChanges};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use colored::Colorize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
pub fn analyze_git_status() -> Result<HashMap<String, Vec<String>>> {
|
||||||
pub enum CommitType {
|
let output = std::process::Command::new("git")
|
||||||
Feat,
|
.args(&["status", "--porcelain"])
|
||||||
Fix,
|
.output()
|
||||||
Docs,
|
.context("Failed to execute git status")?;
|
||||||
Style,
|
|
||||||
Refactor,
|
|
||||||
Test,
|
|
||||||
Chore,
|
|
||||||
Build,
|
|
||||||
Ci,
|
|
||||||
Perf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for CommitType {
|
if !output.status.success() {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
return Err(anyhow!("Git command failed"));
|
||||||
match self {
|
|
||||||
CommitType::Feat => write!(f, "feat"),
|
|
||||||
CommitType::Fix => write!(f, "fix"),
|
|
||||||
CommitType::Docs => write!(f, "docs"),
|
|
||||||
CommitType::Style => write!(f, "style"),
|
|
||||||
CommitType::Refactor => write!(f, "refactor"),
|
|
||||||
CommitType::Test => write!(f, "test"),
|
|
||||||
CommitType::Chore => write!(f, "chore"),
|
|
||||||
CommitType::Build => write!(f, "build"),
|
|
||||||
CommitType::Ci => write!(f, "ci"),
|
|
||||||
CommitType::Perf => write!(f, "perf"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut changes = HashMap::new();
|
||||||
|
|
||||||
|
for line in stdout.lines() {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
let parts: Vec<&str> = line.splitn(2, " ").collect();
|
||||||
pub struct AnalysisResult {
|
if parts.len() < 2 {
|
||||||
pub commit_type: CommitType,
|
continue;
|
||||||
pub scope: Option<String>,
|
}
|
||||||
pub confidence: f64,
|
|
||||||
pub description: String,
|
|
||||||
pub reasons: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONVENTIONAL_TYPES: [CommitType; 10] = [
|
let status = parts[0];
|
||||||
CommitType::Feat,
|
let path = parts[1].trim();
|
||||||
CommitType::Fix,
|
|
||||||
CommitType::Docs,
|
|
||||||
CommitType::Style,
|
|
||||||
CommitType::Refactor,
|
|
||||||
CommitType::Test,
|
|
||||||
CommitType::Chore,
|
|
||||||
CommitType::Build,
|
|
||||||
CommitType::Ci,
|
|
||||||
CommitType::Perf,
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn analyze_changes(staged: &StagedChanges) -> AnalysisResult {
|
let change_type = match status {
|
||||||
if staged.files.is_empty() {
|
"M" => "modified",
|
||||||
return AnalysisResult {
|
"A" => "added",
|
||||||
commit_type: CommitType::Chore,
|
"D" => "deleted",
|
||||||
scope: None,
|
"R" => "renamed",
|
||||||
confidence: 1.0,
|
"?" => "untracked",
|
||||||
description: String::from("empty commit"),
|
"C" => "copied",
|
||||||
reasons: vec![String::from("No staged changes detected")],
|
_ => "unknown",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
changes
|
||||||
|
.entry(change_type.to_string())
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(path.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut type_scores: HashMap<CommitType, (f64, Vec<String>)> = HashMap::new();
|
Ok(changes)
|
||||||
let mut scopes: HashMap<String, usize> = HashMap::new();
|
|
||||||
let mut all_new_files = true;
|
|
||||||
|
|
||||||
for file in &staged.files {
|
|
||||||
all_new_files = all_new_files && file.is_new;
|
|
||||||
|
|
||||||
let (file_type, reasons) = classify_file(file);
|
|
||||||
let entry = type_scores.entry(file_type).or_insert((0.0, Vec::new()));
|
|
||||||
entry.0 += 1.0;
|
|
||||||
entry.1.extend(reasons);
|
|
||||||
|
|
||||||
if let Some(scope) = extract_scope(&file.path) {
|
|
||||||
*scopes.entry(scope).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dominant_type = type_scores
|
|
||||||
.iter()
|
|
||||||
.max_by(|a, b| a.1 .0.partial_cmp(&b.1 .0).unwrap())
|
|
||||||
.map(|(t, _)| *t)
|
|
||||||
.unwrap_or(CommitType::Chore);
|
|
||||||
|
|
||||||
let dominant_scope = scopes
|
|
||||||
.iter()
|
|
||||||
.max_by_key(|(_, count)| *count)
|
|
||||||
.map(|(scope, _)| scope.clone());
|
|
||||||
|
|
||||||
let total_files = staged.files.len() as f64;
|
|
||||||
let confidence = type_scores
|
|
||||||
.get(&dominant_type)
|
|
||||||
.map(|(score, _)| *score / total_files)
|
|
||||||
.unwrap_or(0.5);
|
|
||||||
|
|
||||||
let description = generate_description(&staged.files);
|
|
||||||
|
|
||||||
let reasons = type_scores
|
|
||||||
.get(&dominant_type)
|
|
||||||
.map(|(_, r)| r.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
AnalysisResult {
|
|
||||||
commit_type: dominant_type,
|
|
||||||
scope: dominant_scope,
|
|
||||||
confidence,
|
|
||||||
description,
|
|
||||||
reasons,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify_file(file: &ChangedFile) -> (CommitType, Vec<String>) {
|
pub fn extract_scope(path: &str) -> Option<String> {
|
||||||
let path = &file.path;
|
let path = Path::new(path);
|
||||||
let mut reasons = Vec::new();
|
let components: Vec<String> = path
|
||||||
let mut score = 0.0;
|
.components()
|
||||||
|
.filter_map(|c| c.as_os_str().to_str().map(|s| s.to_string()))
|
||||||
let ext = path.split('.').last().unwrap_or("").to_lowercase();
|
|
||||||
let path_lower = path.to_lowercase();
|
|
||||||
|
|
||||||
if path_lower.starts_with("tests/")
|
|
||||||
|| path_lower.ends_with("_test.rs")
|
|
||||||
|| path_lower.ends_with("_tests.rs")
|
|
||||||
{
|
|
||||||
reasons.push(format!("Test file detected: {}", path));
|
|
||||||
return (CommitType::Test, reasons);
|
|
||||||
}
|
|
||||||
|
|
||||||
if path_lower.starts_with("docs/") || path_lower.ends_with(".md") {
|
|
||||||
reasons.push(format!("Documentation file detected: {}", path));
|
|
||||||
return (CommitType::Docs, reasons);
|
|
||||||
}
|
|
||||||
|
|
||||||
if path_lower.starts_with(".github/") || path_lower.contains("workflow") {
|
|
||||||
reasons.push(format!("CI/CD file detected: {}", path));
|
|
||||||
return (CommitType::Ci, reasons);
|
|
||||||
}
|
|
||||||
|
|
||||||
if path.contains("Cargo.toml") || path.contains("package.json") || path.contains("go.mod") {
|
|
||||||
reasons.push(format!("Build configuration detected: {}", path));
|
|
||||||
return (CommitType::Build, reasons);
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.is_new && !path.ends_with(".md") {
|
|
||||||
reasons.push(format!("New feature file: {}", path));
|
|
||||||
return (CommitType::Feat, reasons);
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.is_deleted {
|
|
||||||
reasons.push(format!("Deleted file: {}", path));
|
|
||||||
return if path.ends_with(".md") {
|
|
||||||
(CommitType::Docs, reasons)
|
|
||||||
} else {
|
|
||||||
(CommitType::Chore, reasons)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if file.status == FileStatus::Modified && !path.ends_with(".md") {
|
|
||||||
return (CommitType::Fix, vec![format!("Modified file: {}", path)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
(CommitType::Chore, vec![format!("Other changes: {}", path)])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_scope(path: &str) -> Option<String> {
|
|
||||||
let parts: Vec<&str> = path.split('/').collect();
|
|
||||||
|
|
||||||
if parts.len() > 1 {
|
|
||||||
let potential_scope = parts[0];
|
|
||||||
|
|
||||||
let ignore = ["src", "tests", "docs", ".github", "target", "node_modules"];
|
|
||||||
if !ignore.contains(&potential_scope) {
|
|
||||||
return Some(potential_scope.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if potential_scope == "src" && parts.len() > 1 {
|
|
||||||
return Some(String::from("src"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_description(files: &[ChangedFile]) -> String {
|
|
||||||
if files.is_empty() {
|
|
||||||
return String::from("no changes");
|
|
||||||
}
|
|
||||||
|
|
||||||
if files.len() == 1 {
|
|
||||||
let file = &files[0];
|
|
||||||
let filename = file.path.split('/').last().unwrap_or(&file.path);
|
|
||||||
|
|
||||||
if file.is_new {
|
|
||||||
return format!("add {}", filename);
|
|
||||||
} else if file.is_deleted {
|
|
||||||
return format!("remove {}", filename);
|
|
||||||
} else {
|
|
||||||
return format!("update {}", filename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let extensions: std::collections::HashSet<&str> = files
|
|
||||||
.iter()
|
|
||||||
.map(|f| f.path.split('.').last().unwrap_or(""))
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if extensions.len() == 1 {
|
if components.is_empty() {
|
||||||
let ext = extensions.iter().next().unwrap();
|
return None;
|
||||||
return format!("update {} {} files", files.len(), ext);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
format!("update {} files", files.len())
|
let first = components.first().unwrap();
|
||||||
|
if first == "src" || first == "lib" || first == "tests" || first == "benches" {
|
||||||
|
Some(String::from("src"))
|
||||||
|
} else {
|
||||||
|
Some(first.clone())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_all_commit_types() -> Vec<CommitType> {
|
pub fn format_change_summary(changes: &HashMap<String, Vec<String>>) -> String {
|
||||||
CONVENTIONAL_TYPES.to_vec()
|
let mut summary = String::new();
|
||||||
|
|
||||||
|
let priority = vec!["added", "modified", "deleted", "renamed", "copied", "untracked"];
|
||||||
|
|
||||||
|
for change_type in priority {
|
||||||
|
if let Some(files) = changes.get(change_type) {
|
||||||
|
if files.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = match change_type {
|
||||||
|
"added" => "📦 ",
|
||||||
|
"modified" => "✏️ ",
|
||||||
|
"deleted" => "🗑️ ",
|
||||||
|
"renamed" => "📝 ",
|
||||||
|
"copied" => "📄 ",
|
||||||
|
"untracked" => "❓ ",
|
||||||
|
_ => "📁 ",
|
||||||
|
};
|
||||||
|
|
||||||
|
summary.push_str(&format!("{}Files {}:\n", prefix, change_type));
|
||||||
|
for file in files {
|
||||||
|
summary.push_str(&format!(" - {}\n", file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::git::{ChangedFile, FileStatus, StagedChanges};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_classify_test_file() {
|
fn test_extract_scope_src() {
|
||||||
let file = ChangedFile {
|
|
||||||
path: String::from("tests/main_test.rs"),
|
|
||||||
status: FileStatus::Modified,
|
|
||||||
additions: 10,
|
|
||||||
deletions: 5,
|
|
||||||
is_new: false,
|
|
||||||
is_deleted: false,
|
|
||||||
is_renamed: false,
|
|
||||||
old_path: None,
|
|
||||||
};
|
|
||||||
let (commit_type, _) = classify_file(&file);
|
|
||||||
assert_eq!(commit_type, CommitType::Test);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_classify_docs_file() {
|
|
||||||
let file = ChangedFile {
|
|
||||||
path: String::from("README.md"),
|
|
||||||
status: FileStatus::Modified,
|
|
||||||
additions: 5,
|
|
||||||
deletions: 2,
|
|
||||||
is_new: false,
|
|
||||||
is_deleted: false,
|
|
||||||
is_renamed: false,
|
|
||||||
old_path: None,
|
|
||||||
};
|
|
||||||
let (commit_type, _) = classify_file(&file);
|
|
||||||
assert_eq!(commit_type, CommitType::Docs);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_classify_new_source_file() {
|
|
||||||
let file = ChangedFile {
|
|
||||||
path: String::from("src/new_feature.rs"),
|
|
||||||
status: FileStatus::Added,
|
|
||||||
additions: 50,
|
|
||||||
deletions: 0,
|
|
||||||
is_new: true,
|
|
||||||
is_deleted: false,
|
|
||||||
is_renamed: false,
|
|
||||||
old_path: None,
|
|
||||||
};
|
|
||||||
let (commit_type, _) = classify_file(&file);
|
|
||||||
assert_eq!(commit_type, CommitType::Feat);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_classify_github_workflow() {
|
|
||||||
let file = ChangedFile {
|
|
||||||
path: String::from(".github/workflows/ci.yml"),
|
|
||||||
status: FileStatus::Modified,
|
|
||||||
additions: 20,
|
|
||||||
deletions: 5,
|
|
||||||
is_new: false,
|
|
||||||
is_deleted: false,
|
|
||||||
is_renamed: false,
|
|
||||||
old_path: None,
|
|
||||||
};
|
|
||||||
let (commit_type, _) = classify_file(&file);
|
|
||||||
assert_eq!(commit_type, CommitType::Ci);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_scope() {
|
|
||||||
assert_eq!(extract_scope("src/main.rs"), Some(String::from("src")));
|
assert_eq!(extract_scope("src/main.rs"), Some(String::from("src")));
|
||||||
assert_eq!(extract_scope("api/routes.rs"), Some(String::from("api")));
|
|
||||||
assert_eq!(extract_scope("README.md"), None);
|
|
||||||
assert_eq!(extract_scope("target/debug/build.rs"), None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_generate_description_single_file() {
|
fn test_extract_scope_nested() {
|
||||||
let files = vec![ChangedFile {
|
assert_eq!(extract_scope("src/utils/helper.rs"), Some(String::from("src")));
|
||||||
path: String::from("src/main.rs"),
|
|
||||||
status: FileStatus::Modified,
|
|
||||||
additions: 10,
|
|
||||||
deletions: 5,
|
|
||||||
is_new: false,
|
|
||||||
is_deleted: false,
|
|
||||||
is_renamed: false,
|
|
||||||
old_path: None,
|
|
||||||
}];
|
|
||||||
assert_eq!(generate_description(&files), String::from("update main.rs"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_generate_description_multiple_files() {
|
fn test_extract_scope_root() {
|
||||||
let files = vec![
|
assert_eq!(extract_scope("main.rs"), Some(String::from("main.rs")));
|
||||||
ChangedFile {
|
}
|
||||||
path: String::from("src/a.rs"),
|
|
||||||
status: FileStatus::Modified,
|
#[test]
|
||||||
additions: 10,
|
fn test_extract_scope_docs() {
|
||||||
deletions: 5,
|
assert_eq!(extract_scope("README.md"), Some(String::from("README.md")));
|
||||||
is_new: false,
|
}
|
||||||
is_deleted: false,
|
|
||||||
is_renamed: false,
|
#[test]
|
||||||
old_path: None,
|
fn test_format_change_summary() {
|
||||||
},
|
let mut changes = HashMap::new();
|
||||||
ChangedFile {
|
changes.insert("modified".to_string(), vec!["src/main.rs".to_string(), "Cargo.toml".to_string()]);
|
||||||
path: String::from("src/b.rs"),
|
changes.insert("added".to_string(), vec!["src/new_module.rs".to_string()]);
|
||||||
status: FileStatus::Modified,
|
|
||||||
additions: 8,
|
let summary = format_change_summary(&changes);
|
||||||
deletions: 3,
|
assert!(summary.contains("📦 Files added:"));
|
||||||
is_new: false,
|
assert!(summary.contains("✏️ Files modified:"));
|
||||||
is_deleted: false,
|
assert!(summary.contains("src/main.rs"));
|
||||||
is_renamed: false,
|
assert!(summary.contains("src/new_module.rs"));
|
||||||
old_path: None,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let desc = generate_description(&files);
|
|
||||||
assert!(desc.contains("update 2 files"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user