Skip to main content

oxi_agent/tools/
commit.rs

1//! Conventional-commit tool.
2//!
3//! Produces atomic, conventional-commits-formatted commits from the working
4//! tree. The deterministic core (scope extraction, validation, Kahn
5//! topological ordering, message formatting) needs no LLM and is unit-tested;
6//! the [`CommitTool`] wraps it with optional LLM analysis via
7//! `oxi_ai::complete`.
8//!
9//! Ported from omp's `commit/` subsystem (~3,000 lines), keeping the
10//! deterministic heuristics verbatim and replacing the agentic pipeline with a
11//! single LLM analysis call plus a deterministic fallback.
12
13use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
14use crate::tools::typed::TypedTool;
15use async_trait::async_trait;
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19use std::collections::{HashMap, HashSet, VecDeque};
20use std::path::{Path, PathBuf};
21use tokio::sync::oneshot;
22
23// ═══════════════════════════════════════════════════════════════════════════
24// Core types
25// ═══════════════════════════════════════════════════════════════════════════
26
27/// Conventional commit type, per the Conventional Commits specification.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum CommitType {
31    /// A new feature.
32    Feat,
33    /// A bug fix.
34    Fix,
35    /// Documentation only changes.
36    Docs,
37    /// Changes that do not affect the meaning of the code (white-space,
38    /// formatting, missing semi-colons, etc).
39    Style,
40    /// A code change that neither fixes a bug nor adds a feature.
41    Refactor,
42    /// A code change that improves performance.
43    Perf,
44    /// Adding missing tests or correcting existing ones.
45    Test,
46    /// Changes that affect the build system or external dependencies.
47    Build,
48    /// Changes to CI configuration files and scripts.
49    Ci,
50    /// Other changes that don't modify `src` or `test` files.
51    Chore,
52    /// Reverts a previous commit.
53    Revert,
54}
55
56impl CommitType {
57    /// Lowercase identifier used in commit headers (e.g. `"feat"`).
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            Self::Feat => "feat",
61            Self::Fix => "fix",
62            Self::Docs => "docs",
63            Self::Style => "style",
64            Self::Refactor => "refactor",
65            Self::Perf => "perf",
66            Self::Test => "test",
67            Self::Build => "build",
68            Self::Ci => "ci",
69            Self::Chore => "chore",
70            Self::Revert => "revert",
71        }
72    }
73
74    /// Parse a commit type from its lowercase identifier.
75    ///
76    /// Returns `None` for unknown identifiers.
77    pub fn from_id(id: &str) -> Option<Self> {
78        match id {
79            "feat" => Some(Self::Feat),
80            "fix" => Some(Self::Fix),
81            "docs" => Some(Self::Docs),
82            "style" => Some(Self::Style),
83            "refactor" => Some(Self::Refactor),
84            "perf" => Some(Self::Perf),
85            "test" => Some(Self::Test),
86            "build" => Some(Self::Build),
87            "ci" => Some(Self::Ci),
88            "chore" => Some(Self::Chore),
89            "revert" => Some(Self::Revert),
90            _ => None,
91        }
92    }
93}
94
95impl std::fmt::Display for CommitType {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101/// Keep-a-Changelog category for a single detail line.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum ChangelogCategory {
105    /// New features for users.
106    Added,
107    /// Changes to existing functionality.
108    Changed,
109    /// Soon-to-be removed features.
110    Deprecated,
111    /// Removed features.
112    Removed,
113    /// Bug fixes.
114    Fixed,
115    /// Vulnerability fixes.
116    Security,
117    /// Internal changes invisible to users.
118    Internal,
119}
120
121/// A single bullet point inside a conventional commit body.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ConventionalDetail {
124    /// Detail text (conventionally ≤120 chars, ending with a period).
125    pub text: String,
126    /// Optional Keep-a-Changelog category driving changelog generation.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub changelog_category: Option<ChangelogCategory>,
129    /// Whether this detail is user-visible (versus internal).
130    #[serde(default = "default_true")]
131    pub user_visible: bool,
132}
133
134/// Default value for [`ConventionalDetail::user_visible`].
135fn default_true() -> bool {
136    true
137}
138
139/// Result of conventional-commit analysis — the heart of every generated
140/// commit message.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ConventionalAnalysis {
143    /// Conventional commit type (`feat`, `fix`, …).
144    #[serde(rename = "type")]
145    pub commit_type: CommitType,
146    /// Optional scope (≤2 path segments, lowercase).
147    pub scope: String,
148    /// Bullet-point details for the commit body.
149    pub details: Vec<ConventionalDetail>,
150    /// Issue references (e.g. `["#42"]`).
151    #[serde(default)]
152    pub issue_refs: Vec<String>,
153}
154
155/// One atomic commit group produced by split-commit planning.
156#[derive(Debug, Clone)]
157pub struct CommitGroup {
158    /// Stable group identifier.
159    pub id: String,
160    /// Files included in this commit.
161    pub files: Vec<String>,
162    /// Conventional analysis for this group.
163    pub analysis: ConventionalAnalysis,
164    /// Summary line.
165    pub summary: String,
166    /// IDs of groups that must be committed before this one.
167    pub dependencies: Vec<String>,
168}
169
170// ═══════════════════════════════════════════════════════════════════════════
171// numstat + scope extraction (deterministic, no LLM)
172// ═══════════════════════════════════════════════════════════════════════════
173
174/// One row of `git diff --numstat` output.
175#[derive(Debug, Clone)]
176pub struct NumstatEntry {
177    /// Changed file path (as reported by git).
178    pub path: String,
179    /// Lines added.
180    pub additions: usize,
181    /// Lines deleted.
182    pub deletions: usize,
183}
184
185/// A scope candidate derived from path analysis, ranked by weighted churn.
186#[derive(Debug, Clone)]
187pub struct ScopeCandidate {
188    /// Candidate scope name (1–2 path segments).
189    pub name: String,
190    /// Weighted line churn (2-segment names boosted ×1.2, 1-segment ×0.8).
191    pub weight: f64,
192    /// Number of path segments in the scope name.
193    pub segments: usize,
194}
195
196/// Lock files and other generated artifacts excluded from scope analysis.
197///
198/// Ported from omp's `commit/utils/exclusions.ts` — these churn heavily but
199/// carry no semantic signal, so they would otherwise dominate scope ranking.
200const EXCLUDED_FILES: &[&str] = &[
201    "Cargo.lock",
202    "package-lock.json",
203    "npm-shrinkwrap.json",
204    "yarn.lock",
205    "pnpm-lock.yaml",
206    "shrinkwrap.yaml",
207    "bun.lock",
208    "bun.lockb",
209    "deno.lock",
210    "composer.lock",
211    "Gemfile.lock",
212    "poetry.lock",
213    "Pipfile.lock",
214    "pdm.lock",
215    "uv.lock",
216    "go.sum",
217    "flake.lock",
218    "pubspec.lock",
219    "Podfile.lock",
220    "Packages.resolved",
221    "mix.lock",
222    "packages.lock.json",
223];
224
225/// Suffixes marking a file as a generated lock artifact.
226const EXCLUDED_SUFFIXES: &[&str] = &[
227    ".lock.yml",
228    ".lock.yaml",
229    "-lock.yml",
230    "-lock.yaml",
231    "config.yml.lock",
232    "config.yaml.lock",
233    "settings.yml.lock",
234    "settings.yaml.lock",
235];
236
237/// Returns `true` if `path` is a lock file or other generated artifact that
238/// should be excluded from conventional-commit analysis.
239pub fn is_excluded_file(path: &str) -> bool {
240    let lower = path.to_ascii_lowercase();
241    EXCLUDED_FILES
242        .iter()
243        .any(|name| lower.ends_with(&name.to_ascii_lowercase()))
244        || EXCLUDED_SUFFIXES
245            .iter()
246            .any(|suffix| lower.ends_with(suffix))
247}
248
249/// Directory segments too generic to count as a distinct change root.
250const PLACEHOLDER_DIRS: &[&str] = &["src", "lib", "bin", "app", "cmd", "internal", "main"];
251
252/// Extract a 1–2 segment directory component from a file path.
253///
254/// The final path segment (the filename) is never part of the component —
255/// scopes are directory groupings. For a bare filename with no directory the
256/// extension is stripped, so `README.md` yields `README`. For deeper paths
257/// the first two directory segments are taken: `src/auth/login.rs` yields
258/// `src/auth`, while `src/main.rs` yields `src`.
259fn extract_path_component(path: &str) -> String {
260    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
261    if segments.is_empty() {
262        return String::new();
263    }
264    // Directory segments exclude the final (filename) segment.
265    let dirs = &segments[..segments.len() - 1];
266    if dirs.is_empty() {
267        // Bare filename: use the stem without extension.
268        return segments[0]
269            .split('.')
270            .next()
271            .unwrap_or(segments[0])
272            .to_string();
273    }
274    let take = dirs.len().min(2);
275    dirs[..take].join("/")
276}
277
278/// Deterministically rank scope candidates from numstat by weighted line churn.
279///
280/// A port of omp's `analysis/scope.ts` that needs no LLM: each changed path is
281/// mapped to a 1–2 segment component, churn is summed per component,
282/// 2-segment components are boosted (×1.2) and single-segment ones dampened
283/// (×0.8), then the list is sorted by descending weight.
284pub fn extract_scope_candidates(numstat: &[NumstatEntry]) -> Vec<ScopeCandidate> {
285    let mut components: HashMap<String, usize> = HashMap::new();
286    for entry in numstat {
287        if is_excluded_file(&entry.path) {
288            continue;
289        }
290        let component = extract_path_component(&entry.path);
291        if component.is_empty() {
292            continue;
293        }
294        *components.entry(component).or_default() += entry.additions + entry.deletions;
295    }
296
297    let mut candidates: Vec<ScopeCandidate> = components
298        .into_iter()
299        .map(|(name, lines)| {
300            let segments = name.split('/').count();
301            ScopeCandidate {
302                name,
303                weight: lines as f64,
304                segments,
305            }
306        })
307        .collect();
308
309    for candidate in &mut candidates {
310        candidate.weight *= if candidate.segments >= 2 { 1.2 } else { 0.8 };
311    }
312
313    candidates.sort_by(|a, b| {
314        b.weight
315            .partial_cmp(&a.weight)
316            .unwrap_or(std::cmp::Ordering::Equal)
317    });
318    candidates
319}
320
321/// Returns `true` when the change set is too broad for a single scope.
322///
323/// Triggers when the top candidate holds <60% of total churn, or when there
324/// are ≥3 distinct non-placeholder roots. Used to decide whether to collapse
325/// the scope or keep a broad marker.
326pub fn is_wide_change(numstat: &[NumstatEntry]) -> bool {
327    let candidates = extract_scope_candidates(numstat);
328    if candidates.is_empty() {
329        return false;
330    }
331    let total: f64 = candidates.iter().map(|c| c.weight).sum();
332    let top_share = if total > 0.0 {
333        candidates[0].weight / total
334    } else {
335        0.0
336    };
337    let distinct_roots = candidates
338        .iter()
339        .filter(|c| {
340            let root = c.name.split('/').next().unwrap_or("");
341            !PLACEHOLDER_DIRS.contains(&root)
342        })
343        .count();
344    top_share < 0.6 || distinct_roots >= 3
345}
346
347/// Parse `git diff --numstat` output into [`NumstatEntry`] rows.
348///
349/// Each line is `<additions>\t<deletions>\t<path>`. Binary files report `-`
350/// for the counts, which parse to `0`.
351pub fn parse_numstat(output: &str) -> Vec<NumstatEntry> {
352    output.lines().filter_map(parse_numstat_line).collect()
353}
354
355fn parse_numstat_line(line: &str) -> Option<NumstatEntry> {
356    let mut parts = line.splitn(3, '\t');
357    let additions_raw = parts.next()?;
358    let deletions_raw = parts.next()?;
359    let path = parts.next()?;
360    if path.is_empty() {
361        return None;
362    }
363    let additions = additions_raw.parse::<usize>().unwrap_or(0);
364    let deletions = deletions_raw.parse::<usize>().unwrap_or(0);
365    Some(NumstatEntry {
366        path: path.to_string(),
367        additions,
368        deletions,
369    })
370}
371
372// ═══════════════════════════════════════════════════════════════════════════
373// Message formatting
374// ═══════════════════════════════════════════════════════════════════════════
375
376/// Format a conventional commit message from an analysis and summary line.
377///
378/// Produces `type(scope): summary` (or `type: summary` when the scope is
379/// empty) followed by a blank line and `- detail` bullets, then an optional
380/// `Refs` footer.
381pub fn format_commit_message(analysis: &ConventionalAnalysis, summary: &str) -> String {
382    let header = if analysis.scope.is_empty() {
383        format!("{}: {}", analysis.commit_type, summary)
384    } else {
385        format!("{}({}): {}", analysis.commit_type, analysis.scope, summary)
386    };
387
388    let mut message = header;
389    if !analysis.details.is_empty() {
390        message.push_str("\n\n");
391        message.push_str(
392            &analysis
393                .details
394                .iter()
395                .map(|d| format!("- {}", d.text))
396                .collect::<Vec<_>>()
397                .join("\n"),
398        );
399    }
400
401    if !analysis.issue_refs.is_empty() {
402        message.push_str("\n\n");
403        message.push_str(
404            &analysis
405                .issue_refs
406                .iter()
407                .map(|r| format!("Refs {}", r))
408                .collect::<Vec<_>>()
409                .join("\n"),
410        );
411    }
412
413    message
414}
415
416// ═══════════════════════════════════════════════════════════════════════════
417// Validation
418// ═══════════════════════════════════════════════════════════════════════════
419
420/// Validate a commit summary line.
421///
422/// Returns a list of human-readable error strings (empty when valid).
423pub fn validate_summary(summary: &str) -> Vec<String> {
424    let mut errors = Vec::new();
425    if summary.trim().is_empty() {
426        errors.push("Summary must not be empty".to_string());
427    }
428    if summary.chars().count() > 72 {
429        errors.push("Summary exceeds 72 characters".to_string());
430    }
431    if summary.ends_with('.') {
432        errors.push("Summary must not end with a period".to_string());
433    }
434    if summary.contains('\n') {
435        errors.push("Summary must be a single line".to_string());
436    }
437    errors
438}
439
440/// Validate a commit scope.
441///
442/// Returns a list of human-readable error strings (empty when valid). An empty
443/// scope is always valid.
444pub fn validate_scope(scope: &str) -> Vec<String> {
445    let mut errors = Vec::new();
446    if scope.is_empty() {
447        return errors;
448    }
449    if scope.split('/').count() > 2 {
450        errors.push("Scope has more than 2 segments".to_string());
451    }
452    if scope != scope.to_ascii_lowercase() {
453        errors.push("Scope must be lowercase".to_string());
454    }
455    if !is_valid_scope_chars(scope) {
456        errors.push("Scope contains invalid characters (allowed: a-z 0-9 - _ /)".to_string());
457    }
458    errors
459}
460
461/// Check that each `/`-separated segment matches `^[a-z0-9][a-z0-9_-]*$`.
462fn is_valid_scope_chars(scope: &str) -> bool {
463    for segment in scope.split('/') {
464        if segment.is_empty() {
465            return false;
466        }
467        let mut chars = segment.chars();
468        let Some(first) = chars.next() else {
469            return false;
470        };
471        if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
472            return false;
473        }
474        if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
475            return false;
476        }
477    }
478    true
479}
480
481/// Best-effort normalisation of a summary so it satisfies [`validate_summary`].
482///
483/// Trims, collapses to the first line, strips trailing periods, and truncates
484/// to 72 characters at the last whole-word boundary.
485pub fn normalize_summary(summary: &str) -> String {
486    let first_line = summary.lines().next().unwrap_or("").trim();
487    let mut s = first_line.trim_end_matches('.').trim().to_string();
488    if s.chars().count() > 72 {
489        let truncated: String = s.chars().take(72).collect();
490        s = match truncated.rfind(' ') {
491            Some(idx) => truncated[..idx]
492                .trim_end_matches(|c: char| !c.is_alphanumeric())
493                .to_string(),
494            None => truncated,
495        };
496    }
497    s
498}
499
500// ═══════════════════════════════════════════════════════════════════════════
501// Topological ordering (Kahn's algorithm)
502// ═══════════════════════════════════════════════════════════════════════════
503
504/// Reorder `groups` in dependency order using Kahn's algorithm.
505///
506/// Each group's [`CommitGroup::dependencies`] lists IDs that must be committed
507/// first. The slice is sorted in place so every dependency precedes its
508/// dependents.
509///
510/// # Errors
511///
512/// Returns an error string when:
513/// - a dependency references an unknown group id,
514/// - a group depends on itself, or
515/// - a dependency cycle is detected.
516pub fn compute_dependency_order(groups: &mut [CommitGroup]) -> Result<(), String> {
517    let n = groups.len();
518    let id_to_index: HashMap<&str, usize> = groups
519        .iter()
520        .enumerate()
521        .map(|(i, g)| (g.id.as_str(), i))
522        .collect();
523
524    let mut in_degree = vec![0usize; n];
525    let mut edges: Vec<HashSet<usize>> = vec![HashSet::new(); n];
526
527    for (idx, group) in groups.iter().enumerate() {
528        for dep in &group.dependencies {
529            let Some(&dep_idx) = id_to_index.get(dep.as_str()) else {
530                return Err(format!(
531                    "Unknown dependency '{}' referenced by group '{}'",
532                    dep, group.id
533                ));
534            };
535            if dep_idx == idx {
536                return Err(format!("Group '{}' depends on itself", group.id));
537            }
538            if edges[dep_idx].insert(idx) {
539                in_degree[idx] += 1;
540            }
541        }
542    }
543
544    let mut queue: VecDeque<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
545    let mut order: Vec<usize> = Vec::with_capacity(n);
546    while let Some(current) = queue.pop_front() {
547        order.push(current);
548        let dependents: Vec<usize> = edges[current].iter().copied().collect();
549        for next in dependents {
550            in_degree[next] -= 1;
551            if in_degree[next] == 0 {
552                queue.push_back(next);
553            }
554        }
555    }
556
557    if order.len() != n {
558        let cycle: Vec<String> = (0..n)
559            .filter(|i| !order.contains(i))
560            .map(|i| groups[i].id.clone())
561            .collect();
562        return Err(format!(
563            "Dependency cycle detected among: {}",
564            cycle.join(", ")
565        ));
566    }
567
568    // Rank each group by its position in the topological order, then sort.
569    let rank_by_id: HashMap<String, usize> = order
570        .iter()
571        .enumerate()
572        .map(|(rank, &idx)| (groups[idx].id.clone(), rank))
573        .collect();
574    groups.sort_by_key(|g| rank_by_id.get(&g.id).copied().unwrap_or(usize::MAX));
575
576    Ok(())
577}
578
579// ═══════════════════════════════════════════════════════════════════════════
580// LLM analysis
581// ═══════════════════════════════════════════════════════════════════════════
582
583/// System prompt steering the model toward a single structured analysis call.
584const ANALYSIS_SYSTEM: &str = "\
585You are a conventional-commits analysis engine. Given a git diff and ranked \
586scope candidates, call the create_conventional_analysis tool exactly once with \
587a conventional commit plan. Rules:\n\
588- type: one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.\n\
589- scope: lowercase, at most two /-separated segments; pick the most relevant scope candidate when possible, or empty string.\n\
590- summary: imperative mood, <=72 chars, no trailing period, single line.\n\
591- details: one bullet per logical change, each <=120 chars ending with a period.\n\
592- issueRefs: issue/PR references like #123, or omit.";
593
594/// JSON schema for the `create_conventional_analysis` tool.
595fn analysis_tool_schema() -> Value {
596    json!({
597        "type": "object",
598        "properties": {
599            "type": {
600                "type": "string",
601                "enum": ["feat","fix","docs","style","refactor","perf","test","build","ci","chore","revert"]
602            },
603            "scope": {
604                "type": "string",
605                "description": "Lowercase scope, at most two /-separated segments, or empty"
606            },
607            "summary": {
608                "type": "string",
609                "maxLength": 72,
610                "description": "Imperative one-line summary, no trailing period"
611            },
612            "details": {
613                "type": "array",
614                "items": {
615                    "type": "object",
616                    "properties": {
617                        "text": {"type": "string", "maxLength": 120},
618                        "changelogCategory": {
619                            "type": "string",
620                            "enum": ["added","changed","deprecated","removed","fixed","security","internal"]
621                        },
622                        "userVisible": {"type": "boolean"}
623                    },
624                    "required": ["text"]
625                }
626            },
627            "issueRefs": {
628                "type": "array",
629                "items": {"type": "string"}
630            }
631        },
632        "required": ["type", "scope", "summary", "details"]
633    })
634}
635
636/// Internal shape of the LLM response — analysis plus a one-line summary.
637#[derive(Debug, Deserialize)]
638struct LlmAnalysis {
639    #[serde(rename = "type")]
640    commit_type: CommitType,
641    #[serde(default)]
642    scope: String,
643    summary: String,
644    #[serde(default)]
645    details: Vec<ConventionalDetail>,
646    #[serde(default)]
647    issue_refs: Vec<String>,
648}
649
650/// Ask the configured model for a conventional analysis of the diff.
651///
652/// Returns `(analysis, summary)`. Falls back to text-JSON parsing when the
653/// model emits JSON instead of a tool call.
654async fn generate_analysis(
655    model: &oxi_ai::Model,
656    diff: &str,
657    candidates: &[ScopeCandidate],
658    extra_context: Option<&str>,
659) -> Result<(ConventionalAnalysis, String), String> {
660    let scope_hint = if candidates.is_empty() {
661        "(none — derive from the diff)".to_string()
662    } else {
663        candidates
664            .iter()
665            .take(5)
666            .map(|c| format!("- {} (weight {:.0})", c.name, c.weight))
667            .collect::<Vec<_>>()
668            .join("\n")
669    };
670
671    let mut user =
672        format!("Ranked scope candidates (by churn):\n{scope_hint}\n\n--- diff ---\n{diff}");
673    if let Some(ctx) = extra_context {
674        user.push_str(&format!("\n\n--- additional context ---\n{ctx}"));
675    }
676
677    let mut context = oxi_ai::Context::new().with_system_prompt(ANALYSIS_SYSTEM);
678    context.add_message(oxi_ai::Message::User(oxi_ai::UserMessage::new(user)));
679    context.set_tools(vec![oxi_ai::Tool::new(
680        "create_conventional_analysis",
681        "Emit a conventional-commit analysis for the given diff.",
682        analysis_tool_schema(),
683    )]);
684
685    let options = oxi_ai::StreamOptions {
686        max_tokens: Some(2400),
687        temperature: Some(0.2),
688        ..Default::default()
689    };
690
691    let response = oxi_ai::complete(model, &context, Some(options))
692        .await
693        .map_err(|e| format!("LLM analysis failed: {e}"))?;
694
695    parse_analysis_response(&response)
696}
697
698/// Extract `(analysis, summary)` from the model response.
699///
700/// Prefers a `create_conventional_analysis` tool call; falls back to the first
701/// JSON object in any text block.
702fn parse_analysis_response(
703    msg: &oxi_ai::AssistantMessage,
704) -> Result<(ConventionalAnalysis, String), String> {
705    for block in &msg.content {
706        if let oxi_ai::ContentBlock::ToolCall(call) = block
707            && call.name == "create_conventional_analysis"
708        {
709            let plan: LlmAnalysis = serde_json::from_value(call.arguments.clone())
710                .map_err(|e| format!("Invalid analysis tool arguments: {e}"))?;
711            return Ok(split_plan(plan));
712        }
713    }
714
715    let text = msg.text_content();
716    if let Some(raw) = extract_json_object(&text) {
717        let plan: LlmAnalysis =
718            serde_json::from_str(&raw).map_err(|e| format!("Invalid analysis JSON: {e}"))?;
719        return Ok(split_plan(plan));
720    }
721
722    Err("LLM did not return a conventional analysis".to_string())
723}
724
725fn split_plan(plan: LlmAnalysis) -> (ConventionalAnalysis, String) {
726    let analysis = ConventionalAnalysis {
727        commit_type: plan.commit_type,
728        scope: plan.scope,
729        details: plan.details,
730        issue_refs: plan.issue_refs,
731    };
732    (analysis, plan.summary)
733}
734
735/// Extract the first balanced JSON object from `text`.
736fn extract_json_object(text: &str) -> Option<String> {
737    let start = text.find('{')?;
738    let bytes = text.as_bytes();
739    let mut depth = 0i32;
740    let mut in_string = false;
741    let mut escape = false;
742    for (i, &byte) in bytes.iter().enumerate().skip(start) {
743        let c = byte as char;
744        if in_string {
745            if escape {
746                escape = false;
747            } else if c == '\\' {
748                escape = true;
749            } else if c == '"' {
750                in_string = false;
751            }
752        } else if c == '"' {
753            in_string = true;
754        } else if c == '{' {
755            depth += 1;
756        } else if c == '}' {
757            depth -= 1;
758            if depth == 0 {
759                return Some(text[start..=i].to_string());
760            }
761        }
762    }
763    None
764}
765
766// ═══════════════════════════════════════════════════════════════════════════
767// Deterministic fallback (no LLM)
768// ═══════════════════════════════════════════════════════════════════════════
769
770/// Deterministically infer a [`ConventionalAnalysis`] when no LLM is available.
771fn deterministic_analysis(
772    entries: &[NumstatEntry],
773    candidates: &[ScopeCandidate],
774) -> ConventionalAnalysis {
775    let commit_type = infer_commit_type(entries);
776    let scope = candidates
777        .first()
778        .map(|c| c.name.clone())
779        .unwrap_or_default();
780    let details = deterministic_details(entries);
781    ConventionalAnalysis {
782        commit_type,
783        scope,
784        details,
785        issue_refs: Vec::new(),
786    }
787}
788
789/// Derive a sensible imperative summary from the commit type and scope.
790fn deterministic_summary(commit_type: CommitType, scope: &str) -> String {
791    let verb = match commit_type {
792        CommitType::Feat => "Add",
793        CommitType::Fix => "Fix",
794        CommitType::Docs => "Document",
795        CommitType::Refactor => "Refactor",
796        CommitType::Test => "Add tests for",
797        CommitType::Perf => "Optimize",
798        CommitType::Build => "Update build config for",
799        CommitType::Ci => "Update CI for",
800        CommitType::Style => "Format",
801        CommitType::Revert => "Revert",
802        CommitType::Chore => "Update",
803    };
804    let target = if scope.is_empty() {
805        "the project"
806    } else {
807        scope
808    };
809    normalize_summary(&format!("{verb} {target}"))
810}
811
812fn infer_commit_type(entries: &[NumstatEntry]) -> CommitType {
813    let paths: Vec<&str> = entries
814        .iter()
815        .filter(|e| !is_excluded_file(&e.path))
816        .map(|e| e.path.as_str())
817        .collect();
818    if paths.is_empty() {
819        return CommitType::Chore;
820    }
821    if paths.iter().all(|p| is_doc_file(p)) {
822        return CommitType::Docs;
823    }
824    if paths.iter().all(|p| is_test_file(p)) {
825        return CommitType::Test;
826    }
827    if paths.iter().all(|p| is_ci_file(p)) {
828        return CommitType::Ci;
829    }
830    if paths.iter().all(|p| is_build_file(p)) {
831        return CommitType::Build;
832    }
833    CommitType::Chore
834}
835
836fn deterministic_details(entries: &[NumstatEntry]) -> Vec<ConventionalDetail> {
837    entries
838        .iter()
839        .filter(|e| !is_excluded_file(&e.path))
840        .take(6)
841        .map(|e| ConventionalDetail {
842            text: format!("Update {}.", short_path(&e.path)),
843            changelog_category: None,
844            user_visible: true,
845        })
846        .collect()
847}
848
849fn short_path(path: &str) -> String {
850    path.rsplit_once('/')
851        .map(|(_, base)| base.to_string())
852        .unwrap_or_else(|| path.to_string())
853}
854
855fn is_doc_file(path: &str) -> bool {
856    let lower = path.to_ascii_lowercase();
857    lower.ends_with(".md")
858        || lower.ends_with(".txt")
859        || lower.ends_with(".rst")
860        || lower.starts_with("docs/")
861        || lower.contains("/docs/")
862        || lower == "readme.md"
863        || lower == "changelog.md"
864        || lower == "license"
865        || lower == "license.md"
866}
867
868fn is_test_file(path: &str) -> bool {
869    let lower = path.to_ascii_lowercase();
870    lower.ends_with("_test.rs")
871        || lower.ends_with(".test.ts")
872        || lower.ends_with(".test.tsx")
873        || lower.ends_with(".test.js")
874        || lower.ends_with(".spec.ts")
875        || lower.ends_with(".spec.js")
876        || lower.contains("/tests/")
877        || lower.contains("/test/")
878        || lower.starts_with("test/")
879        || lower.starts_with("tests/")
880        || lower.ends_with("_test.go")
881        || lower.ends_with("test.py")
882        || lower.ends_with("_test.py")
883}
884
885fn is_ci_file(path: &str) -> bool {
886    let lower = path.to_ascii_lowercase();
887    lower.starts_with(".github/")
888        || lower.starts_with("ci/")
889        || lower.contains("/.gitlab-ci")
890        || lower == ".gitlab-ci.yml"
891        || lower == "dockerfile"
892        || lower.ends_with("/dockerfile")
893}
894
895fn is_build_file(path: &str) -> bool {
896    let lower = path.to_ascii_lowercase();
897    lower.ends_with("cargo.toml")
898        || lower.ends_with("package.json")
899        || lower.ends_with("tsconfig.json")
900        || lower.ends_with("go.mod")
901        || lower.ends_with("go.sum")
902        || lower == "makefile"
903        || lower == "justfile"
904        || lower.ends_with("dockerfile")
905        || lower.ends_with(".cmake")
906}
907
908// ═══════════════════════════════════════════════════════════════════════════
909// Changelog (best-effort Keep-a-Changelog append)
910// ═══════════════════════════════════════════════════════════════════════════
911
912/// Title-case heading for a changelog category.
913fn category_title(cat: ChangelogCategory) -> &'static str {
914    match cat {
915        ChangelogCategory::Added => "Added",
916        ChangelogCategory::Changed => "Changed",
917        ChangelogCategory::Deprecated => "Deprecated",
918        ChangelogCategory::Removed => "Removed",
919        ChangelogCategory::Fixed => "Fixed",
920        ChangelogCategory::Security => "Security",
921        ChangelogCategory::Internal => "Internal",
922    }
923}
924
925/// Best-effort Keep-a-Changelog update.
926///
927/// Appends user-visible details carrying a [`ChangelogCategory`] under the
928/// matching `### <Category>` sub-heading inside the `## [Unreleased]` section,
929/// creating missing sub-headings as needed.
930///
931/// Returns `Ok(true)` if the file was modified, `Ok(false)` when there is
932/// nothing to do (no file, no `[Unreleased]` section, or no categorised
933/// details), and `Err` only on I/O failure. The commit is never rolled back
934/// on failure — the caller treats this as a non-fatal warning.
935fn update_changelog(root: &Path, analysis: &ConventionalAnalysis) -> std::io::Result<bool> {
936    let by_category: Vec<(ChangelogCategory, String)> = analysis
937        .details
938        .iter()
939        .filter(|d| d.user_visible)
940        .filter_map(|d| {
941            d.changelog_category
942                .map(|cat| (cat, d.text.trim_end_matches('.').to_string()))
943        })
944        .collect();
945    if by_category.is_empty() {
946        return Ok(false);
947    }
948
949    let path = root.join("CHANGELOG.md");
950    let content = match std::fs::read_to_string(&path) {
951        Ok(c) => c,
952        Err(_) => return Ok(false),
953    };
954
955    let marker = "[Unreleased]";
956    let Some(marker_idx) = content.find(marker) else {
957        return Ok(false);
958    };
959    let line_end = content[marker_idx..]
960        .find('\n')
961        .map(|n| marker_idx + n)
962        .unwrap_or(content.len());
963    let section_end = content[line_end..]
964        .find("\n## ")
965        .map(|n| line_end + n)
966        .unwrap_or(content.len());
967
968    let section = &content[line_end..section_end];
969    let mut new_section = section.to_string();
970    for (cat, text) in &by_category {
971        let heading = format!("### {}\n", category_title(*cat));
972        if let Some(hpos) = new_section.find(&heading) {
973            let insert_at = hpos + heading.len();
974            new_section.insert_str(insert_at, &format!("- {text}\n"));
975        } else {
976            if !new_section.is_empty() && !new_section.ends_with('\n') {
977                new_section.push('\n');
978            }
979            new_section.push_str(&format!("\n### {}\n- {text}\n", category_title(*cat)));
980        }
981    }
982
983    let mut new_content = String::with_capacity(content.len() + new_section.len());
984    new_content.push_str(&content[..line_end]);
985    new_content.push_str(&new_section);
986    new_content.push_str(&content[section_end..]);
987    std::fs::write(&path, new_content)?;
988    Ok(true)
989}
990
991// ═══════════════════════════════════════════════════════════════════════════
992// Git operations
993// ═══════════════════════════════════════════════════════════════════════════
994
995/// Minimal git wrapper around [`std::process::Command`] for the commit tool.
996struct GitOps {
997    /// Working directory for git invocations.
998    cwd: PathBuf,
999}
1000
1001impl GitOps {
1002    /// Create a git operator rooted at `cwd`.
1003    fn new(cwd: PathBuf) -> Self {
1004        Self { cwd }
1005    }
1006
1007    fn run(&self, args: &[&str]) -> Result<String, String> {
1008        let output = std::process::Command::new("git")
1009            .args(args)
1010            .current_dir(&self.cwd)
1011            .output()
1012            .map_err(|e| format!("Failed to run git {}: {e}", args.join(" ")))?;
1013        if !output.status.success() {
1014            return Err(format!(
1015                "git {} failed: {}",
1016                args.join(" "),
1017                String::from_utf8_lossy(&output.stderr).trim()
1018            ));
1019        }
1020        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1021    }
1022
1023    fn numstat(&self) -> Result<Vec<NumstatEntry>, String> {
1024        let output = self.run(&["diff", "--numstat", "HEAD"])?;
1025        Ok(parse_numstat(&output))
1026    }
1027
1028    fn diff(&self) -> Result<String, String> {
1029        self.run(&["diff", "HEAD"])
1030    }
1031
1032    fn stage_all(&self) -> Result<(), String> {
1033        self.run(&["add", "-A"])?;
1034        Ok(())
1035    }
1036
1037    fn commit(&self, message: &str) -> Result<(), String> {
1038        self.run(&["commit", "-m", message])?;
1039        Ok(())
1040    }
1041
1042    fn push(&self) -> Result<(), String> {
1043        self.run(&["push"])?;
1044        Ok(())
1045    }
1046
1047    fn head_short(&self) -> Result<String, String> {
1048        let output = self.run(&["rev-parse", "--short", "HEAD"])?;
1049        Ok(output.trim().to_string())
1050    }
1051}
1052
1053// ═══════════════════════════════════════════════════════════════════════════
1054// CommitTool
1055/// Parsed parameters for the commit tool.
1056#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
1057pub struct CommitArgs {
1058    /// Preview without committing.
1059    #[serde(default)]
1060    pub dry_run: bool,
1061    /// Push after committing.
1062    #[serde(default)]
1063    pub push: bool,
1064    /// Skip the CHANGELOG update step even when one would normally apply.
1065    #[serde(default)]
1066    pub no_changelog: bool,
1067    /// Extra context passed to the LLM analysis.
1068    pub context: Option<String>,
1069}
1070
1071#[allow(dead_code)]
1072fn parse_args(params: &Value) -> Result<CommitArgs, String> {
1073    serde_json::from_value(params.clone()).map_err(|e| e.to_string())
1074}
1075
1076/// Agent tool that produces conventional commits from the working tree.
1077///
1078/// Collects the git diff, deterministically extracts scope candidates, asks a
1079/// configured LLM for a conventional analysis (falling back to deterministic
1080/// heuristics when no model is set), validates and formats the message, then
1081/// either commits or previews (`dry_run`).
1082pub struct CommitTool {
1083    /// LLM model for conventional analysis. `None` selects deterministic mode.
1084    model: Option<oxi_ai::Model>,
1085}
1086
1087impl CommitTool {
1088    /// Create a commit tool backed by the given LLM model.
1089    ///
1090    /// This is the constructor bootstrap uses to inject a resolved model.
1091    pub fn new(model: oxi_ai::Model) -> Self {
1092        Self { model: Some(model) }
1093    }
1094
1095    /// Create a commit tool with no LLM model (deterministic-only mode).
1096    ///
1097    /// Used by the default built-in registry; bootstrap can replace it with a
1098    /// [`CommitTool::new`] instance once a model is resolved.
1099    pub fn unconfigured() -> Self {
1100        Self { model: None }
1101    }
1102}
1103
1104#[async_trait]
1105impl AgentTool for CommitTool {
1106    fn name(&self) -> &str {
1107        "commit"
1108    }
1109
1110    fn label(&self) -> &str {
1111        "Conventional Commit"
1112    }
1113
1114    fn essential(&self) -> bool {
1115        false
1116    }
1117
1118    fn description(&self) -> &str {
1119        "Analyze working-tree changes, extract a conventional commit scope, \
1120         generate a conventional commit message, and commit (or preview with \
1121         dry_run). Optionally update CHANGELOG.md and push."
1122    }
1123
1124    fn parameters_schema(&self) -> Value {
1125        json!({
1126            "type": "object",
1127            "properties": {
1128                "dry_run": {
1129                    "type": "boolean",
1130                    "description": "Preview the commit message without committing",
1131                    "default": false
1132                },
1133                "push": {
1134                    "type": "boolean",
1135                    "description": "Push after committing",
1136                    "default": false
1137                },
1138                "no_changelog": {
1139                    "type": "boolean",
1140                    "description": "Skip the CHANGELOG.md update",
1141                    "default": false
1142                },
1143                "context": {
1144                    "type": "string",
1145                    "description": "Optional extra context to guide the analysis"
1146                }
1147            }
1148        })
1149    }
1150
1151    async fn execute(
1152        &self,
1153        _tool_call_id: &str,
1154        params: Value,
1155        _signal: Option<oneshot::Receiver<()>>,
1156        ctx: &ToolContext,
1157    ) -> Result<AgentToolResult, ToolError> {
1158        let args: CommitArgs =
1159            serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
1160        self.execute_typed(_tool_call_id, args, _signal, ctx).await
1161    }
1162}
1163
1164#[async_trait]
1165impl TypedTool for CommitTool {
1166    type Args = CommitArgs;
1167
1168    async fn execute_typed(
1169        &self,
1170        _tool_call_id: &str,
1171        args: Self::Args,
1172        _signal: Option<oneshot::Receiver<()>>,
1173        ctx: &ToolContext,
1174    ) -> Result<AgentToolResult, ToolError> {
1175        self._execute_impl(args, ctx).await
1176    }
1177}
1178
1179impl CommitTool {
1180    /// Shared execution logic used by both AgentTool and TypedTool paths.
1181    async fn _execute_impl(
1182        &self,
1183        args: CommitArgs,
1184        ctx: &ToolContext,
1185    ) -> Result<AgentToolResult, ToolError> {
1186        let cwd = ctx.root().to_path_buf();
1187        let git = GitOps::new(cwd.clone());
1188
1189        // 1. Collect changes.
1190        let numstat = git.numstat()?;
1191        let filtered: Vec<NumstatEntry> = numstat
1192            .iter()
1193            .filter(|e| !is_excluded_file(&e.path))
1194            .cloned()
1195            .collect();
1196        if filtered.is_empty() {
1197            return Ok(AgentToolResult::success("No changes to commit."));
1198        }
1199
1200        // 2. Deterministic scope extraction.
1201        let candidates = extract_scope_candidates(&numstat);
1202
1203        // 3. Analysis: LLM when configured, deterministic fallback otherwise.
1204        let (mut analysis, mut summary) = match self.model.as_ref() {
1205            Some(model) => {
1206                let diff = git.diff()?;
1207                match generate_analysis(model, &diff, &candidates, args.context.as_deref()).await {
1208                    Ok(plan) => plan,
1209                    Err(e) => {
1210                        let det = deterministic_analysis(&filtered, &candidates);
1211                        let det_summary = deterministic_summary(det.commit_type, &det.scope);
1212                        tracing::warn!(
1213                            "commit tool: LLM analysis failed ({e}), using deterministic fallback"
1214                        );
1215                        (det, det_summary)
1216                    }
1217                }
1218            }
1219            None => {
1220                let det = deterministic_analysis(&filtered, &candidates);
1221                let det_summary = deterministic_summary(det.commit_type, &det.scope);
1222                (det, det_summary)
1223            }
1224        };
1225
1226        // 4. Normalise + validate.
1227        summary = normalize_summary(&summary);
1228        analysis.scope = analysis.scope.trim().to_string();
1229        let validation = {
1230            let mut v = validate_summary(&summary);
1231            v.extend(validate_scope(&analysis.scope));
1232            v
1233        };
1234
1235        // 5. Format message.
1236        let message = format_commit_message(&analysis, &summary);
1237
1238        if args.dry_run {
1239            let mut output = String::new();
1240            if !validation.is_empty() {
1241                output.push_str("⚠ Validation warnings:\n");
1242                output.push_str(&validation.join("\n"));
1243                output.push_str("\n\n");
1244            }
1245            output.push_str("Dry run — would commit:\n\n");
1246            output.push_str(&message);
1247            return Ok(AgentToolResult::success(output).with_metadata(json!({
1248                "dry_run": true,
1249                "scope": analysis.scope,
1250                "type": analysis.commit_type.as_str(),
1251            })));
1252        }
1253
1254        // Validation is advisory for real commits but surfaced if present.
1255        if !validation.is_empty() {
1256            tracing::warn!(
1257                "commit tool: validation warnings: {}",
1258                validation.join("; ")
1259            );
1260        }
1261
1262        // 6. Stage + commit.
1263        git.stage_all()?;
1264        git.commit(&message)?;
1265        let hash = git.head_short().unwrap_or_else(|_| "unknown".to_string());
1266
1267        // 7. Changelog (best-effort, non-fatal).
1268        if !args.no_changelog
1269            && let Err(e) = update_changelog(&cwd, &analysis)
1270        {
1271            tracing::warn!("commit tool: changelog update failed: {e}");
1272        }
1273
1274        // 8. Push (optional).
1275        if args.push {
1276            git.push()?;
1277        }
1278
1279        Ok(
1280            AgentToolResult::success(format!("Committed {hash}:\n\n{message}")).with_metadata(
1281                json!({
1282                    "hash": hash,
1283                    "scope": analysis.scope,
1284                    "type": analysis.commit_type.as_str(),
1285                }),
1286            ),
1287        )
1288    }
1289}
1290
1291// ═══════════════════════════════════════════════════════════════════════════
1292// Tests
1293// ═══════════════════════════════════════════════════════════════════════════
1294
1295#[cfg(test)]
1296mod tests {
1297    use super::*;
1298
1299    fn entry(path: &str, additions: usize, deletions: usize) -> NumstatEntry {
1300        NumstatEntry {
1301            path: path.to_string(),
1302            additions,
1303            deletions,
1304        }
1305    }
1306
1307    // ── scope extraction ───────────────────────────────────────────────
1308
1309    #[test]
1310    fn scope_extraction_single_component() {
1311        let numstat = vec![
1312            entry("src/auth/login.rs", 50, 10),
1313            entry("src/auth/logout.rs", 20, 5),
1314        ];
1315        let candidates = extract_scope_candidates(&numstat);
1316        assert_eq!(candidates.len(), 1);
1317        assert_eq!(candidates[0].name, "src/auth");
1318        assert_eq!(candidates[0].segments, 2);
1319    }
1320
1321    #[test]
1322    fn scope_extraction_ranks_by_churn() {
1323        let numstat = vec![
1324            entry("src/big/module.rs", 200, 50),
1325            entry("src/tiny/util.rs", 5, 1),
1326            entry("docs/readme.md", 3, 0),
1327        ];
1328        let candidates = extract_scope_candidates(&numstat);
1329        assert!(!candidates.is_empty());
1330        // The high-churn component should rank first.
1331        assert_eq!(candidates[0].name, "src/big");
1332    }
1333
1334    #[test]
1335    fn scope_extraction_excludes_lock_files() {
1336        let numstat = vec![
1337            entry("Cargo.lock", 5000, 100),
1338            entry("src/main.rs", 10, 2),
1339            entry("package-lock.json", 9000, 0),
1340            entry("pnpm-lock.yaml", 300, 10),
1341            entry("go.sum", 800, 5),
1342        ];
1343        let candidates = extract_scope_candidates(&numstat);
1344        // Only src/main.rs survives; lock files are excluded.
1345        assert!(
1346            candidates
1347                .iter()
1348                .all(|c| !c.name.contains("lock") && !c.name.contains("sum"))
1349        );
1350        assert_eq!(candidates.len(), 1);
1351        assert_eq!(candidates[0].name, "src");
1352    }
1353
1354    #[test]
1355    fn scope_extraction_single_segment_boost() {
1356        let numstat = vec![entry("README.md", 10, 0)];
1357        let candidates = extract_scope_candidates(&numstat);
1358        assert_eq!(candidates.len(), 1);
1359        assert_eq!(candidates[0].name, "README");
1360        // Single-segment weight is dampened (×0.8).
1361        assert!((candidates[0].weight - 8.0).abs() < 0.001);
1362    }
1363
1364    #[test]
1365    fn wide_change_detection_many_roots() {
1366        let numstat = vec![
1367            entry("auth/login.rs", 30, 0),
1368            entry("billing/invoice.rs", 30, 0),
1369            entry("reports/export.rs", 30, 0),
1370        ];
1371        // Three distinct roots (auth, billing, reports) each holding 1/3 → wide.
1372        assert!(is_wide_change(&numstat));
1373    }
1374
1375    #[test]
1376    fn wide_change_false_for_single_scope() {
1377        let numstat = vec![
1378            entry("src/auth/login.rs", 100, 10),
1379            entry("src/auth/session.rs", 20, 5),
1380        ];
1381        assert!(!is_wide_change(&numstat));
1382    }
1383
1384    // ── numstat parsing ────────────────────────────────────────────────
1385
1386    #[test]
1387    fn parse_numstat_basic() {
1388        let output = "10\t2\tsrc/main.rs\n3\t0\tdocs/readme.md\n";
1389        let entries = parse_numstat(output);
1390        assert_eq!(entries.len(), 2);
1391        assert_eq!(entries[0].path, "src/main.rs");
1392        assert_eq!(entries[0].additions, 10);
1393        assert_eq!(entries[0].deletions, 2);
1394        assert_eq!(entries[1].path, "docs/readme.md");
1395    }
1396
1397    #[test]
1398    fn parse_numstat_binary_file() {
1399        let output = "-\t-\tassets/logo.png\n";
1400        let entries = parse_numstat(output);
1401        assert_eq!(entries.len(), 1);
1402        assert_eq!(entries[0].path, "assets/logo.png");
1403        assert_eq!(entries[0].additions, 0);
1404        assert_eq!(entries[0].deletions, 0);
1405    }
1406
1407    #[test]
1408    fn parse_numstat_skips_blank() {
1409        let output = "\n10\t2\tsrc/main.rs\n\n";
1410        let entries = parse_numstat(output);
1411        assert_eq!(entries.len(), 1);
1412    }
1413
1414    // ── message format ─────────────────────────────────────────────────
1415
1416    fn feat_auth_analysis() -> ConventionalAnalysis {
1417        ConventionalAnalysis {
1418            commit_type: CommitType::Feat,
1419            scope: "auth".to_string(),
1420            details: vec![ConventionalDetail {
1421                text: "Add OAuth2 login flow.".to_string(),
1422                changelog_category: Some(ChangelogCategory::Added),
1423                user_visible: true,
1424            }],
1425            issue_refs: vec!["#42".to_string()],
1426        }
1427    }
1428
1429    #[test]
1430    fn message_format_with_scope_and_refs() {
1431        let analysis = feat_auth_analysis();
1432        let msg = format_commit_message(&analysis, "Add OAuth2 login");
1433        assert!(msg.starts_with("feat(auth): Add OAuth2 login"));
1434        assert!(msg.contains("- Add OAuth2 login flow."));
1435        assert!(msg.contains("Refs #42"));
1436    }
1437
1438    #[test]
1439    fn message_format_without_scope() {
1440        let analysis = ConventionalAnalysis {
1441            commit_type: CommitType::Fix,
1442            scope: String::new(),
1443            details: vec![ConventionalDetail {
1444                text: "Correct off-by-one.".to_string(),
1445                changelog_category: None,
1446                user_visible: true,
1447            }],
1448            issue_refs: Vec::new(),
1449        };
1450        let msg = format_commit_message(&analysis, "Fix crash");
1451        assert!(msg.starts_with("fix: Fix crash\n\n- Correct off-by-one."));
1452        assert!(!msg.contains("Refs"));
1453    }
1454
1455    #[test]
1456    fn message_format_empty_details() {
1457        let analysis = ConventionalAnalysis {
1458            commit_type: CommitType::Chore,
1459            scope: "deps".to_string(),
1460            details: Vec::new(),
1461            issue_refs: Vec::new(),
1462        };
1463        let msg = format_commit_message(&analysis, "Bump deps");
1464        assert_eq!(msg, "chore(deps): Bump deps");
1465    }
1466
1467    #[test]
1468    fn message_format_multiple_refs() {
1469        let analysis = ConventionalAnalysis {
1470            commit_type: CommitType::Fix,
1471            scope: String::new(),
1472            details: Vec::new(),
1473            issue_refs: vec!["#1".to_string(), "#2".to_string()],
1474        };
1475        let msg = format_commit_message(&analysis, "Fix things");
1476        assert!(msg.contains("Refs #1\nRefs #2"));
1477    }
1478
1479    // ── validation ─────────────────────────────────────────────────────
1480
1481    #[test]
1482    fn validation_rejects_long_summary() {
1483        let long = "x".repeat(73);
1484        let errors = validate_summary(&long);
1485        assert!(errors.iter().any(|e| e.contains("72 characters")));
1486    }
1487
1488    #[test]
1489    fn validation_accepts_max_length_summary() {
1490        let exact = "x".repeat(72);
1491        let errors = validate_summary(&exact);
1492        assert!(!errors.iter().any(|e| e.contains("72 characters")));
1493    }
1494
1495    #[test]
1496    fn validation_rejects_trailing_period() {
1497        let errors = validate_summary("Add feature.");
1498        assert!(errors.iter().any(|e| e.contains("period")));
1499    }
1500
1501    #[test]
1502    fn validation_rejects_multiline_summary() {
1503        let errors = validate_summary("line one\nline two");
1504        assert!(errors.iter().any(|e| e.contains("single line")));
1505    }
1506
1507    #[test]
1508    fn validation_rejects_empty_summary() {
1509        let errors = validate_summary("   ");
1510        assert!(errors.iter().any(|e| e.contains("empty")));
1511    }
1512
1513    #[test]
1514    fn validation_rejects_uppercase_scope() {
1515        let errors = validate_scope("Auth");
1516        assert!(errors.iter().any(|e| e.contains("lowercase")));
1517    }
1518
1519    #[test]
1520    fn validation_rejects_three_segment_scope() {
1521        let errors = validate_scope("a/b/c");
1522        assert!(errors.iter().any(|e| e.contains("2 segments")));
1523    }
1524
1525    #[test]
1526    fn validation_rejects_invalid_scope_chars() {
1527        let errors = validate_scope("auth config");
1528        assert!(errors.iter().any(|e| e.contains("invalid characters")));
1529    }
1530
1531    #[test]
1532    fn validation_accepts_empty_scope() {
1533        assert!(validate_scope("").is_empty());
1534    }
1535
1536    #[test]
1537    fn validation_accepts_two_segment_scope() {
1538        assert!(validate_scope("oxi-agent/auth").is_empty());
1539    }
1540
1541    #[test]
1542    fn normalize_summary_strips_period_and_truncates() {
1543        assert_eq!(normalize_summary("Add feature."), "Add feature");
1544        let long = format!("{}.", "x".repeat(80));
1545        let normalized = normalize_summary(&long);
1546        assert!(normalized.chars().count() <= 72);
1547        assert!(!normalized.ends_with('.'));
1548    }
1549
1550    #[test]
1551    fn normalize_summary_collapses_to_single_line() {
1552        assert_eq!(normalize_summary("first\nsecond"), "first");
1553    }
1554
1555    // ── topological sort ───────────────────────────────────────────────
1556
1557    fn group(id: &str, deps: &[&str]) -> CommitGroup {
1558        CommitGroup {
1559            id: id.to_string(),
1560            files: Vec::new(),
1561            analysis: ConventionalAnalysis {
1562                commit_type: CommitType::Feat,
1563                scope: String::new(),
1564                details: Vec::new(),
1565                issue_refs: Vec::new(),
1566            },
1567            summary: String::new(),
1568            dependencies: deps.iter().map(|s| s.to_string()).collect(),
1569        }
1570    }
1571
1572    #[test]
1573    fn topo_sort_no_cycle() {
1574        let mut groups = vec![group("a", &[]), group("b", &["a"]), group("c", &["b"])];
1575        compute_dependency_order(&mut groups).expect("no cycle");
1576        let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1577        assert_eq!(ids, vec!["a", "b", "c"]);
1578    }
1579
1580    #[test]
1581    fn topo_sort_cycle_detected() {
1582        let mut groups = vec![group("a", &["b"]), group("b", &["a"])];
1583        let result = compute_dependency_order(&mut groups);
1584        assert!(result.is_err());
1585        let err = result.unwrap_err();
1586        assert!(err.contains("cycle"));
1587    }
1588
1589    #[test]
1590    fn topo_sort_unknown_dependency() {
1591        let mut groups = vec![group("a", &["nonexistent"])];
1592        let result = compute_dependency_order(&mut groups);
1593        assert!(result.is_err());
1594        assert!(result.unwrap_err().contains("Unknown dependency"));
1595    }
1596
1597    #[test]
1598    fn topo_sort_self_dependency() {
1599        let mut groups = vec![group("a", &["a"])];
1600        let result = compute_dependency_order(&mut groups);
1601        assert!(result.is_err());
1602        assert!(result.unwrap_err().contains("itself"));
1603    }
1604
1605    #[test]
1606    fn topo_sort_independent_groups_preserved() {
1607        let mut groups = vec![group("x", &[]), group("y", &[]), group("z", &[])];
1608        compute_dependency_order(&mut groups).expect("ok");
1609        // No dependencies → stable order preserved.
1610        let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1611        assert_eq!(ids, vec!["x", "y", "z"]);
1612    }
1613
1614    #[test]
1615    fn topo_sort_diamond() {
1616        // d depends on b and c; b and c depend on a.
1617        let mut groups = vec![
1618            group("d", &["b", "c"]),
1619            group("c", &["a"]),
1620            group("b", &["a"]),
1621            group("a", &[]),
1622        ];
1623        compute_dependency_order(&mut groups).expect("no cycle");
1624        let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1625        assert_eq!(ids[0], "a");
1626        assert_eq!(ids[3], "d");
1627        // b and c come after a and before d.
1628        let b_pos = ids.iter().position(|&i| i == "b").unwrap();
1629        let c_pos = ids.iter().position(|&i| i == "c").unwrap();
1630        assert!(b_pos > 0 && b_pos < 3);
1631        assert!(c_pos > 0 && c_pos < 3);
1632    }
1633
1634    #[test]
1635    fn topo_sort_dedupes_repeated_dependency() {
1636        // Declaring the same dependency twice must not corrupt in-degree.
1637        let mut groups = vec![group("b", &["a", "a"]), group("a", &[])];
1638        compute_dependency_order(&mut groups).expect("no cycle");
1639        let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1640        assert_eq!(ids, vec!["a", "b"]);
1641    }
1642
1643    // ── exclusions ─────────────────────────────────────────────────────
1644
1645    #[test]
1646    fn excludes_common_lock_files() {
1647        assert!(is_excluded_file("Cargo.lock"));
1648        assert!(is_excluded_file("crates/foo/Cargo.lock"));
1649        assert!(is_excluded_file("package-lock.json"));
1650        assert!(is_excluded_file("yarn.lock"));
1651        assert!(is_excluded_file("pnpm-lock.yaml"));
1652        assert!(is_excluded_file("go.sum"));
1653        assert!(is_excluded_file("uv.lock"));
1654        assert!(is_excluded_file("flake.lock"));
1655        assert!(is_excluded_file("app/config.yaml.lock"));
1656    }
1657
1658    #[test]
1659    fn does_not_exclude_source_files() {
1660        assert!(!is_excluded_file("src/main.rs"));
1661        assert!(!is_excluded_file("lib/index.ts"));
1662        assert!(!is_excluded_file("Cargo.toml"));
1663        assert!(!is_excluded_file("README.md"));
1664    }
1665
1666    // ── CommitType ─────────────────────────────────────────────────────
1667
1668    #[test]
1669    fn commit_type_roundtrip() {
1670        for id in [
1671            "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore",
1672            "revert",
1673        ] {
1674            let ty = CommitType::from_id(id).unwrap_or_else(|| panic!("unknown type {id}"));
1675            assert_eq!(ty.as_str(), id);
1676            assert_eq!(ty.to_string(), id);
1677        }
1678        assert!(CommitType::from_id("unknown").is_none());
1679    }
1680
1681    // ── deterministic fallback ─────────────────────────────────────────
1682
1683    #[test]
1684    fn deterministic_analysis_docs() {
1685        let entries = vec![entry("docs/guide.md", 20, 5)];
1686        let candidates = extract_scope_candidates(&entries);
1687        let analysis = deterministic_analysis(&entries, &candidates);
1688        assert_eq!(analysis.commit_type, CommitType::Docs);
1689        assert_eq!(analysis.scope, "docs");
1690        assert!(!analysis.details.is_empty());
1691    }
1692
1693    #[test]
1694    fn deterministic_analysis_tests() {
1695        let entries = vec![entry("src/auth_test.rs", 40, 2)];
1696        let candidates = extract_scope_candidates(&entries);
1697        let analysis = deterministic_analysis(&entries, &candidates);
1698        assert_eq!(analysis.commit_type, CommitType::Test);
1699    }
1700
1701    #[test]
1702    fn deterministic_summary_is_valid() {
1703        let summary = deterministic_summary(CommitType::Feat, "auth");
1704        assert!(validate_summary(&summary).is_empty());
1705        assert!(summary.contains("Add"));
1706    }
1707
1708    // ── JSON extraction ────────────────────────────────────────────────
1709
1710    #[test]
1711    fn extract_json_object_from_fence() {
1712        let text = "Here is the plan:\n```json\n{\"type\":\"fix\",\"scope\":\"a\"}\n```\n";
1713        let extracted = extract_json_object(text).expect("found json");
1714        assert!(extracted.contains("\"type\":\"fix\""));
1715    }
1716
1717    #[test]
1718    fn extract_json_object_nested() {
1719        let text = "{\"a\":{\"b\":1},\"c\":2}";
1720        let extracted = extract_json_object(text).expect("found json");
1721        assert_eq!(extracted, text);
1722    }
1723
1724    #[test]
1725    fn extract_json_object_with_brace_in_string() {
1726        let text = "{\"text\":\"has } brace\"}";
1727        let extracted = extract_json_object(text).expect("found json");
1728        assert_eq!(extracted, text);
1729    }
1730
1731    // ── changelog ──────────────────────────────────────────────────────
1732
1733    #[test]
1734    fn update_changelog_appends_under_unreleased() {
1735        let dir = tempfile::tempdir().expect("tempdir");
1736        let changelog = dir.path().join("CHANGELOG.md");
1737        std::fs::write(
1738            &changelog,
1739            "# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2024-01-01\n\n- initial\n",
1740        )
1741        .expect("write");
1742        let analysis = ConventionalAnalysis {
1743            commit_type: CommitType::Feat,
1744            scope: String::new(),
1745            details: vec![ConventionalDetail {
1746                text: "Add OAuth2 login.".to_string(),
1747                changelog_category: Some(ChangelogCategory::Added),
1748                user_visible: true,
1749            }],
1750            issue_refs: Vec::new(),
1751        };
1752        let modified = update_changelog(dir.path(), &analysis).expect("ok");
1753        assert!(modified);
1754        let content = std::fs::read_to_string(&changelog).expect("read");
1755        let unreleased_start = content.find("## [Unreleased]").unwrap();
1756        let v1_start = content.find("## [1.0.0]").unwrap();
1757        let unreleased = &content[unreleased_start..v1_start];
1758        assert!(unreleased.contains("### Added"));
1759        assert!(unreleased.contains("- Add OAuth2 login"));
1760    }
1761
1762    #[test]
1763    fn update_changelog_skips_without_unreleased() {
1764        let dir = tempfile::tempdir().expect("tempdir");
1765        std::fs::write(
1766            dir.path().join("CHANGELOG.md"),
1767            "# Changelog\n\n## [1.0.0]\n",
1768        )
1769        .unwrap();
1770        let analysis = ConventionalAnalysis {
1771            commit_type: CommitType::Feat,
1772            scope: String::new(),
1773            details: vec![ConventionalDetail {
1774                text: "Add.".to_string(),
1775                changelog_category: Some(ChangelogCategory::Added),
1776                user_visible: true,
1777            }],
1778            issue_refs: Vec::new(),
1779        };
1780        let modified = update_changelog(dir.path(), &analysis).expect("ok");
1781        assert!(!modified);
1782    }
1783
1784    #[test]
1785    fn update_changelog_no_file_is_noop() {
1786        let dir = tempfile::tempdir().expect("tempdir");
1787        let analysis = ConventionalAnalysis {
1788            commit_type: CommitType::Feat,
1789            scope: String::new(),
1790            details: vec![ConventionalDetail {
1791                text: "Add.".to_string(),
1792                changelog_category: Some(ChangelogCategory::Added),
1793                user_visible: true,
1794            }],
1795            issue_refs: Vec::new(),
1796        };
1797        let modified = update_changelog(dir.path(), &analysis).expect("ok");
1798        assert!(!modified);
1799    }
1800
1801    // ── parse_args ─────────────────────────────────────────────────────
1802
1803    #[test]
1804    fn parse_args_defaults() {
1805        let args = parse_args(&json!({})).expect("ok");
1806        assert!(!args.dry_run);
1807        assert!(!args.push);
1808        assert!(!args.no_changelog);
1809        assert!(args.context.is_none());
1810    }
1811
1812    #[test]
1813    fn parse_args_all_set() {
1814        let args = parse_args(
1815            &json!({"dry_run": true, "push": true, "no_changelog": true, "context": "ctx"}),
1816        )
1817        .expect("ok");
1818        assert!(args.dry_run);
1819        assert!(args.push);
1820        assert!(args.no_changelog);
1821        assert_eq!(args.context.as_deref(), Some("ctx"));
1822    }
1823}