1use crate::cli::output::print_success;
4use crate::core::config::Config;
5use crate::core::semver::validate_semver;
6use crate::infra::git::{
7 git_commit_files, git_create_annotated_tag, git_is_clean, resolve_repo_name, resolve_toplevel,
8};
9use crate::workflow::error::WorkflowError;
10use std::path::Path;
11
12pub struct TagOptions<'a> {
14 pub version: &'a str,
16 pub repo_root: Option<&'a Path>,
18}
19
20pub fn update_cargo_toml_version(content: &str, new_version: &str) -> Option<String> {
47 let mut in_package = false;
48 let mut replaced = false;
49 let mut result = String::with_capacity(content.len() + 16);
50
51 for line in content.split_inclusive('\n') {
52 let trimmed = line.trim();
53
54 if trimmed.starts_with('[') {
55 let without_comment = if let Some(idx) = trimmed.find('#') {
56 trimmed[..idx].trim()
57 } else {
58 trimmed
59 };
60 in_package = without_comment == "[package]"
61 || without_comment
62 .strip_prefix('[')
63 .and_then(|s| s.strip_suffix(']'))
64 .map(|s| s.trim())
65 == Some("package");
66 } else if in_package && !replaced {
67 if let Some(rest) = trimmed.strip_prefix("version") {
68 let rest = rest.trim_start();
69 if let Some(after_eq) = rest.strip_prefix('=') {
70 let after_eq = after_eq.trim_start();
71 if let Some(quote) = after_eq.chars().next() {
72 if quote == '"' || quote == '\'' {
73 let after_quote = &after_eq[quote.len_utf8()..];
74 if let Some(end_idx) = after_quote.find(quote) {
75 let current_version = &after_quote[..end_idx];
76 if current_version == new_version {
77 return None;
78 }
79 let old_pattern = format!("{}{}{}", quote, current_version, quote);
80 let new_pattern = format!("{}{}{}", quote, new_version, quote);
81 let updated_line = line.replacen(&old_pattern, &new_pattern, 1);
82 result.push_str(&updated_line);
83 replaced = true;
84 continue;
85 }
86 }
87 }
88 }
89 }
90 }
91
92 result.push_str(line);
93 }
94
95 if replaced {
96 Some(result)
97 } else {
98 None
99 }
100}
101
102pub fn tag_release(options: TagOptions<'_>) -> Result<(), WorkflowError> {
124 if !validate_semver(options.version) {
125 return Err(WorkflowError::InvalidSemVer(options.version.to_string()));
126 }
127
128 let toplevel = resolve_toplevel(options.repo_root)?;
129 let repo_name = resolve_repo_name(&toplevel);
130
131 let config_path = toplevel.join(".git-claw.toml");
132 let config = Config::load_or_default(&config_path, &repo_name)?;
133
134 if !git_is_clean(&toplevel)? {
135 return Err(WorkflowError::DirtyWorkingTree);
136 }
137
138 let cargo_toml_path = toplevel.join("Cargo.toml");
139 if cargo_toml_path.exists() {
140 let clean_version = options.version.trim_start_matches('v');
141 let content = std::fs::read_to_string(&cargo_toml_path)?;
142 if let Some(new_content) = update_cargo_toml_version(&content, clean_version) {
143 std::fs::write(&cargo_toml_path, new_content)?;
144
145 let cargo_lock_path = toplevel.join("Cargo.lock");
146 let commit_files: &[&str] = if cargo_lock_path.exists() {
147 let _ = std::process::Command::new("cargo")
148 .current_dir(&toplevel)
149 .arg("check")
150 .output();
151 &["Cargo.toml", "Cargo.lock"]
152 } else {
153 &["Cargo.toml"]
154 };
155
156 let commit_msg = format!("chore: release {}", options.version);
157 git_commit_files(&toplevel, commit_files, &commit_msg)?;
158 print_success(format!("Updated Cargo.toml to version '{}'", clean_version));
159 }
160 }
161
162 let message = format!("Release {}", options.version);
163 git_create_annotated_tag(&toplevel, options.version, config.main_branch(), &message)?;
164
165 print_success(format!(
166 "Created annotated release tag '{}' on branch '{}'",
167 options.version,
168 config.main_branch()
169 ));
170
171 Ok(())
172}