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