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: &oxi_ai::Model,
654 diff: &str,
655 candidates: &[ScopeCandidate],
656 extra_context: Option<&str>,
657) -> Result<(ConventionalAnalysis, String), String> {
658 let scope_hint = if candidates.is_empty() {
659 "(none — derive from the diff)".to_string()
660 } else {
661 candidates
662 .iter()
663 .take(5)
664 .map(|c| format!("- {} (weight {:.0})", c.name, c.weight))
665 .collect::<Vec<_>>()
666 .join("\n")
667 };
668
669 let mut user =
670 format!("Ranked scope candidates (by churn):\n{scope_hint}\n\n--- diff ---\n{diff}");
671 if let Some(ctx) = extra_context {
672 user.push_str(&format!("\n\n--- additional context ---\n{ctx}"));
673 }
674
675 let mut context = oxi_ai::Context::new().with_system_prompt(ANALYSIS_SYSTEM);
676 context.add_message(oxi_ai::Message::User(oxi_ai::UserMessage::new(user)));
677 context.set_tools(vec![oxi_ai::Tool::new(
678 "create_conventional_analysis",
679 "Emit a conventional-commit analysis for the given diff.",
680 analysis_tool_schema(),
681 )]);
682
683 let options = oxi_ai::StreamOptions {
684 max_tokens: Some(2400),
685 temperature: Some(0.2),
686 ..Default::default()
687 };
688
689 let response = oxi_ai::complete(model, &context, Some(options))
690 .await
691 .map_err(|e| format!("LLM analysis failed: {e}"))?;
692
693 parse_analysis_response(&response)
694}
695
696fn parse_analysis_response(
701 msg: &oxi_ai::AssistantMessage,
702) -> Result<(ConventionalAnalysis, String), String> {
703 for block in &msg.content {
704 if let oxi_ai::ContentBlock::ToolCall(call) = block
705 && call.name == "create_conventional_analysis"
706 {
707 let plan: LlmAnalysis = serde_json::from_value(call.arguments.clone())
708 .map_err(|e| format!("Invalid analysis tool arguments: {e}"))?;
709 return Ok(split_plan(plan));
710 }
711 }
712
713 let text = msg.text_content();
714 if let Some(raw) = extract_json_object(&text) {
715 let plan: LlmAnalysis =
716 serde_json::from_str(&raw).map_err(|e| format!("Invalid analysis JSON: {e}"))?;
717 return Ok(split_plan(plan));
718 }
719
720 Err("LLM did not return a conventional analysis".to_string())
721}
722
723fn split_plan(plan: LlmAnalysis) -> (ConventionalAnalysis, String) {
724 let analysis = ConventionalAnalysis {
725 commit_type: plan.commit_type,
726 scope: plan.scope,
727 details: plan.details,
728 issue_refs: plan.issue_refs,
729 };
730 (analysis, plan.summary)
731}
732
733fn extract_json_object(text: &str) -> Option<String> {
735 let start = text.find('{')?;
736 let bytes = text.as_bytes();
737 let mut depth = 0i32;
738 let mut in_string = false;
739 let mut escape = false;
740 for (i, &byte) in bytes.iter().enumerate().skip(start) {
741 let c = byte as char;
742 if in_string {
743 if escape {
744 escape = false;
745 } else if c == '\\' {
746 escape = true;
747 } else if c == '"' {
748 in_string = false;
749 }
750 } else if c == '"' {
751 in_string = true;
752 } else if c == '{' {
753 depth += 1;
754 } else if c == '}' {
755 depth -= 1;
756 if depth == 0 {
757 return Some(text[start..=i].to_string());
758 }
759 }
760 }
761 None
762}
763
764fn deterministic_analysis(
770 entries: &[NumstatEntry],
771 candidates: &[ScopeCandidate],
772) -> ConventionalAnalysis {
773 let commit_type = infer_commit_type(entries);
774 let scope = candidates
775 .first()
776 .map(|c| c.name.clone())
777 .unwrap_or_default();
778 let details = deterministic_details(entries);
779 ConventionalAnalysis {
780 commit_type,
781 scope,
782 details,
783 issue_refs: Vec::new(),
784 }
785}
786
787fn deterministic_summary(commit_type: CommitType, scope: &str) -> String {
789 let verb = match commit_type {
790 CommitType::Feat => "Add",
791 CommitType::Fix => "Fix",
792 CommitType::Docs => "Document",
793 CommitType::Refactor => "Refactor",
794 CommitType::Test => "Add tests for",
795 CommitType::Perf => "Optimize",
796 CommitType::Build => "Update build config for",
797 CommitType::Ci => "Update CI for",
798 CommitType::Style => "Format",
799 CommitType::Revert => "Revert",
800 CommitType::Chore => "Update",
801 };
802 let target = if scope.is_empty() {
803 "the project"
804 } else {
805 scope
806 };
807 normalize_summary(&format!("{verb} {target}"))
808}
809
810fn infer_commit_type(entries: &[NumstatEntry]) -> CommitType {
811 let paths: Vec<&str> = entries
812 .iter()
813 .filter(|e| !is_excluded_file(&e.path))
814 .map(|e| e.path.as_str())
815 .collect();
816 if paths.is_empty() {
817 return CommitType::Chore;
818 }
819 if paths.iter().all(|p| is_doc_file(p)) {
820 return CommitType::Docs;
821 }
822 if paths.iter().all(|p| is_test_file(p)) {
823 return CommitType::Test;
824 }
825 if paths.iter().all(|p| is_ci_file(p)) {
826 return CommitType::Ci;
827 }
828 if paths.iter().all(|p| is_build_file(p)) {
829 return CommitType::Build;
830 }
831 CommitType::Chore
832}
833
834fn deterministic_details(entries: &[NumstatEntry]) -> Vec<ConventionalDetail> {
835 entries
836 .iter()
837 .filter(|e| !is_excluded_file(&e.path))
838 .take(6)
839 .map(|e| ConventionalDetail {
840 text: format!("Update {}.", short_path(&e.path)),
841 changelog_category: None,
842 user_visible: true,
843 })
844 .collect()
845}
846
847fn short_path(path: &str) -> String {
848 path.rsplit_once('/')
849 .map(|(_, base)| base.to_string())
850 .unwrap_or_else(|| path.to_string())
851}
852
853fn is_doc_file(path: &str) -> bool {
854 let lower = path.to_ascii_lowercase();
855 lower.ends_with(".md")
856 || lower.ends_with(".txt")
857 || lower.ends_with(".rst")
858 || lower.starts_with("docs/")
859 || lower.contains("/docs/")
860 || lower == "readme.md"
861 || lower == "changelog.md"
862 || lower == "license"
863 || lower == "license.md"
864}
865
866fn is_test_file(path: &str) -> bool {
867 let lower = path.to_ascii_lowercase();
868 lower.ends_with("_test.rs")
869 || lower.ends_with(".test.ts")
870 || lower.ends_with(".test.tsx")
871 || lower.ends_with(".test.js")
872 || lower.ends_with(".spec.ts")
873 || lower.ends_with(".spec.js")
874 || lower.contains("/tests/")
875 || lower.contains("/test/")
876 || lower.starts_with("test/")
877 || lower.starts_with("tests/")
878 || lower.ends_with("_test.go")
879 || lower.ends_with("test.py")
880 || lower.ends_with("_test.py")
881}
882
883fn is_ci_file(path: &str) -> bool {
884 let lower = path.to_ascii_lowercase();
885 lower.starts_with(".github/")
886 || lower.starts_with("ci/")
887 || lower.contains("/.gitlab-ci")
888 || lower == ".gitlab-ci.yml"
889 || lower == "dockerfile"
890 || lower.ends_with("/dockerfile")
891}
892
893fn is_build_file(path: &str) -> bool {
894 let lower = path.to_ascii_lowercase();
895 lower.ends_with("cargo.toml")
896 || lower.ends_with("package.json")
897 || lower.ends_with("tsconfig.json")
898 || lower.ends_with("go.mod")
899 || lower.ends_with("go.sum")
900 || lower == "makefile"
901 || lower == "justfile"
902 || lower.ends_with("dockerfile")
903 || lower.ends_with(".cmake")
904}
905
906fn category_title(cat: ChangelogCategory) -> &'static str {
912 match cat {
913 ChangelogCategory::Added => "Added",
914 ChangelogCategory::Changed => "Changed",
915 ChangelogCategory::Deprecated => "Deprecated",
916 ChangelogCategory::Removed => "Removed",
917 ChangelogCategory::Fixed => "Fixed",
918 ChangelogCategory::Security => "Security",
919 ChangelogCategory::Internal => "Internal",
920 }
921}
922
923fn update_changelog(root: &Path, analysis: &ConventionalAnalysis) -> std::io::Result<bool> {
934 let by_category: Vec<(ChangelogCategory, String)> = analysis
935 .details
936 .iter()
937 .filter(|d| d.user_visible)
938 .filter_map(|d| {
939 d.changelog_category
940 .map(|cat| (cat, d.text.trim_end_matches('.').to_string()))
941 })
942 .collect();
943 if by_category.is_empty() {
944 return Ok(false);
945 }
946
947 let path = root.join("CHANGELOG.md");
948 let content = match std::fs::read_to_string(&path) {
949 Ok(c) => c,
950 Err(_) => return Ok(false),
951 };
952
953 let marker = "[Unreleased]";
954 let Some(marker_idx) = content.find(marker) else {
955 return Ok(false);
956 };
957 let line_end = content[marker_idx..]
958 .find('\n')
959 .map(|n| marker_idx + n)
960 .unwrap_or(content.len());
961 let section_end = content[line_end..]
962 .find("\n## ")
963 .map(|n| line_end + n)
964 .unwrap_or(content.len());
965
966 let section = &content[line_end..section_end];
967 let mut new_section = section.to_string();
968 for (cat, text) in &by_category {
969 let heading = format!("### {}\n", category_title(*cat));
970 if let Some(hpos) = new_section.find(&heading) {
971 let insert_at = hpos + heading.len();
972 new_section.insert_str(insert_at, &format!("- {text}\n"));
973 } else {
974 if !new_section.is_empty() && !new_section.ends_with('\n') {
975 new_section.push('\n');
976 }
977 new_section.push_str(&format!("\n### {}\n- {text}\n", category_title(*cat)));
978 }
979 }
980
981 let mut new_content = String::with_capacity(content.len() + new_section.len());
982 new_content.push_str(&content[..line_end]);
983 new_content.push_str(&new_section);
984 new_content.push_str(&content[section_end..]);
985 std::fs::write(&path, new_content)?;
986 Ok(true)
987}
988
989struct GitOps {
995 cwd: PathBuf,
997}
998
999impl GitOps {
1000 fn new(cwd: PathBuf) -> Self {
1002 Self { cwd }
1003 }
1004
1005 fn run(&self, args: &[&str]) -> Result<String, String> {
1006 let output = std::process::Command::new("git")
1007 .args(args)
1008 .current_dir(&self.cwd)
1009 .output()
1010 .map_err(|e| format!("Failed to run git {}: {e}", args.join(" ")))?;
1011 if !output.status.success() {
1012 return Err(format!(
1013 "git {} failed: {}",
1014 args.join(" "),
1015 String::from_utf8_lossy(&output.stderr).trim()
1016 ));
1017 }
1018 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1019 }
1020
1021 fn numstat(&self) -> Result<Vec<NumstatEntry>, String> {
1022 let output = self.run(&["diff", "--numstat", "HEAD"])?;
1023 Ok(parse_numstat(&output))
1024 }
1025
1026 fn diff(&self) -> Result<String, String> {
1027 self.run(&["diff", "HEAD"])
1028 }
1029
1030 fn stage_all(&self) -> Result<(), String> {
1031 self.run(&["add", "-A"])?;
1032 Ok(())
1033 }
1034
1035 fn commit(&self, message: &str) -> Result<(), String> {
1036 self.run(&["commit", "-m", message])?;
1037 Ok(())
1038 }
1039
1040 fn push(&self) -> Result<(), String> {
1041 self.run(&["push"])?;
1042 Ok(())
1043 }
1044
1045 fn head_short(&self) -> Result<String, String> {
1046 let output = self.run(&["rev-parse", "--short", "HEAD"])?;
1047 Ok(output.trim().to_string())
1048 }
1049}
1050
1051#[derive(Debug, Default)]
1057struct CommitArgs {
1058 dry_run: bool,
1060 push: bool,
1062 no_changelog: bool,
1064 context: Option<String>,
1066}
1067
1068fn parse_args(params: &Value) -> Result<CommitArgs, String> {
1069 Ok(CommitArgs {
1070 dry_run: params["dry_run"].as_bool().unwrap_or(false),
1071 push: params["push"].as_bool().unwrap_or(false),
1072 no_changelog: params["no_changelog"].as_bool().unwrap_or(false),
1073 context: params["context"].as_str().map(String::from),
1074 })
1075}
1076
1077pub struct CommitTool {
1084 model: Option<oxi_ai::Model>,
1086}
1087
1088impl CommitTool {
1089 pub fn new(model: oxi_ai::Model) -> Self {
1093 Self { model: Some(model) }
1094 }
1095
1096 pub fn unconfigured() -> Self {
1101 Self { model: None }
1102 }
1103}
1104
1105#[async_trait]
1106impl AgentTool for CommitTool {
1107 fn name(&self) -> &str {
1108 "commit"
1109 }
1110
1111 fn label(&self) -> &str {
1112 "Conventional Commit"
1113 }
1114
1115 fn essential(&self) -> bool {
1116 false
1117 }
1118
1119 fn description(&self) -> &str {
1120 "Analyze working-tree changes, extract a conventional commit scope, \
1121 generate a conventional commit message, and commit (or preview with \
1122 dry_run). Optionally update CHANGELOG.md and push."
1123 }
1124
1125 fn parameters_schema(&self) -> Value {
1126 json!({
1127 "type": "object",
1128 "properties": {
1129 "dry_run": {
1130 "type": "boolean",
1131 "description": "Preview the commit message without committing",
1132 "default": false
1133 },
1134 "push": {
1135 "type": "boolean",
1136 "description": "Push after committing",
1137 "default": false
1138 },
1139 "no_changelog": {
1140 "type": "boolean",
1141 "description": "Skip the CHANGELOG.md update",
1142 "default": false
1143 },
1144 "context": {
1145 "type": "string",
1146 "description": "Optional extra context to guide the analysis"
1147 }
1148 }
1149 })
1150 }
1151
1152 async fn execute(
1153 &self,
1154 _tool_call_id: &str,
1155 params: Value,
1156 _signal: Option<oneshot::Receiver<()>>,
1157 ctx: &ToolContext,
1158 ) -> Result<AgentToolResult, ToolError> {
1159 let args = parse_args(¶ms)?;
1160 let cwd = ctx.root().to_path_buf();
1161 let git = GitOps::new(cwd.clone());
1162
1163 let numstat = git.numstat()?;
1165 let filtered: Vec<NumstatEntry> = numstat
1166 .iter()
1167 .filter(|e| !is_excluded_file(&e.path))
1168 .cloned()
1169 .collect();
1170 if filtered.is_empty() {
1171 return Ok(AgentToolResult::success("No changes to commit."));
1172 }
1173
1174 let candidates = extract_scope_candidates(&numstat);
1176
1177 let (mut analysis, mut summary) = match self.model.as_ref() {
1179 Some(model) => {
1180 let diff = git.diff()?;
1181 match generate_analysis(model, &diff, &candidates, args.context.as_deref()).await {
1182 Ok(plan) => plan,
1183 Err(e) => {
1184 let det = deterministic_analysis(&filtered, &candidates);
1185 let det_summary = deterministic_summary(det.commit_type, &det.scope);
1186 tracing::warn!(
1187 "commit tool: LLM analysis failed ({e}), using deterministic fallback"
1188 );
1189 (det, det_summary)
1190 }
1191 }
1192 }
1193 None => {
1194 let det = deterministic_analysis(&filtered, &candidates);
1195 let det_summary = deterministic_summary(det.commit_type, &det.scope);
1196 (det, det_summary)
1197 }
1198 };
1199
1200 summary = normalize_summary(&summary);
1202 analysis.scope = analysis.scope.trim().to_string();
1203 let validation = {
1204 let mut v = validate_summary(&summary);
1205 v.extend(validate_scope(&analysis.scope));
1206 v
1207 };
1208
1209 let message = format_commit_message(&analysis, &summary);
1211
1212 if args.dry_run {
1213 let mut output = String::new();
1214 if !validation.is_empty() {
1215 output.push_str("⚠ Validation warnings:\n");
1216 output.push_str(&validation.join("\n"));
1217 output.push_str("\n\n");
1218 }
1219 output.push_str("Dry run — would commit:\n\n");
1220 output.push_str(&message);
1221 return Ok(AgentToolResult::success(output).with_metadata(json!({
1222 "dry_run": true,
1223 "scope": analysis.scope,
1224 "type": analysis.commit_type.as_str(),
1225 })));
1226 }
1227
1228 if !validation.is_empty() {
1230 tracing::warn!(
1231 "commit tool: validation warnings: {}",
1232 validation.join("; ")
1233 );
1234 }
1235
1236 git.stage_all()?;
1238 git.commit(&message)?;
1239 let hash = git.head_short().unwrap_or_else(|_| "unknown".to_string());
1240
1241 if !args.no_changelog
1243 && let Err(e) = update_changelog(&cwd, &analysis)
1244 {
1245 tracing::warn!("commit tool: changelog update failed: {e}");
1246 }
1247
1248 if args.push {
1250 git.push()?;
1251 }
1252
1253 Ok(
1254 AgentToolResult::success(format!("Committed {hash}:\n\n{message}")).with_metadata(
1255 json!({
1256 "hash": hash,
1257 "scope": analysis.scope,
1258 "type": analysis.commit_type.as_str(),
1259 }),
1260 ),
1261 )
1262 }
1263}
1264
1265#[cfg(test)]
1270mod tests {
1271 use super::*;
1272
1273 fn entry(path: &str, additions: usize, deletions: usize) -> NumstatEntry {
1274 NumstatEntry {
1275 path: path.to_string(),
1276 additions,
1277 deletions,
1278 }
1279 }
1280
1281 #[test]
1284 fn scope_extraction_single_component() {
1285 let numstat = vec![
1286 entry("src/auth/login.rs", 50, 10),
1287 entry("src/auth/logout.rs", 20, 5),
1288 ];
1289 let candidates = extract_scope_candidates(&numstat);
1290 assert_eq!(candidates.len(), 1);
1291 assert_eq!(candidates[0].name, "src/auth");
1292 assert_eq!(candidates[0].segments, 2);
1293 }
1294
1295 #[test]
1296 fn scope_extraction_ranks_by_churn() {
1297 let numstat = vec![
1298 entry("src/big/module.rs", 200, 50),
1299 entry("src/tiny/util.rs", 5, 1),
1300 entry("docs/readme.md", 3, 0),
1301 ];
1302 let candidates = extract_scope_candidates(&numstat);
1303 assert!(!candidates.is_empty());
1304 assert_eq!(candidates[0].name, "src/big");
1306 }
1307
1308 #[test]
1309 fn scope_extraction_excludes_lock_files() {
1310 let numstat = vec![
1311 entry("Cargo.lock", 5000, 100),
1312 entry("src/main.rs", 10, 2),
1313 entry("package-lock.json", 9000, 0),
1314 entry("pnpm-lock.yaml", 300, 10),
1315 entry("go.sum", 800, 5),
1316 ];
1317 let candidates = extract_scope_candidates(&numstat);
1318 assert!(
1320 candidates
1321 .iter()
1322 .all(|c| !c.name.contains("lock") && !c.name.contains("sum"))
1323 );
1324 assert_eq!(candidates.len(), 1);
1325 assert_eq!(candidates[0].name, "src");
1326 }
1327
1328 #[test]
1329 fn scope_extraction_single_segment_boost() {
1330 let numstat = vec![entry("README.md", 10, 0)];
1331 let candidates = extract_scope_candidates(&numstat);
1332 assert_eq!(candidates.len(), 1);
1333 assert_eq!(candidates[0].name, "README");
1334 assert!((candidates[0].weight - 8.0).abs() < 0.001);
1336 }
1337
1338 #[test]
1339 fn wide_change_detection_many_roots() {
1340 let numstat = vec![
1341 entry("auth/login.rs", 30, 0),
1342 entry("billing/invoice.rs", 30, 0),
1343 entry("reports/export.rs", 30, 0),
1344 ];
1345 assert!(is_wide_change(&numstat));
1347 }
1348
1349 #[test]
1350 fn wide_change_false_for_single_scope() {
1351 let numstat = vec![
1352 entry("src/auth/login.rs", 100, 10),
1353 entry("src/auth/session.rs", 20, 5),
1354 ];
1355 assert!(!is_wide_change(&numstat));
1356 }
1357
1358 #[test]
1361 fn parse_numstat_basic() {
1362 let output = "10\t2\tsrc/main.rs\n3\t0\tdocs/readme.md\n";
1363 let entries = parse_numstat(output);
1364 assert_eq!(entries.len(), 2);
1365 assert_eq!(entries[0].path, "src/main.rs");
1366 assert_eq!(entries[0].additions, 10);
1367 assert_eq!(entries[0].deletions, 2);
1368 assert_eq!(entries[1].path, "docs/readme.md");
1369 }
1370
1371 #[test]
1372 fn parse_numstat_binary_file() {
1373 let output = "-\t-\tassets/logo.png\n";
1374 let entries = parse_numstat(output);
1375 assert_eq!(entries.len(), 1);
1376 assert_eq!(entries[0].path, "assets/logo.png");
1377 assert_eq!(entries[0].additions, 0);
1378 assert_eq!(entries[0].deletions, 0);
1379 }
1380
1381 #[test]
1382 fn parse_numstat_skips_blank() {
1383 let output = "\n10\t2\tsrc/main.rs\n\n";
1384 let entries = parse_numstat(output);
1385 assert_eq!(entries.len(), 1);
1386 }
1387
1388 fn feat_auth_analysis() -> ConventionalAnalysis {
1391 ConventionalAnalysis {
1392 commit_type: CommitType::Feat,
1393 scope: "auth".to_string(),
1394 details: vec![ConventionalDetail {
1395 text: "Add OAuth2 login flow.".to_string(),
1396 changelog_category: Some(ChangelogCategory::Added),
1397 user_visible: true,
1398 }],
1399 issue_refs: vec!["#42".to_string()],
1400 }
1401 }
1402
1403 #[test]
1404 fn message_format_with_scope_and_refs() {
1405 let analysis = feat_auth_analysis();
1406 let msg = format_commit_message(&analysis, "Add OAuth2 login");
1407 assert!(msg.starts_with("feat(auth): Add OAuth2 login"));
1408 assert!(msg.contains("- Add OAuth2 login flow."));
1409 assert!(msg.contains("Refs #42"));
1410 }
1411
1412 #[test]
1413 fn message_format_without_scope() {
1414 let analysis = ConventionalAnalysis {
1415 commit_type: CommitType::Fix,
1416 scope: String::new(),
1417 details: vec![ConventionalDetail {
1418 text: "Correct off-by-one.".to_string(),
1419 changelog_category: None,
1420 user_visible: true,
1421 }],
1422 issue_refs: Vec::new(),
1423 };
1424 let msg = format_commit_message(&analysis, "Fix crash");
1425 assert!(msg.starts_with("fix: Fix crash\n\n- Correct off-by-one."));
1426 assert!(!msg.contains("Refs"));
1427 }
1428
1429 #[test]
1430 fn message_format_empty_details() {
1431 let analysis = ConventionalAnalysis {
1432 commit_type: CommitType::Chore,
1433 scope: "deps".to_string(),
1434 details: Vec::new(),
1435 issue_refs: Vec::new(),
1436 };
1437 let msg = format_commit_message(&analysis, "Bump deps");
1438 assert_eq!(msg, "chore(deps): Bump deps");
1439 }
1440
1441 #[test]
1442 fn message_format_multiple_refs() {
1443 let analysis = ConventionalAnalysis {
1444 commit_type: CommitType::Fix,
1445 scope: String::new(),
1446 details: Vec::new(),
1447 issue_refs: vec!["#1".to_string(), "#2".to_string()],
1448 };
1449 let msg = format_commit_message(&analysis, "Fix things");
1450 assert!(msg.contains("Refs #1\nRefs #2"));
1451 }
1452
1453 #[test]
1456 fn validation_rejects_long_summary() {
1457 let long = "x".repeat(73);
1458 let errors = validate_summary(&long);
1459 assert!(errors.iter().any(|e| e.contains("72 characters")));
1460 }
1461
1462 #[test]
1463 fn validation_accepts_max_length_summary() {
1464 let exact = "x".repeat(72);
1465 let errors = validate_summary(&exact);
1466 assert!(!errors.iter().any(|e| e.contains("72 characters")));
1467 }
1468
1469 #[test]
1470 fn validation_rejects_trailing_period() {
1471 let errors = validate_summary("Add feature.");
1472 assert!(errors.iter().any(|e| e.contains("period")));
1473 }
1474
1475 #[test]
1476 fn validation_rejects_multiline_summary() {
1477 let errors = validate_summary("line one\nline two");
1478 assert!(errors.iter().any(|e| e.contains("single line")));
1479 }
1480
1481 #[test]
1482 fn validation_rejects_empty_summary() {
1483 let errors = validate_summary(" ");
1484 assert!(errors.iter().any(|e| e.contains("empty")));
1485 }
1486
1487 #[test]
1488 fn validation_rejects_uppercase_scope() {
1489 let errors = validate_scope("Auth");
1490 assert!(errors.iter().any(|e| e.contains("lowercase")));
1491 }
1492
1493 #[test]
1494 fn validation_rejects_three_segment_scope() {
1495 let errors = validate_scope("a/b/c");
1496 assert!(errors.iter().any(|e| e.contains("2 segments")));
1497 }
1498
1499 #[test]
1500 fn validation_rejects_invalid_scope_chars() {
1501 let errors = validate_scope("auth config");
1502 assert!(errors.iter().any(|e| e.contains("invalid characters")));
1503 }
1504
1505 #[test]
1506 fn validation_accepts_empty_scope() {
1507 assert!(validate_scope("").is_empty());
1508 }
1509
1510 #[test]
1511 fn validation_accepts_two_segment_scope() {
1512 assert!(validate_scope("oxi-agent/auth").is_empty());
1513 }
1514
1515 #[test]
1516 fn normalize_summary_strips_period_and_truncates() {
1517 assert_eq!(normalize_summary("Add feature."), "Add feature");
1518 let long = format!("{}.", "x".repeat(80));
1519 let normalized = normalize_summary(&long);
1520 assert!(normalized.chars().count() <= 72);
1521 assert!(!normalized.ends_with('.'));
1522 }
1523
1524 #[test]
1525 fn normalize_summary_collapses_to_single_line() {
1526 assert_eq!(normalize_summary("first\nsecond"), "first");
1527 }
1528
1529 fn group(id: &str, deps: &[&str]) -> CommitGroup {
1532 CommitGroup {
1533 id: id.to_string(),
1534 files: Vec::new(),
1535 analysis: ConventionalAnalysis {
1536 commit_type: CommitType::Feat,
1537 scope: String::new(),
1538 details: Vec::new(),
1539 issue_refs: Vec::new(),
1540 },
1541 summary: String::new(),
1542 dependencies: deps.iter().map(|s| s.to_string()).collect(),
1543 }
1544 }
1545
1546 #[test]
1547 fn topo_sort_no_cycle() {
1548 let mut groups = vec![group("a", &[]), group("b", &["a"]), group("c", &["b"])];
1549 compute_dependency_order(&mut groups).expect("no cycle");
1550 let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1551 assert_eq!(ids, vec!["a", "b", "c"]);
1552 }
1553
1554 #[test]
1555 fn topo_sort_cycle_detected() {
1556 let mut groups = vec![group("a", &["b"]), group("b", &["a"])];
1557 let result = compute_dependency_order(&mut groups);
1558 assert!(result.is_err());
1559 let err = result.unwrap_err();
1560 assert!(err.contains("cycle"));
1561 }
1562
1563 #[test]
1564 fn topo_sort_unknown_dependency() {
1565 let mut groups = vec![group("a", &["nonexistent"])];
1566 let result = compute_dependency_order(&mut groups);
1567 assert!(result.is_err());
1568 assert!(result.unwrap_err().contains("Unknown dependency"));
1569 }
1570
1571 #[test]
1572 fn topo_sort_self_dependency() {
1573 let mut groups = vec![group("a", &["a"])];
1574 let result = compute_dependency_order(&mut groups);
1575 assert!(result.is_err());
1576 assert!(result.unwrap_err().contains("itself"));
1577 }
1578
1579 #[test]
1580 fn topo_sort_independent_groups_preserved() {
1581 let mut groups = vec![group("x", &[]), group("y", &[]), group("z", &[])];
1582 compute_dependency_order(&mut groups).expect("ok");
1583 let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1585 assert_eq!(ids, vec!["x", "y", "z"]);
1586 }
1587
1588 #[test]
1589 fn topo_sort_diamond() {
1590 let mut groups = vec![
1592 group("d", &["b", "c"]),
1593 group("c", &["a"]),
1594 group("b", &["a"]),
1595 group("a", &[]),
1596 ];
1597 compute_dependency_order(&mut groups).expect("no cycle");
1598 let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1599 assert_eq!(ids[0], "a");
1600 assert_eq!(ids[3], "d");
1601 let b_pos = ids.iter().position(|&i| i == "b").unwrap();
1603 let c_pos = ids.iter().position(|&i| i == "c").unwrap();
1604 assert!(b_pos > 0 && b_pos < 3);
1605 assert!(c_pos > 0 && c_pos < 3);
1606 }
1607
1608 #[test]
1609 fn topo_sort_dedupes_repeated_dependency() {
1610 let mut groups = vec![group("b", &["a", "a"]), group("a", &[])];
1612 compute_dependency_order(&mut groups).expect("no cycle");
1613 let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1614 assert_eq!(ids, vec!["a", "b"]);
1615 }
1616
1617 #[test]
1620 fn excludes_common_lock_files() {
1621 assert!(is_excluded_file("Cargo.lock"));
1622 assert!(is_excluded_file("crates/foo/Cargo.lock"));
1623 assert!(is_excluded_file("package-lock.json"));
1624 assert!(is_excluded_file("yarn.lock"));
1625 assert!(is_excluded_file("pnpm-lock.yaml"));
1626 assert!(is_excluded_file("go.sum"));
1627 assert!(is_excluded_file("uv.lock"));
1628 assert!(is_excluded_file("flake.lock"));
1629 assert!(is_excluded_file("app/config.yaml.lock"));
1630 }
1631
1632 #[test]
1633 fn does_not_exclude_source_files() {
1634 assert!(!is_excluded_file("src/main.rs"));
1635 assert!(!is_excluded_file("lib/index.ts"));
1636 assert!(!is_excluded_file("Cargo.toml"));
1637 assert!(!is_excluded_file("README.md"));
1638 }
1639
1640 #[test]
1643 fn commit_type_roundtrip() {
1644 for id in [
1645 "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore",
1646 "revert",
1647 ] {
1648 let ty = CommitType::from_id(id).unwrap_or_else(|| panic!("unknown type {id}"));
1649 assert_eq!(ty.as_str(), id);
1650 assert_eq!(ty.to_string(), id);
1651 }
1652 assert!(CommitType::from_id("unknown").is_none());
1653 }
1654
1655 #[test]
1658 fn deterministic_analysis_docs() {
1659 let entries = vec![entry("docs/guide.md", 20, 5)];
1660 let candidates = extract_scope_candidates(&entries);
1661 let analysis = deterministic_analysis(&entries, &candidates);
1662 assert_eq!(analysis.commit_type, CommitType::Docs);
1663 assert_eq!(analysis.scope, "docs");
1664 assert!(!analysis.details.is_empty());
1665 }
1666
1667 #[test]
1668 fn deterministic_analysis_tests() {
1669 let entries = vec![entry("src/auth_test.rs", 40, 2)];
1670 let candidates = extract_scope_candidates(&entries);
1671 let analysis = deterministic_analysis(&entries, &candidates);
1672 assert_eq!(analysis.commit_type, CommitType::Test);
1673 }
1674
1675 #[test]
1676 fn deterministic_summary_is_valid() {
1677 let summary = deterministic_summary(CommitType::Feat, "auth");
1678 assert!(validate_summary(&summary).is_empty());
1679 assert!(summary.contains("Add"));
1680 }
1681
1682 #[test]
1685 fn extract_json_object_from_fence() {
1686 let text = "Here is the plan:\n```json\n{\"type\":\"fix\",\"scope\":\"a\"}\n```\n";
1687 let extracted = extract_json_object(text).expect("found json");
1688 assert!(extracted.contains("\"type\":\"fix\""));
1689 }
1690
1691 #[test]
1692 fn extract_json_object_nested() {
1693 let text = "{\"a\":{\"b\":1},\"c\":2}";
1694 let extracted = extract_json_object(text).expect("found json");
1695 assert_eq!(extracted, text);
1696 }
1697
1698 #[test]
1699 fn extract_json_object_with_brace_in_string() {
1700 let text = "{\"text\":\"has } brace\"}";
1701 let extracted = extract_json_object(text).expect("found json");
1702 assert_eq!(extracted, text);
1703 }
1704
1705 #[test]
1708 fn update_changelog_appends_under_unreleased() {
1709 let dir = tempfile::tempdir().expect("tempdir");
1710 let changelog = dir.path().join("CHANGELOG.md");
1711 std::fs::write(
1712 &changelog,
1713 "# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2024-01-01\n\n- initial\n",
1714 )
1715 .expect("write");
1716 let analysis = ConventionalAnalysis {
1717 commit_type: CommitType::Feat,
1718 scope: String::new(),
1719 details: vec![ConventionalDetail {
1720 text: "Add OAuth2 login.".to_string(),
1721 changelog_category: Some(ChangelogCategory::Added),
1722 user_visible: true,
1723 }],
1724 issue_refs: Vec::new(),
1725 };
1726 let modified = update_changelog(dir.path(), &analysis).expect("ok");
1727 assert!(modified);
1728 let content = std::fs::read_to_string(&changelog).expect("read");
1729 let unreleased_start = content.find("## [Unreleased]").unwrap();
1730 let v1_start = content.find("## [1.0.0]").unwrap();
1731 let unreleased = &content[unreleased_start..v1_start];
1732 assert!(unreleased.contains("### Added"));
1733 assert!(unreleased.contains("- Add OAuth2 login"));
1734 }
1735
1736 #[test]
1737 fn update_changelog_skips_without_unreleased() {
1738 let dir = tempfile::tempdir().expect("tempdir");
1739 std::fs::write(
1740 dir.path().join("CHANGELOG.md"),
1741 "# Changelog\n\n## [1.0.0]\n",
1742 )
1743 .unwrap();
1744 let analysis = ConventionalAnalysis {
1745 commit_type: CommitType::Feat,
1746 scope: String::new(),
1747 details: vec![ConventionalDetail {
1748 text: "Add.".to_string(),
1749 changelog_category: Some(ChangelogCategory::Added),
1750 user_visible: true,
1751 }],
1752 issue_refs: Vec::new(),
1753 };
1754 let modified = update_changelog(dir.path(), &analysis).expect("ok");
1755 assert!(!modified);
1756 }
1757
1758 #[test]
1759 fn update_changelog_no_file_is_noop() {
1760 let dir = tempfile::tempdir().expect("tempdir");
1761 let analysis = ConventionalAnalysis {
1762 commit_type: CommitType::Feat,
1763 scope: String::new(),
1764 details: vec![ConventionalDetail {
1765 text: "Add.".to_string(),
1766 changelog_category: Some(ChangelogCategory::Added),
1767 user_visible: true,
1768 }],
1769 issue_refs: Vec::new(),
1770 };
1771 let modified = update_changelog(dir.path(), &analysis).expect("ok");
1772 assert!(!modified);
1773 }
1774
1775 #[test]
1778 fn parse_args_defaults() {
1779 let args = parse_args(&json!({})).expect("ok");
1780 assert!(!args.dry_run);
1781 assert!(!args.push);
1782 assert!(!args.no_changelog);
1783 assert!(args.context.is_none());
1784 }
1785
1786 #[test]
1787 fn parse_args_all_set() {
1788 let args = parse_args(
1789 &json!({"dry_run": true, "push": true, "no_changelog": true, "context": "ctx"}),
1790 )
1791 .expect("ok");
1792 assert!(args.dry_run);
1793 assert!(args.push);
1794 assert!(args.no_changelog);
1795 assert_eq!(args.context.as_deref(), Some("ctx"));
1796 }
1797}