Skip to main content

oxicode_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//! `oxicode_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 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: &oxicode_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 = oxicode_ai::Context::new().with_system_prompt(ANALYSIS_SYSTEM);
676    context.add_message(oxicode_ai::Message::User(oxicode_ai::UserMessage::new(
677        user,
678    )));
679    context.set_tools(vec![oxicode_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 = oxicode_ai::StreamOptions {
686        max_tokens: Some(2400),
687        temperature: Some(0.2),
688        ..Default::default()
689    };
690
691    let response = oxicode_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: &oxicode_ai::AssistantMessage,
704) -> Result<(ConventionalAnalysis, String), String> {
705    for block in &msg.content {
706        if let oxicode_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// ═══════════════════════════════════════════════════════════════════════════
1056
1057/// Parsed parameters for the commit tool.
1058#[derive(Debug, Default)]
1059struct CommitArgs {
1060    /// Preview without committing.
1061    dry_run: bool,
1062    /// Push after committing.
1063    push: bool,
1064    /// Skip the changelog update.
1065    no_changelog: bool,
1066    /// Extra context passed to the LLM analysis.
1067    context: Option<String>,
1068}
1069
1070fn parse_args(params: &Value) -> Result<CommitArgs, String> {
1071    Ok(CommitArgs {
1072        dry_run: params["dry_run"].as_bool().unwrap_or(false),
1073        push: params["push"].as_bool().unwrap_or(false),
1074        no_changelog: params["no_changelog"].as_bool().unwrap_or(false),
1075        context: params["context"].as_str().map(String::from),
1076    })
1077}
1078
1079/// Agent tool that produces conventional commits from the working tree.
1080///
1081/// Collects the git diff, deterministically extracts scope candidates, asks a
1082/// configured LLM for a conventional analysis (falling back to deterministic
1083/// heuristics when no model is set), validates and formats the message, then
1084/// either commits or previews (`dry_run`).
1085pub struct CommitTool {
1086    /// LLM model for conventional analysis. `None` selects deterministic mode.
1087    model: Option<oxicode_ai::Model>,
1088}
1089
1090impl CommitTool {
1091    /// Create a commit tool backed by the given LLM model.
1092    ///
1093    /// This is the constructor bootstrap uses to inject a resolved model.
1094    pub fn new(model: oxicode_ai::Model) -> Self {
1095        Self { model: Some(model) }
1096    }
1097
1098    /// Create a commit tool with no LLM model (deterministic-only mode).
1099    ///
1100    /// Used by the default built-in registry; bootstrap can replace it with a
1101    /// [`CommitTool::new`] instance once a model is resolved.
1102    pub fn unconfigured() -> Self {
1103        Self { model: None }
1104    }
1105}
1106
1107#[async_trait]
1108impl AgentTool for CommitTool {
1109    fn name(&self) -> &str {
1110        "commit"
1111    }
1112
1113    fn label(&self) -> &str {
1114        "Conventional Commit"
1115    }
1116
1117    fn essential(&self) -> bool {
1118        false
1119    }
1120
1121    fn description(&self) -> &str {
1122        "Analyze working-tree changes, extract a conventional commit scope, \
1123         generate a conventional commit message, and commit (or preview with \
1124         dry_run). Optionally update CHANGELOG.md and push."
1125    }
1126
1127    fn parameters_schema(&self) -> Value {
1128        json!({
1129            "type": "object",
1130            "properties": {
1131                "dry_run": {
1132                    "type": "boolean",
1133                    "description": "Preview the commit message without committing",
1134                    "default": false
1135                },
1136                "push": {
1137                    "type": "boolean",
1138                    "description": "Push after committing",
1139                    "default": false
1140                },
1141                "no_changelog": {
1142                    "type": "boolean",
1143                    "description": "Skip the CHANGELOG.md update",
1144                    "default": false
1145                },
1146                "context": {
1147                    "type": "string",
1148                    "description": "Optional extra context to guide the analysis"
1149                }
1150            }
1151        })
1152    }
1153
1154    async fn execute(
1155        &self,
1156        _tool_call_id: &str,
1157        params: Value,
1158        _signal: Option<oneshot::Receiver<()>>,
1159        ctx: &ToolContext,
1160    ) -> Result<AgentToolResult, ToolError> {
1161        let args = parse_args(&params)?;
1162        let cwd = ctx.root().to_path_buf();
1163        let git = GitOps::new(cwd.clone());
1164
1165        // 1. Collect changes.
1166        let numstat = git.numstat()?;
1167        let filtered: Vec<NumstatEntry> = numstat
1168            .iter()
1169            .filter(|e| !is_excluded_file(&e.path))
1170            .cloned()
1171            .collect();
1172        if filtered.is_empty() {
1173            return Ok(AgentToolResult::success("No changes to commit."));
1174        }
1175
1176        // 2. Deterministic scope extraction.
1177        let candidates = extract_scope_candidates(&numstat);
1178
1179        // 3. Analysis: LLM when configured, deterministic fallback otherwise.
1180        let (mut analysis, mut summary) = match self.model.as_ref() {
1181            Some(model) => {
1182                let diff = git.diff()?;
1183                match generate_analysis(model, &diff, &candidates, args.context.as_deref()).await {
1184                    Ok(plan) => plan,
1185                    Err(e) => {
1186                        let det = deterministic_analysis(&filtered, &candidates);
1187                        let det_summary = deterministic_summary(det.commit_type, &det.scope);
1188                        tracing::warn!(
1189                            "commit tool: LLM analysis failed ({e}), using deterministic fallback"
1190                        );
1191                        (det, det_summary)
1192                    }
1193                }
1194            }
1195            None => {
1196                let det = deterministic_analysis(&filtered, &candidates);
1197                let det_summary = deterministic_summary(det.commit_type, &det.scope);
1198                (det, det_summary)
1199            }
1200        };
1201
1202        // 4. Normalise + validate.
1203        summary = normalize_summary(&summary);
1204        analysis.scope = analysis.scope.trim().to_string();
1205        let validation = {
1206            let mut v = validate_summary(&summary);
1207            v.extend(validate_scope(&analysis.scope));
1208            v
1209        };
1210
1211        // 5. Format message.
1212        let message = format_commit_message(&analysis, &summary);
1213
1214        if args.dry_run {
1215            let mut output = String::new();
1216            if !validation.is_empty() {
1217                output.push_str("⚠ Validation warnings:\n");
1218                output.push_str(&validation.join("\n"));
1219                output.push_str("\n\n");
1220            }
1221            output.push_str("Dry run — would commit:\n\n");
1222            output.push_str(&message);
1223            return Ok(AgentToolResult::success(output).with_metadata(json!({
1224                "dry_run": true,
1225                "scope": analysis.scope,
1226                "type": analysis.commit_type.as_str(),
1227            })));
1228        }
1229
1230        // Validation is advisory for real commits but surfaced if present.
1231        if !validation.is_empty() {
1232            tracing::warn!(
1233                "commit tool: validation warnings: {}",
1234                validation.join("; ")
1235            );
1236        }
1237
1238        // 6. Stage + commit.
1239        git.stage_all()?;
1240        git.commit(&message)?;
1241        let hash = git.head_short().unwrap_or_else(|_| "unknown".to_string());
1242
1243        // 7. Changelog (best-effort, non-fatal).
1244        if !args.no_changelog
1245            && let Err(e) = update_changelog(&cwd, &analysis)
1246        {
1247            tracing::warn!("commit tool: changelog update failed: {e}");
1248        }
1249
1250        // 8. Push (optional).
1251        if args.push {
1252            git.push()?;
1253        }
1254
1255        Ok(
1256            AgentToolResult::success(format!("Committed {hash}:\n\n{message}")).with_metadata(
1257                json!({
1258                    "hash": hash,
1259                    "scope": analysis.scope,
1260                    "type": analysis.commit_type.as_str(),
1261                }),
1262            ),
1263        )
1264    }
1265}
1266
1267// ═══════════════════════════════════════════════════════════════════════════
1268// Tests
1269// ═══════════════════════════════════════════════════════════════════════════
1270
1271#[cfg(test)]
1272mod tests {
1273    use super::*;
1274
1275    fn entry(path: &str, additions: usize, deletions: usize) -> NumstatEntry {
1276        NumstatEntry {
1277            path: path.to_string(),
1278            additions,
1279            deletions,
1280        }
1281    }
1282
1283    // ── scope extraction ───────────────────────────────────────────────
1284
1285    #[test]
1286    fn scope_extraction_single_component() {
1287        let numstat = vec![
1288            entry("src/auth/login.rs", 50, 10),
1289            entry("src/auth/logout.rs", 20, 5),
1290        ];
1291        let candidates = extract_scope_candidates(&numstat);
1292        assert_eq!(candidates.len(), 1);
1293        assert_eq!(candidates[0].name, "src/auth");
1294        assert_eq!(candidates[0].segments, 2);
1295    }
1296
1297    #[test]
1298    fn scope_extraction_ranks_by_churn() {
1299        let numstat = vec![
1300            entry("src/big/module.rs", 200, 50),
1301            entry("src/tiny/util.rs", 5, 1),
1302            entry("docs/readme.md", 3, 0),
1303        ];
1304        let candidates = extract_scope_candidates(&numstat);
1305        assert!(!candidates.is_empty());
1306        // The high-churn component should rank first.
1307        assert_eq!(candidates[0].name, "src/big");
1308    }
1309
1310    #[test]
1311    fn scope_extraction_excludes_lock_files() {
1312        let numstat = vec![
1313            entry("Cargo.lock", 5000, 100),
1314            entry("src/main.rs", 10, 2),
1315            entry("package-lock.json", 9000, 0),
1316            entry("pnpm-lock.yaml", 300, 10),
1317            entry("go.sum", 800, 5),
1318        ];
1319        let candidates = extract_scope_candidates(&numstat);
1320        // Only src/main.rs survives; lock files are excluded.
1321        assert!(
1322            candidates
1323                .iter()
1324                .all(|c| !c.name.contains("lock") && !c.name.contains("sum"))
1325        );
1326        assert_eq!(candidates.len(), 1);
1327        assert_eq!(candidates[0].name, "src");
1328    }
1329
1330    #[test]
1331    fn scope_extraction_single_segment_boost() {
1332        let numstat = vec![entry("README.md", 10, 0)];
1333        let candidates = extract_scope_candidates(&numstat);
1334        assert_eq!(candidates.len(), 1);
1335        assert_eq!(candidates[0].name, "README");
1336        // Single-segment weight is dampened (×0.8).
1337        assert!((candidates[0].weight - 8.0).abs() < 0.001);
1338    }
1339
1340    #[test]
1341    fn wide_change_detection_many_roots() {
1342        let numstat = vec![
1343            entry("auth/login.rs", 30, 0),
1344            entry("billing/invoice.rs", 30, 0),
1345            entry("reports/export.rs", 30, 0),
1346        ];
1347        // Three distinct roots (auth, billing, reports) each holding 1/3 → wide.
1348        assert!(is_wide_change(&numstat));
1349    }
1350
1351    #[test]
1352    fn wide_change_false_for_single_scope() {
1353        let numstat = vec![
1354            entry("src/auth/login.rs", 100, 10),
1355            entry("src/auth/session.rs", 20, 5),
1356        ];
1357        assert!(!is_wide_change(&numstat));
1358    }
1359
1360    // ── numstat parsing ────────────────────────────────────────────────
1361
1362    #[test]
1363    fn parse_numstat_basic() {
1364        let output = "10\t2\tsrc/main.rs\n3\t0\tdocs/readme.md\n";
1365        let entries = parse_numstat(output);
1366        assert_eq!(entries.len(), 2);
1367        assert_eq!(entries[0].path, "src/main.rs");
1368        assert_eq!(entries[0].additions, 10);
1369        assert_eq!(entries[0].deletions, 2);
1370        assert_eq!(entries[1].path, "docs/readme.md");
1371    }
1372
1373    #[test]
1374    fn parse_numstat_binary_file() {
1375        let output = "-\t-\tassets/logo.png\n";
1376        let entries = parse_numstat(output);
1377        assert_eq!(entries.len(), 1);
1378        assert_eq!(entries[0].path, "assets/logo.png");
1379        assert_eq!(entries[0].additions, 0);
1380        assert_eq!(entries[0].deletions, 0);
1381    }
1382
1383    #[test]
1384    fn parse_numstat_skips_blank() {
1385        let output = "\n10\t2\tsrc/main.rs\n\n";
1386        let entries = parse_numstat(output);
1387        assert_eq!(entries.len(), 1);
1388    }
1389
1390    // ── message format ─────────────────────────────────────────────────
1391
1392    fn feat_auth_analysis() -> ConventionalAnalysis {
1393        ConventionalAnalysis {
1394            commit_type: CommitType::Feat,
1395            scope: "auth".to_string(),
1396            details: vec![ConventionalDetail {
1397                text: "Add OAuth2 login flow.".to_string(),
1398                changelog_category: Some(ChangelogCategory::Added),
1399                user_visible: true,
1400            }],
1401            issue_refs: vec!["#42".to_string()],
1402        }
1403    }
1404
1405    #[test]
1406    fn message_format_with_scope_and_refs() {
1407        let analysis = feat_auth_analysis();
1408        let msg = format_commit_message(&analysis, "Add OAuth2 login");
1409        assert!(msg.starts_with("feat(auth): Add OAuth2 login"));
1410        assert!(msg.contains("- Add OAuth2 login flow."));
1411        assert!(msg.contains("Refs #42"));
1412    }
1413
1414    #[test]
1415    fn message_format_without_scope() {
1416        let analysis = ConventionalAnalysis {
1417            commit_type: CommitType::Fix,
1418            scope: String::new(),
1419            details: vec![ConventionalDetail {
1420                text: "Correct off-by-one.".to_string(),
1421                changelog_category: None,
1422                user_visible: true,
1423            }],
1424            issue_refs: Vec::new(),
1425        };
1426        let msg = format_commit_message(&analysis, "Fix crash");
1427        assert!(msg.starts_with("fix: Fix crash\n\n- Correct off-by-one."));
1428        assert!(!msg.contains("Refs"));
1429    }
1430
1431    #[test]
1432    fn message_format_empty_details() {
1433        let analysis = ConventionalAnalysis {
1434            commit_type: CommitType::Chore,
1435            scope: "deps".to_string(),
1436            details: Vec::new(),
1437            issue_refs: Vec::new(),
1438        };
1439        let msg = format_commit_message(&analysis, "Bump deps");
1440        assert_eq!(msg, "chore(deps): Bump deps");
1441    }
1442
1443    #[test]
1444    fn message_format_multiple_refs() {
1445        let analysis = ConventionalAnalysis {
1446            commit_type: CommitType::Fix,
1447            scope: String::new(),
1448            details: Vec::new(),
1449            issue_refs: vec!["#1".to_string(), "#2".to_string()],
1450        };
1451        let msg = format_commit_message(&analysis, "Fix things");
1452        assert!(msg.contains("Refs #1\nRefs #2"));
1453    }
1454
1455    // ── validation ─────────────────────────────────────────────────────
1456
1457    #[test]
1458    fn validation_rejects_long_summary() {
1459        let long = "x".repeat(73);
1460        let errors = validate_summary(&long);
1461        assert!(errors.iter().any(|e| e.contains("72 characters")));
1462    }
1463
1464    #[test]
1465    fn validation_accepts_max_length_summary() {
1466        let exact = "x".repeat(72);
1467        let errors = validate_summary(&exact);
1468        assert!(!errors.iter().any(|e| e.contains("72 characters")));
1469    }
1470
1471    #[test]
1472    fn validation_rejects_trailing_period() {
1473        let errors = validate_summary("Add feature.");
1474        assert!(errors.iter().any(|e| e.contains("period")));
1475    }
1476
1477    #[test]
1478    fn validation_rejects_multiline_summary() {
1479        let errors = validate_summary("line one\nline two");
1480        assert!(errors.iter().any(|e| e.contains("single line")));
1481    }
1482
1483    #[test]
1484    fn validation_rejects_empty_summary() {
1485        let errors = validate_summary("   ");
1486        assert!(errors.iter().any(|e| e.contains("empty")));
1487    }
1488
1489    #[test]
1490    fn validation_rejects_uppercase_scope() {
1491        let errors = validate_scope("Auth");
1492        assert!(errors.iter().any(|e| e.contains("lowercase")));
1493    }
1494
1495    #[test]
1496    fn validation_rejects_three_segment_scope() {
1497        let errors = validate_scope("a/b/c");
1498        assert!(errors.iter().any(|e| e.contains("2 segments")));
1499    }
1500
1501    #[test]
1502    fn validation_rejects_invalid_scope_chars() {
1503        let errors = validate_scope("auth config");
1504        assert!(errors.iter().any(|e| e.contains("invalid characters")));
1505    }
1506
1507    #[test]
1508    fn validation_accepts_empty_scope() {
1509        assert!(validate_scope("").is_empty());
1510    }
1511
1512    #[test]
1513    fn validation_accepts_two_segment_scope() {
1514        assert!(validate_scope("oxicode-agent/auth").is_empty());
1515    }
1516
1517    #[test]
1518    fn normalize_summary_strips_period_and_truncates() {
1519        assert_eq!(normalize_summary("Add feature."), "Add feature");
1520        let long = format!("{}.", "x".repeat(80));
1521        let normalized = normalize_summary(&long);
1522        assert!(normalized.chars().count() <= 72);
1523        assert!(!normalized.ends_with('.'));
1524    }
1525
1526    #[test]
1527    fn normalize_summary_collapses_to_single_line() {
1528        assert_eq!(normalize_summary("first\nsecond"), "first");
1529    }
1530
1531    // ── topological sort ───────────────────────────────────────────────
1532
1533    fn group(id: &str, deps: &[&str]) -> CommitGroup {
1534        CommitGroup {
1535            id: id.to_string(),
1536            files: Vec::new(),
1537            analysis: ConventionalAnalysis {
1538                commit_type: CommitType::Feat,
1539                scope: String::new(),
1540                details: Vec::new(),
1541                issue_refs: Vec::new(),
1542            },
1543            summary: String::new(),
1544            dependencies: deps.iter().map(|s| s.to_string()).collect(),
1545        }
1546    }
1547
1548    #[test]
1549    fn topo_sort_no_cycle() {
1550        let mut groups = vec![group("a", &[]), group("b", &["a"]), group("c", &["b"])];
1551        compute_dependency_order(&mut groups).expect("no cycle");
1552        let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1553        assert_eq!(ids, vec!["a", "b", "c"]);
1554    }
1555
1556    #[test]
1557    fn topo_sort_cycle_detected() {
1558        let mut groups = vec![group("a", &["b"]), group("b", &["a"])];
1559        let result = compute_dependency_order(&mut groups);
1560        assert!(result.is_err());
1561        let err = result.unwrap_err();
1562        assert!(err.contains("cycle"));
1563    }
1564
1565    #[test]
1566    fn topo_sort_unknown_dependency() {
1567        let mut groups = vec![group("a", &["nonexistent"])];
1568        let result = compute_dependency_order(&mut groups);
1569        assert!(result.is_err());
1570        assert!(result.unwrap_err().contains("Unknown dependency"));
1571    }
1572
1573    #[test]
1574    fn topo_sort_self_dependency() {
1575        let mut groups = vec![group("a", &["a"])];
1576        let result = compute_dependency_order(&mut groups);
1577        assert!(result.is_err());
1578        assert!(result.unwrap_err().contains("itself"));
1579    }
1580
1581    #[test]
1582    fn topo_sort_independent_groups_preserved() {
1583        let mut groups = vec![group("x", &[]), group("y", &[]), group("z", &[])];
1584        compute_dependency_order(&mut groups).expect("ok");
1585        // No dependencies → stable order preserved.
1586        let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1587        assert_eq!(ids, vec!["x", "y", "z"]);
1588    }
1589
1590    #[test]
1591    fn topo_sort_diamond() {
1592        // d depends on b and c; b and c depend on a.
1593        let mut groups = vec![
1594            group("d", &["b", "c"]),
1595            group("c", &["a"]),
1596            group("b", &["a"]),
1597            group("a", &[]),
1598        ];
1599        compute_dependency_order(&mut groups).expect("no cycle");
1600        let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1601        assert_eq!(ids[0], "a");
1602        assert_eq!(ids[3], "d");
1603        // b and c come after a and before d.
1604        let b_pos = ids.iter().position(|&i| i == "b").unwrap();
1605        let c_pos = ids.iter().position(|&i| i == "c").unwrap();
1606        assert!(b_pos > 0 && b_pos < 3);
1607        assert!(c_pos > 0 && c_pos < 3);
1608    }
1609
1610    #[test]
1611    fn topo_sort_dedupes_repeated_dependency() {
1612        // Declaring the same dependency twice must not corrupt in-degree.
1613        let mut groups = vec![group("b", &["a", "a"]), group("a", &[])];
1614        compute_dependency_order(&mut groups).expect("no cycle");
1615        let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1616        assert_eq!(ids, vec!["a", "b"]);
1617    }
1618
1619    // ── exclusions ─────────────────────────────────────────────────────
1620
1621    #[test]
1622    fn excludes_common_lock_files() {
1623        assert!(is_excluded_file("Cargo.lock"));
1624        assert!(is_excluded_file("crates/foo/Cargo.lock"));
1625        assert!(is_excluded_file("package-lock.json"));
1626        assert!(is_excluded_file("yarn.lock"));
1627        assert!(is_excluded_file("pnpm-lock.yaml"));
1628        assert!(is_excluded_file("go.sum"));
1629        assert!(is_excluded_file("uv.lock"));
1630        assert!(is_excluded_file("flake.lock"));
1631        assert!(is_excluded_file("app/config.yaml.lock"));
1632    }
1633
1634    #[test]
1635    fn does_not_exclude_source_files() {
1636        assert!(!is_excluded_file("src/main.rs"));
1637        assert!(!is_excluded_file("lib/index.ts"));
1638        assert!(!is_excluded_file("Cargo.toml"));
1639        assert!(!is_excluded_file("README.md"));
1640    }
1641
1642    // ── CommitType ─────────────────────────────────────────────────────
1643
1644    #[test]
1645    fn commit_type_roundtrip() {
1646        for id in [
1647            "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore",
1648            "revert",
1649        ] {
1650            let ty = CommitType::from_id(id).unwrap_or_else(|| panic!("unknown type {id}"));
1651            assert_eq!(ty.as_str(), id);
1652            assert_eq!(ty.to_string(), id);
1653        }
1654        assert!(CommitType::from_id("unknown").is_none());
1655    }
1656
1657    // ── deterministic fallback ─────────────────────────────────────────
1658
1659    #[test]
1660    fn deterministic_analysis_docs() {
1661        let entries = vec![entry("docs/guide.md", 20, 5)];
1662        let candidates = extract_scope_candidates(&entries);
1663        let analysis = deterministic_analysis(&entries, &candidates);
1664        assert_eq!(analysis.commit_type, CommitType::Docs);
1665        assert_eq!(analysis.scope, "docs");
1666        assert!(!analysis.details.is_empty());
1667    }
1668
1669    #[test]
1670    fn deterministic_analysis_tests() {
1671        let entries = vec![entry("src/auth_test.rs", 40, 2)];
1672        let candidates = extract_scope_candidates(&entries);
1673        let analysis = deterministic_analysis(&entries, &candidates);
1674        assert_eq!(analysis.commit_type, CommitType::Test);
1675    }
1676
1677    #[test]
1678    fn deterministic_summary_is_valid() {
1679        let summary = deterministic_summary(CommitType::Feat, "auth");
1680        assert!(validate_summary(&summary).is_empty());
1681        assert!(summary.contains("Add"));
1682    }
1683
1684    // ── JSON extraction ────────────────────────────────────────────────
1685
1686    #[test]
1687    fn extract_json_object_from_fence() {
1688        let text = "Here is the plan:\n```json\n{\"type\":\"fix\",\"scope\":\"a\"}\n```\n";
1689        let extracted = extract_json_object(text).expect("found json");
1690        assert!(extracted.contains("\"type\":\"fix\""));
1691    }
1692
1693    #[test]
1694    fn extract_json_object_nested() {
1695        let text = "{\"a\":{\"b\":1},\"c\":2}";
1696        let extracted = extract_json_object(text).expect("found json");
1697        assert_eq!(extracted, text);
1698    }
1699
1700    #[test]
1701    fn extract_json_object_with_brace_in_string() {
1702        let text = "{\"text\":\"has } brace\"}";
1703        let extracted = extract_json_object(text).expect("found json");
1704        assert_eq!(extracted, text);
1705    }
1706
1707    // ── changelog ──────────────────────────────────────────────────────
1708
1709    #[test]
1710    fn update_changelog_appends_under_unreleased() {
1711        let dir = tempfile::tempdir().expect("tempdir");
1712        let changelog = dir.path().join("CHANGELOG.md");
1713        std::fs::write(
1714            &changelog,
1715            "# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2024-01-01\n\n- initial\n",
1716        )
1717        .expect("write");
1718        let analysis = ConventionalAnalysis {
1719            commit_type: CommitType::Feat,
1720            scope: String::new(),
1721            details: vec![ConventionalDetail {
1722                text: "Add OAuth2 login.".to_string(),
1723                changelog_category: Some(ChangelogCategory::Added),
1724                user_visible: true,
1725            }],
1726            issue_refs: Vec::new(),
1727        };
1728        let modified = update_changelog(dir.path(), &analysis).expect("ok");
1729        assert!(modified);
1730        let content = std::fs::read_to_string(&changelog).expect("read");
1731        let unreleased_start = content.find("## [Unreleased]").unwrap();
1732        let v1_start = content.find("## [1.0.0]").unwrap();
1733        let unreleased = &content[unreleased_start..v1_start];
1734        assert!(unreleased.contains("### Added"));
1735        assert!(unreleased.contains("- Add OAuth2 login"));
1736    }
1737
1738    #[test]
1739    fn update_changelog_skips_without_unreleased() {
1740        let dir = tempfile::tempdir().expect("tempdir");
1741        std::fs::write(
1742            dir.path().join("CHANGELOG.md"),
1743            "# Changelog\n\n## [1.0.0]\n",
1744        )
1745        .unwrap();
1746        let analysis = ConventionalAnalysis {
1747            commit_type: CommitType::Feat,
1748            scope: String::new(),
1749            details: vec![ConventionalDetail {
1750                text: "Add.".to_string(),
1751                changelog_category: Some(ChangelogCategory::Added),
1752                user_visible: true,
1753            }],
1754            issue_refs: Vec::new(),
1755        };
1756        let modified = update_changelog(dir.path(), &analysis).expect("ok");
1757        assert!(!modified);
1758    }
1759
1760    #[test]
1761    fn update_changelog_no_file_is_noop() {
1762        let dir = tempfile::tempdir().expect("tempdir");
1763        let analysis = ConventionalAnalysis {
1764            commit_type: CommitType::Feat,
1765            scope: String::new(),
1766            details: vec![ConventionalDetail {
1767                text: "Add.".to_string(),
1768                changelog_category: Some(ChangelogCategory::Added),
1769                user_visible: true,
1770            }],
1771            issue_refs: Vec::new(),
1772        };
1773        let modified = update_changelog(dir.path(), &analysis).expect("ok");
1774        assert!(!modified);
1775    }
1776
1777    // ── parse_args ─────────────────────────────────────────────────────
1778
1779    #[test]
1780    fn parse_args_defaults() {
1781        let args = parse_args(&json!({})).expect("ok");
1782        assert!(!args.dry_run);
1783        assert!(!args.push);
1784        assert!(!args.no_changelog);
1785        assert!(args.context.is_none());
1786    }
1787
1788    #[test]
1789    fn parse_args_all_set() {
1790        let args = parse_args(
1791            &json!({"dry_run": true, "push": true, "no_changelog": true, "context": "ctx"}),
1792        )
1793        .expect("ok");
1794        assert!(args.dry_run);
1795        assert!(args.push);
1796        assert!(args.no_changelog);
1797        assert_eq!(args.context.as_deref(), Some("ctx"));
1798    }
1799}