use std::collections::HashMap;
use std::path::Path;
use crate::classify::errors::{ClassifyError, Result};
use crate::classify::rules::loader::load_rules;
use crate::classify::rules::types::RuleSet;
use crate::classify::tiers::ClassificationResult;
pub fn load_rules_multi(paths: &[&Path]) -> Result<RuleSet> {
if paths.is_empty() {
return Err(ClassifyError::RuleLoad(
"rules_files is empty — at least one file is required".to_string(),
));
}
let mut merged: Option<RuleSet> = None;
for path in paths {
let set = load_rules(path)?;
merged = Some(match merged {
None => set,
Some(acc) => acc.merge(set),
});
}
Ok(merged.expect("at least one file was loaded"))
}
pub fn repo_matches(repo_name: &str, pattern: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
if prefix.is_empty() {
return true; }
return repo_name.starts_with(prefix);
}
if let Some(suffix) = pattern.strip_prefix('*') {
if suffix.is_empty() {
return true;
}
return repo_name.ends_with(suffix);
}
if let Some(pos) = pattern.find('*') {
let prefix = &pattern[..pos];
let suffix = &pattern[pos + 1..];
return repo_name.starts_with(prefix) && repo_name.ends_with(suffix);
}
repo_name == pattern
}
pub fn apply_repo_category_fallback(
result: &mut ClassificationResult,
repo_name: &str,
repo_categories: &HashMap<String, String>,
confidence_threshold: f64,
) {
let is_uncategorized = result.subcategory.as_deref() == Some("uncategorized")
|| result.category == "uncategorized"
|| result.category.is_empty();
let is_low_confidence = result.confidence < confidence_threshold;
if !is_uncategorized && !is_low_confidence {
return; }
let matched_subcategory = repo_categories
.get(repo_name)
.or_else(|| {
repo_categories
.iter()
.find(|(k, _)| k.contains('*') && repo_matches(repo_name, k))
.map(|(_, v)| v)
})
.cloned();
if let Some(subcategory) = matched_subcategory {
tracing::debug!(
repo = repo_name,
old_category = %result.category,
old_subcategory = ?result.subcategory,
old_confidence = result.confidence,
new_subcategory = %subcategory,
"repo_categories fallback applied"
);
result.subcategory = Some(subcategory.clone());
result.category = subcategory;
result.method = crate::core::models::ClassificationMethod::RepoCategoryFallback;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_yaml(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::with_suffix(".yaml").expect("create temp file");
f.write_all(content.as_bytes()).expect("write yaml");
f
}
#[test]
fn multiple_files_merge_in_order() {
let yaml_a = r#"
rules:
- id: rule-a
category: feature
keywords: ["feat:"]
confidence: 0.8
- id: rule-c
category: chore
keywords: ["chore:"]
"#;
let yaml_b = r#"
rules:
- id: rule-a
category: feature
keywords: ["feat:", "feature:"]
confidence: 0.95
- id: rule-b
category: bugfix
keywords: ["fix:"]
"#;
let file_a = write_yaml(yaml_a);
let file_b = write_yaml(yaml_b);
let merged = load_rules_multi(&[file_a.path(), file_b.path()]).expect("load");
let rule_a = merged
.rules
.iter()
.find(|r| r.id == "rule-a")
.expect("rule-a");
assert!(
(rule_a.confidence - 0.95).abs() < 1e-9,
"later file must override earlier: got confidence {}",
rule_a.confidence
);
assert!(
merged.rules.iter().any(|r| r.id == "rule-b"),
"rule-b from file B must be present"
);
assert!(
merged.rules.iter().any(|r| r.id == "rule-c"),
"rule-c from file A must be present"
);
}
#[test]
fn single_file_load_works() {
let yaml = r#"
rules:
- id: cc-feat
category: feature
keywords: ["feat:"]
"#;
let f = write_yaml(yaml);
let set = load_rules_multi(&[f.path()]).expect("single-file load");
assert_eq!(set.rules.len(), 1);
}
#[test]
fn repo_glob_matching() {
assert!(repo_matches("infra-api", "infra-*"));
assert!(repo_matches("infra-web", "infra-*"));
assert!(!repo_matches("platform-api", "infra-*"));
assert!(repo_matches("anything", "*"));
assert!(repo_matches("api-service", "*service"));
assert!(!repo_matches("api-tools", "*service"));
assert!(repo_matches("exactname", "exactname"));
assert!(!repo_matches("other", "exactname"));
}
fn make_low_confidence_result() -> ClassificationResult {
ClassificationResult {
category: "maintenance".to_string(),
subcategory: Some("uncategorized".to_string()),
top_level: None,
confidence: 0.3,
method: crate::core::models::ClassificationMethod::CatchAll,
ticket_id: None,
complexity: None,
}
}
fn make_confident_result() -> ClassificationResult {
ClassificationResult {
category: "feature".to_string(),
subcategory: Some("api".to_string()),
top_level: None,
confidence: 0.95,
method: crate::core::models::ClassificationMethod::ExactRule,
ticket_id: None,
complexity: None,
}
}
#[test]
fn repo_category_applies_to_uncategorized() {
let mut result = make_low_confidence_result();
let mut repo_categories = HashMap::new();
repo_categories.insert(
"infra-api".to_string(),
"platform_infrastructure".to_string(),
);
apply_repo_category_fallback(&mut result, "infra-api", &repo_categories, 0.7);
assert_eq!(result.category, "platform_infrastructure");
assert_eq!(
result.subcategory,
Some("platform_infrastructure".to_string())
);
assert!(matches!(
result.method,
crate::core::models::ClassificationMethod::RepoCategoryFallback
));
}
#[test]
fn repo_category_skips_confident_result() {
let mut result = make_confident_result();
let mut repo_categories = HashMap::new();
repo_categories.insert(
"infra-api".to_string(),
"platform_infrastructure".to_string(),
);
apply_repo_category_fallback(&mut result, "infra-api", &repo_categories, 0.7);
assert_eq!(result.category, "feature");
assert!(matches!(
result.method,
crate::core::models::ClassificationMethod::ExactRule
));
}
#[test]
fn repo_category_glob_match() {
let mut result = make_low_confidence_result();
let mut repo_categories = HashMap::new();
repo_categories.insert("infra-*".to_string(), "platform_infrastructure".to_string());
apply_repo_category_fallback(&mut result, "infra-payments", &repo_categories, 0.7);
assert_eq!(result.category, "platform_infrastructure");
}
#[test]
fn repo_category_no_match_leaves_result_unchanged() {
let mut result = make_low_confidence_result();
let repo_categories = HashMap::new();
apply_repo_category_fallback(&mut result, "unknown-repo", &repo_categories, 0.7);
assert_eq!(result.category, "maintenance");
assert_eq!(result.subcategory, Some("uncategorized".to_string()));
}
}