1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum CommitType {
29 Feat,
31 Fix,
33 Docs,
35 Style,
38 Refactor,
40 Perf,
42 Test,
44 Build,
46 Ci,
48 Chore,
50 Revert,
52}
53
54impl CommitType {
55 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "lowercase")]
102pub enum ChangelogCategory {
103 Added,
105 Changed,
107 Deprecated,
109 Removed,
111 Fixed,
113 Security,
115 Internal,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ConventionalDetail {
122 pub text: String,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub changelog_category: Option<ChangelogCategory>,
127 #[serde(default = "default_true")]
129 pub user_visible: bool,
130}
131
132fn default_true() -> bool {
134 true
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ConventionalAnalysis {
141 #[serde(rename = "type")]
143 pub commit_type: CommitType,
144 pub scope: String,
146 pub details: Vec<ConventionalDetail>,
148 #[serde(default)]
150 pub issue_refs: Vec<String>,
151}
152
153#[derive(Debug, Clone)]
155pub struct CommitGroup {
156 pub id: String,
158 pub files: Vec<String>,
160 pub analysis: ConventionalAnalysis,
162 pub summary: String,
164 pub dependencies: Vec<String>,
166}
167
168#[derive(Debug, Clone)]
174pub struct NumstatEntry {
175 pub path: String,
177 pub additions: usize,
179 pub deletions: usize,
181}
182
183#[derive(Debug, Clone)]
185pub struct ScopeCandidate {
186 pub name: String,
188 pub weight: f64,
190 pub segments: usize,
192}
193
194const 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
223const 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
235pub 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
247const PLACEHOLDER_DIRS: &[&str] = &["src", "lib", "bin", "app", "cmd", "internal", "main"];
249
250fn 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 let dirs = &segments[..segments.len() - 1];
264 if dirs.is_empty() {
265 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
276pub 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
319pub 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
345pub 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
370pub 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
414pub 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
438pub 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
459fn 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
479pub 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
498pub 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 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
577const 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
592fn 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#[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
648async 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
698fn 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
735fn 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
766fn 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
789fn 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
908fn 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
925fn 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
991struct GitOps {
997 cwd: PathBuf,
999}
1000
1001impl GitOps {
1002 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#[derive(Debug, Default)]
1059struct CommitArgs {
1060 dry_run: bool,
1062 push: bool,
1064 no_changelog: bool,
1066 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
1079pub struct CommitTool {
1086 model: Option<oxicode_ai::Model>,
1088}
1089
1090impl CommitTool {
1091 pub fn new(model: oxicode_ai::Model) -> Self {
1095 Self { model: Some(model) }
1096 }
1097
1098 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(¶ms)?;
1162 let cwd = ctx.root().to_path_buf();
1163 let git = GitOps::new(cwd.clone());
1164
1165 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 let candidates = extract_scope_candidates(&numstat);
1178
1179 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 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 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 if !validation.is_empty() {
1232 tracing::warn!(
1233 "commit tool: validation warnings: {}",
1234 validation.join("; ")
1235 );
1236 }
1237
1238 git.stage_all()?;
1240 git.commit(&message)?;
1241 let hash = git.head_short().unwrap_or_else(|_| "unknown".to_string());
1242
1243 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 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#[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 #[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 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 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 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 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 #[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 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 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}