use clap::{Args, Subcommand};
use tga::classify::classifier::{ClassificationEngine, ClassificationEngineConfig};
use tga::classify::rules::{default_rules, load_rules, Rule};
use tga::core::config::Config;
use tga::core::db::Database;
#[derive(Args, Debug)]
#[command(
about = "Introspect or validate the active classification rule set.",
long_about = "Three subcommands for tuning and debugging the classification cascade:\n\n\
tga rules list -- print every rule the engine will load (default + overrides)\n\
tga rules show -- print the verdict and method recorded for a commit SHA\n\
tga rules test -- dry-run the cascade against a single commit message\n\n\
Rules are loaded in priority order: manual overrides (Tier 0) > external ticket\n\
sources (Tier 1) > regex rules (Tier 2) > LLM fallback (Tier 3). This command\n\
helps answer \"why was this commit classified as X?\" and \"will my new rule fire?\".",
after_help = "EXAMPLES:\n\
# Show every rule currently active (built-in + custom --rules file)\n\
tga rules list\n\n\
# Debug the verdict for a specific commit\n\
tga rules show abc123def456\n\n\
# Test a commit message against the current rule set\n\
tga rules test \"fix: resolve null pointer in auth handler\"\n\n\
TIPS:\n\
- Use `tga rules list --rules custom.yaml` to preview a new rule file.\n\
- `tga rules show` reads from the DB; run classify first if the commit is new."
)]
pub struct RulesArgs {
#[command(subcommand)]
pub subcommand: RulesSubcommand,
}
#[derive(Subcommand, Debug)]
pub enum RulesSubcommand {
List(ListArgs),
Show(ShowArgs),
Test(TestArgs),
}
#[derive(Args, Debug)]
pub struct ListArgs {
#[arg(long)]
pub rules: Option<std::path::PathBuf>,
}
#[derive(Args, Debug)]
pub struct ShowArgs {
pub commit_sha: String,
}
#[derive(Args, Debug)]
pub struct TestArgs {
pub message: String,
#[arg(long, default_value_t = false)]
pub is_merge: bool,
#[arg(long)]
pub rules: Option<std::path::PathBuf>,
}
pub fn run(config: Config, db: &Database, args: RulesArgs) -> anyhow::Result<()> {
match args.subcommand {
RulesSubcommand::List(a) => list(&config, a),
RulesSubcommand::Show(a) => show(db, a),
RulesSubcommand::Test(a) => test(&config, a),
}
}
fn list(config: &Config, args: ListArgs) -> anyhow::Result<()> {
let ruleset = resolve_rules(config, args.rules.as_deref())?;
let sorted = ruleset.by_priority();
println!(
"Loaded {} rule(s) (version: {})",
sorted.len(),
ruleset.version.as_deref().unwrap_or("?")
);
println!("(Higher priority fires first within a tier.)\n");
println!(
"{:<26} {:>4} {:<18} {:<18} kw re conf",
"id", "prio", "category", "subcategory"
);
println!("{}", "-".repeat(86));
for r in sorted {
println!(
"{:<26} {:>4} {:<18} {:<18} {:>2} {:>2} {:>4.2}",
r.id,
r.priority,
r.category,
r.subcategory.as_deref().unwrap_or("-"),
r.keywords.len(),
r.patterns.len(),
r.confidence,
);
}
Ok(())
}
struct ShowRow {
category: String,
subcategory: Option<String>,
confidence: f64,
method: String,
ticket_id: Option<String>,
message: String,
}
fn show(db: &Database, args: ShowArgs) -> anyhow::Result<()> {
let conn = db.connection();
let row: Option<ShowRow> = conn
.query_row(
"SELECT cl.category, cl.subcategory, cl.confidence, cl.method, \
cl.ticket_id, c.message \
FROM commits c \
LEFT JOIN classifications cl ON cl.id = c.classification_id \
WHERE c.sha = ?1",
rusqlite::params![args.commit_sha],
|r| {
Ok(ShowRow {
category: r.get::<_, Option<String>>(0)?.unwrap_or_default(),
subcategory: r.get(1)?,
confidence: r.get::<_, Option<f64>>(2)?.unwrap_or(0.0),
method: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
ticket_id: r.get(4)?,
message: r.get(5)?,
})
},
)
.ok();
let Some(ShowRow {
category,
subcategory,
confidence,
method,
ticket_id,
message,
}) = row
else {
println!("No commit found with SHA {}", args.commit_sha);
return Ok(());
};
println!("Commit: {}", args.commit_sha);
println!(
"Message: {}",
message.lines().next().unwrap_or("").trim_end()
);
if method.is_empty() {
println!("Status: not classified (no classification_id)");
println!("Hint: run `tga classify` to populate.");
return Ok(());
}
println!("Verdict:");
println!(" category : {category}");
if let Some(s) = subcategory {
println!(" subcategory : {s}");
}
println!(" method : {method}");
println!(" confidence : {confidence:.2}");
if let Some(t) = ticket_id {
println!(" ticket_id : {t}");
}
Ok(())
}
fn test(config: &Config, args: TestArgs) -> anyhow::Result<()> {
let ruleset = resolve_rules(config, args.rules.as_deref())?;
let engine_cfg = ClassificationEngineConfig::default();
let custom_taxonomy = config
.classification
.as_ref()
.map(|c| c.custom_categories.clone())
.unwrap_or_default();
let jira_mappings = config
.jira
.as_ref()
.map(|j| j.jira_project_mappings.clone())
.unwrap_or_default();
let engine = ClassificationEngine::with_taxonomy_and_mappings(
ruleset,
engine_cfg,
custom_taxonomy,
jira_mappings,
None,
)?;
println!("Message: {}", args.message);
println!("is_merge: {}", args.is_merge);
println!();
match engine.classify_sync(&args.message, args.is_merge) {
Some(verdict) => {
println!("Verdict:");
println!(" category : {}", verdict.category);
if let Some(s) = &verdict.subcategory {
println!(" subcategory : {s}");
}
if let Some(t) = &verdict.top_level {
println!(" top_level : {t:?}");
}
println!(" method : {}", verdict.method.as_str());
println!(" confidence : {:.2}", verdict.confidence);
if let Some(id) = &verdict.ticket_id {
println!(" ticket_id : {id}");
}
}
None => {
println!("No tier matched. The async LLM tier (if enabled) would run next.");
}
}
Ok(())
}
fn resolve_rules(
config: &Config,
cli_rules: Option<&std::path::Path>,
) -> anyhow::Result<tga::classify::rules::RuleSet> {
let path = cli_rules.map(|p| p.to_path_buf()).or_else(|| {
config
.classification
.as_ref()
.and_then(|c| c.rules_file.clone())
});
let ruleset = match path {
Some(p) => {
let custom = load_rules(&p)?;
if custom.extend_defaults {
let mut merged = default_rules();
let custom_ids: std::collections::HashSet<String> =
custom.rules.iter().map(|r: &Rule| r.id.clone()).collect();
merged.rules.retain(|r| !custom_ids.contains(&r.id));
merged.rules.extend(custom.rules);
merged
} else {
custom
}
}
None => default_rules(),
};
Ok(ruleset)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_rules_returns_defaults_without_override() {
let cfg = Config::default();
let rs = resolve_rules(&cfg, None).expect("resolve");
assert!(!rs.rules.is_empty());
assert!(rs.rules.iter().any(|r| r.id == "cc-feat"));
}
#[test]
fn test_subcommand_classifies_conventional_commit_message() {
let cfg = Config::default();
let rs = resolve_rules(&cfg, None).expect("resolve");
let engine = ClassificationEngine::with_taxonomy_and_mappings(
rs,
ClassificationEngineConfig::default(),
Vec::new(),
std::collections::HashMap::new(),
None,
)
.expect("engine");
let v = engine
.classify_sync("feat: add login flow", false)
.expect("verdict");
assert_eq!(v.category, "feature");
}
#[test]
fn show_subcommand_handles_missing_commit_gracefully() {
let db = Database::open_in_memory().expect("db");
let args = ShowArgs {
commit_sha: "does-not-exist".into(),
};
show(&db, args).expect("show should not error on missing SHA");
}
}