1use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
14use crate::tools::typed::TypedTool;
15use async_trait::async_trait;
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19use std::collections::{HashMap, HashSet, VecDeque};
20use std::path::{Path, PathBuf};
21use tokio::sync::oneshot;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum CommitType {
31 Feat,
33 Fix,
35 Docs,
37 Style,
40 Refactor,
42 Perf,
44 Test,
46 Build,
48 Ci,
50 Chore,
52 Revert,
54}
55
56impl CommitType {
57 pub fn as_str(&self) -> &'static str {
59 match self {
60 Self::Feat => "feat",
61 Self::Fix => "fix",
62 Self::Docs => "docs",
63 Self::Style => "style",
64 Self::Refactor => "refactor",
65 Self::Perf => "perf",
66 Self::Test => "test",
67 Self::Build => "build",
68 Self::Ci => "ci",
69 Self::Chore => "chore",
70 Self::Revert => "revert",
71 }
72 }
73
74 pub fn from_id(id: &str) -> Option<Self> {
78 match id {
79 "feat" => Some(Self::Feat),
80 "fix" => Some(Self::Fix),
81 "docs" => Some(Self::Docs),
82 "style" => Some(Self::Style),
83 "refactor" => Some(Self::Refactor),
84 "perf" => Some(Self::Perf),
85 "test" => Some(Self::Test),
86 "build" => Some(Self::Build),
87 "ci" => Some(Self::Ci),
88 "chore" => Some(Self::Chore),
89 "revert" => Some(Self::Revert),
90 _ => None,
91 }
92 }
93}
94
95impl std::fmt::Display for CommitType {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.write_str(self.as_str())
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum ChangelogCategory {
105 Added,
107 Changed,
109 Deprecated,
111 Removed,
113 Fixed,
115 Security,
117 Internal,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ConventionalDetail {
124 pub text: String,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub changelog_category: Option<ChangelogCategory>,
129 #[serde(default = "default_true")]
131 pub user_visible: bool,
132}
133
134fn default_true() -> bool {
136 true
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ConventionalAnalysis {
143 #[serde(rename = "type")]
145 pub commit_type: CommitType,
146 pub scope: String,
148 pub details: Vec<ConventionalDetail>,
150 #[serde(default)]
152 pub issue_refs: Vec<String>,
153}
154
155#[derive(Debug, Clone)]
157pub struct CommitGroup {
158 pub id: String,
160 pub files: Vec<String>,
162 pub analysis: ConventionalAnalysis,
164 pub summary: String,
166 pub dependencies: Vec<String>,
168}
169
170#[derive(Debug, Clone)]
176pub struct NumstatEntry {
177 pub path: String,
179 pub additions: usize,
181 pub deletions: usize,
183}
184
185#[derive(Debug, Clone)]
187pub struct ScopeCandidate {
188 pub name: String,
190 pub weight: f64,
192 pub segments: usize,
194}
195
196const EXCLUDED_FILES: &[&str] = &[
201 "Cargo.lock",
202 "package-lock.json",
203 "npm-shrinkwrap.json",
204 "yarn.lock",
205 "pnpm-lock.yaml",
206 "shrinkwrap.yaml",
207 "bun.lock",
208 "bun.lockb",
209 "deno.lock",
210 "composer.lock",
211 "Gemfile.lock",
212 "poetry.lock",
213 "Pipfile.lock",
214 "pdm.lock",
215 "uv.lock",
216 "go.sum",
217 "flake.lock",
218 "pubspec.lock",
219 "Podfile.lock",
220 "Packages.resolved",
221 "mix.lock",
222 "packages.lock.json",
223];
224
225const EXCLUDED_SUFFIXES: &[&str] = &[
227 ".lock.yml",
228 ".lock.yaml",
229 "-lock.yml",
230 "-lock.yaml",
231 "config.yml.lock",
232 "config.yaml.lock",
233 "settings.yml.lock",
234 "settings.yaml.lock",
235];
236
237pub fn is_excluded_file(path: &str) -> bool {
240 let lower = path.to_ascii_lowercase();
241 EXCLUDED_FILES
242 .iter()
243 .any(|name| lower.ends_with(&name.to_ascii_lowercase()))
244 || EXCLUDED_SUFFIXES
245 .iter()
246 .any(|suffix| lower.ends_with(suffix))
247}
248
249const PLACEHOLDER_DIRS: &[&str] = &["src", "lib", "bin", "app", "cmd", "internal", "main"];
251
252fn extract_path_component(path: &str) -> String {
260 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
261 if segments.is_empty() {
262 return String::new();
263 }
264 let dirs = &segments[..segments.len() - 1];
266 if dirs.is_empty() {
267 return segments[0]
269 .split('.')
270 .next()
271 .unwrap_or(segments[0])
272 .to_string();
273 }
274 let take = dirs.len().min(2);
275 dirs[..take].join("/")
276}
277
278pub fn extract_scope_candidates(numstat: &[NumstatEntry]) -> Vec<ScopeCandidate> {
285 let mut components: HashMap<String, usize> = HashMap::new();
286 for entry in numstat {
287 if is_excluded_file(&entry.path) {
288 continue;
289 }
290 let component = extract_path_component(&entry.path);
291 if component.is_empty() {
292 continue;
293 }
294 *components.entry(component).or_default() += entry.additions + entry.deletions;
295 }
296
297 let mut candidates: Vec<ScopeCandidate> = components
298 .into_iter()
299 .map(|(name, lines)| {
300 let segments = name.split('/').count();
301 ScopeCandidate {
302 name,
303 weight: lines as f64,
304 segments,
305 }
306 })
307 .collect();
308
309 for candidate in &mut candidates {
310 candidate.weight *= if candidate.segments >= 2 { 1.2 } else { 0.8 };
311 }
312
313 candidates.sort_by(|a, b| {
314 b.weight
315 .partial_cmp(&a.weight)
316 .unwrap_or(std::cmp::Ordering::Equal)
317 });
318 candidates
319}
320
321pub fn is_wide_change(numstat: &[NumstatEntry]) -> bool {
327 let candidates = extract_scope_candidates(numstat);
328 if candidates.is_empty() {
329 return false;
330 }
331 let total: f64 = candidates.iter().map(|c| c.weight).sum();
332 let top_share = if total > 0.0 {
333 candidates[0].weight / total
334 } else {
335 0.0
336 };
337 let distinct_roots = candidates
338 .iter()
339 .filter(|c| {
340 let root = c.name.split('/').next().unwrap_or("");
341 !PLACEHOLDER_DIRS.contains(&root)
342 })
343 .count();
344 top_share < 0.6 || distinct_roots >= 3
345}
346
347pub fn parse_numstat(output: &str) -> Vec<NumstatEntry> {
352 output.lines().filter_map(parse_numstat_line).collect()
353}
354
355fn parse_numstat_line(line: &str) -> Option<NumstatEntry> {
356 let mut parts = line.splitn(3, '\t');
357 let additions_raw = parts.next()?;
358 let deletions_raw = parts.next()?;
359 let path = parts.next()?;
360 if path.is_empty() {
361 return None;
362 }
363 let additions = additions_raw.parse::<usize>().unwrap_or(0);
364 let deletions = deletions_raw.parse::<usize>().unwrap_or(0);
365 Some(NumstatEntry {
366 path: path.to_string(),
367 additions,
368 deletions,
369 })
370}
371
372pub fn format_commit_message(analysis: &ConventionalAnalysis, summary: &str) -> String {
382 let header = if analysis.scope.is_empty() {
383 format!("{}: {}", analysis.commit_type, summary)
384 } else {
385 format!("{}({}): {}", analysis.commit_type, analysis.scope, summary)
386 };
387
388 let mut message = header;
389 if !analysis.details.is_empty() {
390 message.push_str("\n\n");
391 message.push_str(
392 &analysis
393 .details
394 .iter()
395 .map(|d| format!("- {}", d.text))
396 .collect::<Vec<_>>()
397 .join("\n"),
398 );
399 }
400
401 if !analysis.issue_refs.is_empty() {
402 message.push_str("\n\n");
403 message.push_str(
404 &analysis
405 .issue_refs
406 .iter()
407 .map(|r| format!("Refs {}", r))
408 .collect::<Vec<_>>()
409 .join("\n"),
410 );
411 }
412
413 message
414}
415
416pub fn validate_summary(summary: &str) -> Vec<String> {
424 let mut errors = Vec::new();
425 if summary.trim().is_empty() {
426 errors.push("Summary must not be empty".to_string());
427 }
428 if summary.chars().count() > 72 {
429 errors.push("Summary exceeds 72 characters".to_string());
430 }
431 if summary.ends_with('.') {
432 errors.push("Summary must not end with a period".to_string());
433 }
434 if summary.contains('\n') {
435 errors.push("Summary must be a single line".to_string());
436 }
437 errors
438}
439
440pub fn validate_scope(scope: &str) -> Vec<String> {
445 let mut errors = Vec::new();
446 if scope.is_empty() {
447 return errors;
448 }
449 if scope.split('/').count() > 2 {
450 errors.push("Scope has more than 2 segments".to_string());
451 }
452 if scope != scope.to_ascii_lowercase() {
453 errors.push("Scope must be lowercase".to_string());
454 }
455 if !is_valid_scope_chars(scope) {
456 errors.push("Scope contains invalid characters (allowed: a-z 0-9 - _ /)".to_string());
457 }
458 errors
459}
460
461fn is_valid_scope_chars(scope: &str) -> bool {
463 for segment in scope.split('/') {
464 if segment.is_empty() {
465 return false;
466 }
467 let mut chars = segment.chars();
468 let Some(first) = chars.next() else {
469 return false;
470 };
471 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
472 return false;
473 }
474 if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
475 return false;
476 }
477 }
478 true
479}
480
481pub fn normalize_summary(summary: &str) -> String {
486 let first_line = summary.lines().next().unwrap_or("").trim();
487 let mut s = first_line.trim_end_matches('.').trim().to_string();
488 if s.chars().count() > 72 {
489 let truncated: String = s.chars().take(72).collect();
490 s = match truncated.rfind(' ') {
491 Some(idx) => truncated[..idx]
492 .trim_end_matches(|c: char| !c.is_alphanumeric())
493 .to_string(),
494 None => truncated,
495 };
496 }
497 s
498}
499
500pub fn compute_dependency_order(groups: &mut [CommitGroup]) -> Result<(), String> {
517 let n = groups.len();
518 let id_to_index: HashMap<&str, usize> = groups
519 .iter()
520 .enumerate()
521 .map(|(i, g)| (g.id.as_str(), i))
522 .collect();
523
524 let mut in_degree = vec![0usize; n];
525 let mut edges: Vec<HashSet<usize>> = vec![HashSet::new(); n];
526
527 for (idx, group) in groups.iter().enumerate() {
528 for dep in &group.dependencies {
529 let Some(&dep_idx) = id_to_index.get(dep.as_str()) else {
530 return Err(format!(
531 "Unknown dependency '{}' referenced by group '{}'",
532 dep, group.id
533 ));
534 };
535 if dep_idx == idx {
536 return Err(format!("Group '{}' depends on itself", group.id));
537 }
538 if edges[dep_idx].insert(idx) {
539 in_degree[idx] += 1;
540 }
541 }
542 }
543
544 let mut queue: VecDeque<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
545 let mut order: Vec<usize> = Vec::with_capacity(n);
546 while let Some(current) = queue.pop_front() {
547 order.push(current);
548 let dependents: Vec<usize> = edges[current].iter().copied().collect();
549 for next in dependents {
550 in_degree[next] -= 1;
551 if in_degree[next] == 0 {
552 queue.push_back(next);
553 }
554 }
555 }
556
557 if order.len() != n {
558 let cycle: Vec<String> = (0..n)
559 .filter(|i| !order.contains(i))
560 .map(|i| groups[i].id.clone())
561 .collect();
562 return Err(format!(
563 "Dependency cycle detected among: {}",
564 cycle.join(", ")
565 ));
566 }
567
568 let rank_by_id: HashMap<String, usize> = order
570 .iter()
571 .enumerate()
572 .map(|(rank, &idx)| (groups[idx].id.clone(), rank))
573 .collect();
574 groups.sort_by_key(|g| rank_by_id.get(&g.id).copied().unwrap_or(usize::MAX));
575
576 Ok(())
577}
578
579const ANALYSIS_SYSTEM: &str = "\
585You are a conventional-commits analysis engine. Given a git diff and ranked \
586scope candidates, call the create_conventional_analysis tool exactly once with \
587a conventional commit plan. Rules:\n\
588- type: one of feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.\n\
589- scope: lowercase, at most two /-separated segments; pick the most relevant scope candidate when possible, or empty string.\n\
590- summary: imperative mood, <=72 chars, no trailing period, single line.\n\
591- details: one bullet per logical change, each <=120 chars ending with a period.\n\
592- issueRefs: issue/PR references like #123, or omit.";
593
594fn analysis_tool_schema() -> Value {
596 json!({
597 "type": "object",
598 "properties": {
599 "type": {
600 "type": "string",
601 "enum": ["feat","fix","docs","style","refactor","perf","test","build","ci","chore","revert"]
602 },
603 "scope": {
604 "type": "string",
605 "description": "Lowercase scope, at most two /-separated segments, or empty"
606 },
607 "summary": {
608 "type": "string",
609 "maxLength": 72,
610 "description": "Imperative one-line summary, no trailing period"
611 },
612 "details": {
613 "type": "array",
614 "items": {
615 "type": "object",
616 "properties": {
617 "text": {"type": "string", "maxLength": 120},
618 "changelogCategory": {
619 "type": "string",
620 "enum": ["added","changed","deprecated","removed","fixed","security","internal"]
621 },
622 "userVisible": {"type": "boolean"}
623 },
624 "required": ["text"]
625 }
626 },
627 "issueRefs": {
628 "type": "array",
629 "items": {"type": "string"}
630 }
631 },
632 "required": ["type", "scope", "summary", "details"]
633 })
634}
635
636#[derive(Debug, Deserialize)]
638struct LlmAnalysis {
639 #[serde(rename = "type")]
640 commit_type: CommitType,
641 #[serde(default)]
642 scope: String,
643 summary: String,
644 #[serde(default)]
645 details: Vec<ConventionalDetail>,
646 #[serde(default)]
647 issue_refs: Vec<String>,
648}
649
650async fn generate_analysis(
655 model: &oxi_ai::Model,
656 diff: &str,
657 candidates: &[ScopeCandidate],
658 extra_context: Option<&str>,
659) -> Result<(ConventionalAnalysis, String), String> {
660 let scope_hint = if candidates.is_empty() {
661 "(none — derive from the diff)".to_string()
662 } else {
663 candidates
664 .iter()
665 .take(5)
666 .map(|c| format!("- {} (weight {:.0})", c.name, c.weight))
667 .collect::<Vec<_>>()
668 .join("\n")
669 };
670
671 let mut user =
672 format!("Ranked scope candidates (by churn):\n{scope_hint}\n\n--- diff ---\n{diff}");
673 if let Some(ctx) = extra_context {
674 user.push_str(&format!("\n\n--- additional context ---\n{ctx}"));
675 }
676
677 let mut context = oxi_ai::Context::new().with_system_prompt(ANALYSIS_SYSTEM);
678 context.add_message(oxi_ai::Message::User(oxi_ai::UserMessage::new(user)));
679 context.set_tools(vec![oxi_ai::Tool::new(
680 "create_conventional_analysis",
681 "Emit a conventional-commit analysis for the given diff.",
682 analysis_tool_schema(),
683 )]);
684
685 let options = oxi_ai::StreamOptions {
686 max_tokens: Some(2400),
687 temperature: Some(0.2),
688 ..Default::default()
689 };
690
691 let response = oxi_ai::complete(model, &context, Some(options))
692 .await
693 .map_err(|e| format!("LLM analysis failed: {e}"))?;
694
695 parse_analysis_response(&response)
696}
697
698fn parse_analysis_response(
703 msg: &oxi_ai::AssistantMessage,
704) -> Result<(ConventionalAnalysis, String), String> {
705 for block in &msg.content {
706 if let oxi_ai::ContentBlock::ToolCall(call) = block
707 && call.name == "create_conventional_analysis"
708 {
709 let plan: LlmAnalysis = serde_json::from_value(call.arguments.clone())
710 .map_err(|e| format!("Invalid analysis tool arguments: {e}"))?;
711 return Ok(split_plan(plan));
712 }
713 }
714
715 let text = msg.text_content();
716 if let Some(raw) = extract_json_object(&text) {
717 let plan: LlmAnalysis =
718 serde_json::from_str(&raw).map_err(|e| format!("Invalid analysis JSON: {e}"))?;
719 return Ok(split_plan(plan));
720 }
721
722 Err("LLM did not return a conventional analysis".to_string())
723}
724
725fn split_plan(plan: LlmAnalysis) -> (ConventionalAnalysis, String) {
726 let analysis = ConventionalAnalysis {
727 commit_type: plan.commit_type,
728 scope: plan.scope,
729 details: plan.details,
730 issue_refs: plan.issue_refs,
731 };
732 (analysis, plan.summary)
733}
734
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, Deserialize, Serialize, JsonSchema)]
1057pub struct CommitArgs {
1058 #[serde(default)]
1060 pub dry_run: bool,
1061 #[serde(default)]
1063 pub push: bool,
1064 #[serde(default)]
1066 pub no_changelog: bool,
1067 pub context: Option<String>,
1069}
1070
1071#[allow(dead_code)]
1072fn parse_args(params: &Value) -> Result<CommitArgs, String> {
1073 serde_json::from_value(params.clone()).map_err(|e| e.to_string())
1074}
1075
1076pub struct CommitTool {
1083 model: Option<oxi_ai::Model>,
1085}
1086
1087impl CommitTool {
1088 pub fn new(model: oxi_ai::Model) -> Self {
1092 Self { model: Some(model) }
1093 }
1094
1095 pub fn unconfigured() -> Self {
1100 Self { model: None }
1101 }
1102}
1103
1104#[async_trait]
1105impl AgentTool for CommitTool {
1106 fn name(&self) -> &str {
1107 "commit"
1108 }
1109
1110 fn label(&self) -> &str {
1111 "Conventional Commit"
1112 }
1113
1114 fn essential(&self) -> bool {
1115 false
1116 }
1117
1118 fn description(&self) -> &str {
1119 "Analyze working-tree changes, extract a conventional commit scope, \
1120 generate a conventional commit message, and commit (or preview with \
1121 dry_run). Optionally update CHANGELOG.md and push."
1122 }
1123
1124 fn parameters_schema(&self) -> Value {
1125 json!({
1126 "type": "object",
1127 "properties": {
1128 "dry_run": {
1129 "type": "boolean",
1130 "description": "Preview the commit message without committing",
1131 "default": false
1132 },
1133 "push": {
1134 "type": "boolean",
1135 "description": "Push after committing",
1136 "default": false
1137 },
1138 "no_changelog": {
1139 "type": "boolean",
1140 "description": "Skip the CHANGELOG.md update",
1141 "default": false
1142 },
1143 "context": {
1144 "type": "string",
1145 "description": "Optional extra context to guide the analysis"
1146 }
1147 }
1148 })
1149 }
1150
1151 async fn execute(
1152 &self,
1153 _tool_call_id: &str,
1154 params: Value,
1155 _signal: Option<oneshot::Receiver<()>>,
1156 ctx: &ToolContext,
1157 ) -> Result<AgentToolResult, ToolError> {
1158 let args: CommitArgs =
1159 serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
1160 self.execute_typed(_tool_call_id, args, _signal, ctx).await
1161 }
1162}
1163
1164#[async_trait]
1165impl TypedTool for CommitTool {
1166 type Args = CommitArgs;
1167
1168 async fn execute_typed(
1169 &self,
1170 _tool_call_id: &str,
1171 args: Self::Args,
1172 _signal: Option<oneshot::Receiver<()>>,
1173 ctx: &ToolContext,
1174 ) -> Result<AgentToolResult, ToolError> {
1175 self._execute_impl(args, ctx).await
1176 }
1177}
1178
1179impl CommitTool {
1180 async fn _execute_impl(
1182 &self,
1183 args: CommitArgs,
1184 ctx: &ToolContext,
1185 ) -> Result<AgentToolResult, ToolError> {
1186 let cwd = ctx.root().to_path_buf();
1187 let git = GitOps::new(cwd.clone());
1188
1189 let numstat = git.numstat()?;
1191 let filtered: Vec<NumstatEntry> = numstat
1192 .iter()
1193 .filter(|e| !is_excluded_file(&e.path))
1194 .cloned()
1195 .collect();
1196 if filtered.is_empty() {
1197 return Ok(AgentToolResult::success("No changes to commit."));
1198 }
1199
1200 let candidates = extract_scope_candidates(&numstat);
1202
1203 let (mut analysis, mut summary) = match self.model.as_ref() {
1205 Some(model) => {
1206 let diff = git.diff()?;
1207 match generate_analysis(model, &diff, &candidates, args.context.as_deref()).await {
1208 Ok(plan) => plan,
1209 Err(e) => {
1210 let det = deterministic_analysis(&filtered, &candidates);
1211 let det_summary = deterministic_summary(det.commit_type, &det.scope);
1212 tracing::warn!(
1213 "commit tool: LLM analysis failed ({e}), using deterministic fallback"
1214 );
1215 (det, det_summary)
1216 }
1217 }
1218 }
1219 None => {
1220 let det = deterministic_analysis(&filtered, &candidates);
1221 let det_summary = deterministic_summary(det.commit_type, &det.scope);
1222 (det, det_summary)
1223 }
1224 };
1225
1226 summary = normalize_summary(&summary);
1228 analysis.scope = analysis.scope.trim().to_string();
1229 let validation = {
1230 let mut v = validate_summary(&summary);
1231 v.extend(validate_scope(&analysis.scope));
1232 v
1233 };
1234
1235 let message = format_commit_message(&analysis, &summary);
1237
1238 if args.dry_run {
1239 let mut output = String::new();
1240 if !validation.is_empty() {
1241 output.push_str("⚠ Validation warnings:\n");
1242 output.push_str(&validation.join("\n"));
1243 output.push_str("\n\n");
1244 }
1245 output.push_str("Dry run — would commit:\n\n");
1246 output.push_str(&message);
1247 return Ok(AgentToolResult::success(output).with_metadata(json!({
1248 "dry_run": true,
1249 "scope": analysis.scope,
1250 "type": analysis.commit_type.as_str(),
1251 })));
1252 }
1253
1254 if !validation.is_empty() {
1256 tracing::warn!(
1257 "commit tool: validation warnings: {}",
1258 validation.join("; ")
1259 );
1260 }
1261
1262 git.stage_all()?;
1264 git.commit(&message)?;
1265 let hash = git.head_short().unwrap_or_else(|_| "unknown".to_string());
1266
1267 if !args.no_changelog
1269 && let Err(e) = update_changelog(&cwd, &analysis)
1270 {
1271 tracing::warn!("commit tool: changelog update failed: {e}");
1272 }
1273
1274 if args.push {
1276 git.push()?;
1277 }
1278
1279 Ok(
1280 AgentToolResult::success(format!("Committed {hash}:\n\n{message}")).with_metadata(
1281 json!({
1282 "hash": hash,
1283 "scope": analysis.scope,
1284 "type": analysis.commit_type.as_str(),
1285 }),
1286 ),
1287 )
1288 }
1289}
1290
1291#[cfg(test)]
1296mod tests {
1297 use super::*;
1298
1299 fn entry(path: &str, additions: usize, deletions: usize) -> NumstatEntry {
1300 NumstatEntry {
1301 path: path.to_string(),
1302 additions,
1303 deletions,
1304 }
1305 }
1306
1307 #[test]
1310 fn scope_extraction_single_component() {
1311 let numstat = vec![
1312 entry("src/auth/login.rs", 50, 10),
1313 entry("src/auth/logout.rs", 20, 5),
1314 ];
1315 let candidates = extract_scope_candidates(&numstat);
1316 assert_eq!(candidates.len(), 1);
1317 assert_eq!(candidates[0].name, "src/auth");
1318 assert_eq!(candidates[0].segments, 2);
1319 }
1320
1321 #[test]
1322 fn scope_extraction_ranks_by_churn() {
1323 let numstat = vec![
1324 entry("src/big/module.rs", 200, 50),
1325 entry("src/tiny/util.rs", 5, 1),
1326 entry("docs/readme.md", 3, 0),
1327 ];
1328 let candidates = extract_scope_candidates(&numstat);
1329 assert!(!candidates.is_empty());
1330 assert_eq!(candidates[0].name, "src/big");
1332 }
1333
1334 #[test]
1335 fn scope_extraction_excludes_lock_files() {
1336 let numstat = vec![
1337 entry("Cargo.lock", 5000, 100),
1338 entry("src/main.rs", 10, 2),
1339 entry("package-lock.json", 9000, 0),
1340 entry("pnpm-lock.yaml", 300, 10),
1341 entry("go.sum", 800, 5),
1342 ];
1343 let candidates = extract_scope_candidates(&numstat);
1344 assert!(
1346 candidates
1347 .iter()
1348 .all(|c| !c.name.contains("lock") && !c.name.contains("sum"))
1349 );
1350 assert_eq!(candidates.len(), 1);
1351 assert_eq!(candidates[0].name, "src");
1352 }
1353
1354 #[test]
1355 fn scope_extraction_single_segment_boost() {
1356 let numstat = vec![entry("README.md", 10, 0)];
1357 let candidates = extract_scope_candidates(&numstat);
1358 assert_eq!(candidates.len(), 1);
1359 assert_eq!(candidates[0].name, "README");
1360 assert!((candidates[0].weight - 8.0).abs() < 0.001);
1362 }
1363
1364 #[test]
1365 fn wide_change_detection_many_roots() {
1366 let numstat = vec![
1367 entry("auth/login.rs", 30, 0),
1368 entry("billing/invoice.rs", 30, 0),
1369 entry("reports/export.rs", 30, 0),
1370 ];
1371 assert!(is_wide_change(&numstat));
1373 }
1374
1375 #[test]
1376 fn wide_change_false_for_single_scope() {
1377 let numstat = vec![
1378 entry("src/auth/login.rs", 100, 10),
1379 entry("src/auth/session.rs", 20, 5),
1380 ];
1381 assert!(!is_wide_change(&numstat));
1382 }
1383
1384 #[test]
1387 fn parse_numstat_basic() {
1388 let output = "10\t2\tsrc/main.rs\n3\t0\tdocs/readme.md\n";
1389 let entries = parse_numstat(output);
1390 assert_eq!(entries.len(), 2);
1391 assert_eq!(entries[0].path, "src/main.rs");
1392 assert_eq!(entries[0].additions, 10);
1393 assert_eq!(entries[0].deletions, 2);
1394 assert_eq!(entries[1].path, "docs/readme.md");
1395 }
1396
1397 #[test]
1398 fn parse_numstat_binary_file() {
1399 let output = "-\t-\tassets/logo.png\n";
1400 let entries = parse_numstat(output);
1401 assert_eq!(entries.len(), 1);
1402 assert_eq!(entries[0].path, "assets/logo.png");
1403 assert_eq!(entries[0].additions, 0);
1404 assert_eq!(entries[0].deletions, 0);
1405 }
1406
1407 #[test]
1408 fn parse_numstat_skips_blank() {
1409 let output = "\n10\t2\tsrc/main.rs\n\n";
1410 let entries = parse_numstat(output);
1411 assert_eq!(entries.len(), 1);
1412 }
1413
1414 fn feat_auth_analysis() -> ConventionalAnalysis {
1417 ConventionalAnalysis {
1418 commit_type: CommitType::Feat,
1419 scope: "auth".to_string(),
1420 details: vec![ConventionalDetail {
1421 text: "Add OAuth2 login flow.".to_string(),
1422 changelog_category: Some(ChangelogCategory::Added),
1423 user_visible: true,
1424 }],
1425 issue_refs: vec!["#42".to_string()],
1426 }
1427 }
1428
1429 #[test]
1430 fn message_format_with_scope_and_refs() {
1431 let analysis = feat_auth_analysis();
1432 let msg = format_commit_message(&analysis, "Add OAuth2 login");
1433 assert!(msg.starts_with("feat(auth): Add OAuth2 login"));
1434 assert!(msg.contains("- Add OAuth2 login flow."));
1435 assert!(msg.contains("Refs #42"));
1436 }
1437
1438 #[test]
1439 fn message_format_without_scope() {
1440 let analysis = ConventionalAnalysis {
1441 commit_type: CommitType::Fix,
1442 scope: String::new(),
1443 details: vec![ConventionalDetail {
1444 text: "Correct off-by-one.".to_string(),
1445 changelog_category: None,
1446 user_visible: true,
1447 }],
1448 issue_refs: Vec::new(),
1449 };
1450 let msg = format_commit_message(&analysis, "Fix crash");
1451 assert!(msg.starts_with("fix: Fix crash\n\n- Correct off-by-one."));
1452 assert!(!msg.contains("Refs"));
1453 }
1454
1455 #[test]
1456 fn message_format_empty_details() {
1457 let analysis = ConventionalAnalysis {
1458 commit_type: CommitType::Chore,
1459 scope: "deps".to_string(),
1460 details: Vec::new(),
1461 issue_refs: Vec::new(),
1462 };
1463 let msg = format_commit_message(&analysis, "Bump deps");
1464 assert_eq!(msg, "chore(deps): Bump deps");
1465 }
1466
1467 #[test]
1468 fn message_format_multiple_refs() {
1469 let analysis = ConventionalAnalysis {
1470 commit_type: CommitType::Fix,
1471 scope: String::new(),
1472 details: Vec::new(),
1473 issue_refs: vec!["#1".to_string(), "#2".to_string()],
1474 };
1475 let msg = format_commit_message(&analysis, "Fix things");
1476 assert!(msg.contains("Refs #1\nRefs #2"));
1477 }
1478
1479 #[test]
1482 fn validation_rejects_long_summary() {
1483 let long = "x".repeat(73);
1484 let errors = validate_summary(&long);
1485 assert!(errors.iter().any(|e| e.contains("72 characters")));
1486 }
1487
1488 #[test]
1489 fn validation_accepts_max_length_summary() {
1490 let exact = "x".repeat(72);
1491 let errors = validate_summary(&exact);
1492 assert!(!errors.iter().any(|e| e.contains("72 characters")));
1493 }
1494
1495 #[test]
1496 fn validation_rejects_trailing_period() {
1497 let errors = validate_summary("Add feature.");
1498 assert!(errors.iter().any(|e| e.contains("period")));
1499 }
1500
1501 #[test]
1502 fn validation_rejects_multiline_summary() {
1503 let errors = validate_summary("line one\nline two");
1504 assert!(errors.iter().any(|e| e.contains("single line")));
1505 }
1506
1507 #[test]
1508 fn validation_rejects_empty_summary() {
1509 let errors = validate_summary(" ");
1510 assert!(errors.iter().any(|e| e.contains("empty")));
1511 }
1512
1513 #[test]
1514 fn validation_rejects_uppercase_scope() {
1515 let errors = validate_scope("Auth");
1516 assert!(errors.iter().any(|e| e.contains("lowercase")));
1517 }
1518
1519 #[test]
1520 fn validation_rejects_three_segment_scope() {
1521 let errors = validate_scope("a/b/c");
1522 assert!(errors.iter().any(|e| e.contains("2 segments")));
1523 }
1524
1525 #[test]
1526 fn validation_rejects_invalid_scope_chars() {
1527 let errors = validate_scope("auth config");
1528 assert!(errors.iter().any(|e| e.contains("invalid characters")));
1529 }
1530
1531 #[test]
1532 fn validation_accepts_empty_scope() {
1533 assert!(validate_scope("").is_empty());
1534 }
1535
1536 #[test]
1537 fn validation_accepts_two_segment_scope() {
1538 assert!(validate_scope("oxi-agent/auth").is_empty());
1539 }
1540
1541 #[test]
1542 fn normalize_summary_strips_period_and_truncates() {
1543 assert_eq!(normalize_summary("Add feature."), "Add feature");
1544 let long = format!("{}.", "x".repeat(80));
1545 let normalized = normalize_summary(&long);
1546 assert!(normalized.chars().count() <= 72);
1547 assert!(!normalized.ends_with('.'));
1548 }
1549
1550 #[test]
1551 fn normalize_summary_collapses_to_single_line() {
1552 assert_eq!(normalize_summary("first\nsecond"), "first");
1553 }
1554
1555 fn group(id: &str, deps: &[&str]) -> CommitGroup {
1558 CommitGroup {
1559 id: id.to_string(),
1560 files: Vec::new(),
1561 analysis: ConventionalAnalysis {
1562 commit_type: CommitType::Feat,
1563 scope: String::new(),
1564 details: Vec::new(),
1565 issue_refs: Vec::new(),
1566 },
1567 summary: String::new(),
1568 dependencies: deps.iter().map(|s| s.to_string()).collect(),
1569 }
1570 }
1571
1572 #[test]
1573 fn topo_sort_no_cycle() {
1574 let mut groups = vec![group("a", &[]), group("b", &["a"]), group("c", &["b"])];
1575 compute_dependency_order(&mut groups).expect("no cycle");
1576 let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1577 assert_eq!(ids, vec!["a", "b", "c"]);
1578 }
1579
1580 #[test]
1581 fn topo_sort_cycle_detected() {
1582 let mut groups = vec![group("a", &["b"]), group("b", &["a"])];
1583 let result = compute_dependency_order(&mut groups);
1584 assert!(result.is_err());
1585 let err = result.unwrap_err();
1586 assert!(err.contains("cycle"));
1587 }
1588
1589 #[test]
1590 fn topo_sort_unknown_dependency() {
1591 let mut groups = vec![group("a", &["nonexistent"])];
1592 let result = compute_dependency_order(&mut groups);
1593 assert!(result.is_err());
1594 assert!(result.unwrap_err().contains("Unknown dependency"));
1595 }
1596
1597 #[test]
1598 fn topo_sort_self_dependency() {
1599 let mut groups = vec![group("a", &["a"])];
1600 let result = compute_dependency_order(&mut groups);
1601 assert!(result.is_err());
1602 assert!(result.unwrap_err().contains("itself"));
1603 }
1604
1605 #[test]
1606 fn topo_sort_independent_groups_preserved() {
1607 let mut groups = vec![group("x", &[]), group("y", &[]), group("z", &[])];
1608 compute_dependency_order(&mut groups).expect("ok");
1609 let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1611 assert_eq!(ids, vec!["x", "y", "z"]);
1612 }
1613
1614 #[test]
1615 fn topo_sort_diamond() {
1616 let mut groups = vec![
1618 group("d", &["b", "c"]),
1619 group("c", &["a"]),
1620 group("b", &["a"]),
1621 group("a", &[]),
1622 ];
1623 compute_dependency_order(&mut groups).expect("no cycle");
1624 let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1625 assert_eq!(ids[0], "a");
1626 assert_eq!(ids[3], "d");
1627 let b_pos = ids.iter().position(|&i| i == "b").unwrap();
1629 let c_pos = ids.iter().position(|&i| i == "c").unwrap();
1630 assert!(b_pos > 0 && b_pos < 3);
1631 assert!(c_pos > 0 && c_pos < 3);
1632 }
1633
1634 #[test]
1635 fn topo_sort_dedupes_repeated_dependency() {
1636 let mut groups = vec![group("b", &["a", "a"]), group("a", &[])];
1638 compute_dependency_order(&mut groups).expect("no cycle");
1639 let ids: Vec<&str> = groups.iter().map(|g| g.id.as_str()).collect();
1640 assert_eq!(ids, vec!["a", "b"]);
1641 }
1642
1643 #[test]
1646 fn excludes_common_lock_files() {
1647 assert!(is_excluded_file("Cargo.lock"));
1648 assert!(is_excluded_file("crates/foo/Cargo.lock"));
1649 assert!(is_excluded_file("package-lock.json"));
1650 assert!(is_excluded_file("yarn.lock"));
1651 assert!(is_excluded_file("pnpm-lock.yaml"));
1652 assert!(is_excluded_file("go.sum"));
1653 assert!(is_excluded_file("uv.lock"));
1654 assert!(is_excluded_file("flake.lock"));
1655 assert!(is_excluded_file("app/config.yaml.lock"));
1656 }
1657
1658 #[test]
1659 fn does_not_exclude_source_files() {
1660 assert!(!is_excluded_file("src/main.rs"));
1661 assert!(!is_excluded_file("lib/index.ts"));
1662 assert!(!is_excluded_file("Cargo.toml"));
1663 assert!(!is_excluded_file("README.md"));
1664 }
1665
1666 #[test]
1669 fn commit_type_roundtrip() {
1670 for id in [
1671 "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore",
1672 "revert",
1673 ] {
1674 let ty = CommitType::from_id(id).unwrap_or_else(|| panic!("unknown type {id}"));
1675 assert_eq!(ty.as_str(), id);
1676 assert_eq!(ty.to_string(), id);
1677 }
1678 assert!(CommitType::from_id("unknown").is_none());
1679 }
1680
1681 #[test]
1684 fn deterministic_analysis_docs() {
1685 let entries = vec![entry("docs/guide.md", 20, 5)];
1686 let candidates = extract_scope_candidates(&entries);
1687 let analysis = deterministic_analysis(&entries, &candidates);
1688 assert_eq!(analysis.commit_type, CommitType::Docs);
1689 assert_eq!(analysis.scope, "docs");
1690 assert!(!analysis.details.is_empty());
1691 }
1692
1693 #[test]
1694 fn deterministic_analysis_tests() {
1695 let entries = vec![entry("src/auth_test.rs", 40, 2)];
1696 let candidates = extract_scope_candidates(&entries);
1697 let analysis = deterministic_analysis(&entries, &candidates);
1698 assert_eq!(analysis.commit_type, CommitType::Test);
1699 }
1700
1701 #[test]
1702 fn deterministic_summary_is_valid() {
1703 let summary = deterministic_summary(CommitType::Feat, "auth");
1704 assert!(validate_summary(&summary).is_empty());
1705 assert!(summary.contains("Add"));
1706 }
1707
1708 #[test]
1711 fn extract_json_object_from_fence() {
1712 let text = "Here is the plan:\n```json\n{\"type\":\"fix\",\"scope\":\"a\"}\n```\n";
1713 let extracted = extract_json_object(text).expect("found json");
1714 assert!(extracted.contains("\"type\":\"fix\""));
1715 }
1716
1717 #[test]
1718 fn extract_json_object_nested() {
1719 let text = "{\"a\":{\"b\":1},\"c\":2}";
1720 let extracted = extract_json_object(text).expect("found json");
1721 assert_eq!(extracted, text);
1722 }
1723
1724 #[test]
1725 fn extract_json_object_with_brace_in_string() {
1726 let text = "{\"text\":\"has } brace\"}";
1727 let extracted = extract_json_object(text).expect("found json");
1728 assert_eq!(extracted, text);
1729 }
1730
1731 #[test]
1734 fn update_changelog_appends_under_unreleased() {
1735 let dir = tempfile::tempdir().expect("tempdir");
1736 let changelog = dir.path().join("CHANGELOG.md");
1737 std::fs::write(
1738 &changelog,
1739 "# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2024-01-01\n\n- initial\n",
1740 )
1741 .expect("write");
1742 let analysis = ConventionalAnalysis {
1743 commit_type: CommitType::Feat,
1744 scope: String::new(),
1745 details: vec![ConventionalDetail {
1746 text: "Add OAuth2 login.".to_string(),
1747 changelog_category: Some(ChangelogCategory::Added),
1748 user_visible: true,
1749 }],
1750 issue_refs: Vec::new(),
1751 };
1752 let modified = update_changelog(dir.path(), &analysis).expect("ok");
1753 assert!(modified);
1754 let content = std::fs::read_to_string(&changelog).expect("read");
1755 let unreleased_start = content.find("## [Unreleased]").unwrap();
1756 let v1_start = content.find("## [1.0.0]").unwrap();
1757 let unreleased = &content[unreleased_start..v1_start];
1758 assert!(unreleased.contains("### Added"));
1759 assert!(unreleased.contains("- Add OAuth2 login"));
1760 }
1761
1762 #[test]
1763 fn update_changelog_skips_without_unreleased() {
1764 let dir = tempfile::tempdir().expect("tempdir");
1765 std::fs::write(
1766 dir.path().join("CHANGELOG.md"),
1767 "# Changelog\n\n## [1.0.0]\n",
1768 )
1769 .unwrap();
1770 let analysis = ConventionalAnalysis {
1771 commit_type: CommitType::Feat,
1772 scope: String::new(),
1773 details: vec![ConventionalDetail {
1774 text: "Add.".to_string(),
1775 changelog_category: Some(ChangelogCategory::Added),
1776 user_visible: true,
1777 }],
1778 issue_refs: Vec::new(),
1779 };
1780 let modified = update_changelog(dir.path(), &analysis).expect("ok");
1781 assert!(!modified);
1782 }
1783
1784 #[test]
1785 fn update_changelog_no_file_is_noop() {
1786 let dir = tempfile::tempdir().expect("tempdir");
1787 let analysis = ConventionalAnalysis {
1788 commit_type: CommitType::Feat,
1789 scope: String::new(),
1790 details: vec![ConventionalDetail {
1791 text: "Add.".to_string(),
1792 changelog_category: Some(ChangelogCategory::Added),
1793 user_visible: true,
1794 }],
1795 issue_refs: Vec::new(),
1796 };
1797 let modified = update_changelog(dir.path(), &analysis).expect("ok");
1798 assert!(!modified);
1799 }
1800
1801 #[test]
1804 fn parse_args_defaults() {
1805 let args = parse_args(&json!({})).expect("ok");
1806 assert!(!args.dry_run);
1807 assert!(!args.push);
1808 assert!(!args.no_changelog);
1809 assert!(args.context.is_none());
1810 }
1811
1812 #[test]
1813 fn parse_args_all_set() {
1814 let args = parse_args(
1815 &json!({"dry_run": true, "push": true, "no_changelog": true, "context": "ctx"}),
1816 )
1817 .expect("ok");
1818 assert!(args.dry_run);
1819 assert!(args.push);
1820 assert!(args.no_changelog);
1821 assert_eq!(args.context.as_deref(), Some("ctx"));
1822 }
1823}