1use crate::cli::output::print_success;
4use crate::core::config::Config;
5use crate::core::conventional::{determine_bump, parse_conventional_commit, ConventionalCommit};
6use crate::core::semver::{parse_semver, validate_semver, SemVer};
7use crate::infra::git::{
8 git_commit_files, git_create_annotated_tag, git_get_commits_since, git_get_latest_tag,
9 git_is_clean, resolve_repo_name, resolve_toplevel,
10};
11use crate::workflow::changelog::{
12 current_date_ymd, generate_changelog_entry, update_changelog_file,
13};
14use crate::workflow::error::WorkflowError;
15use std::path::Path;
16
17pub struct TagOptions<'a> {
19 pub version: Option<&'a str>,
22 pub repo_root: Option<&'a Path>,
24}
25
26pub fn update_toml_version(content: &str, sections: &[&str], new_version: &str) -> Option<String> {
51 let clean_version = new_version.trim_start_matches('v');
52 let mut in_target_section = false;
53 let mut replaced = false;
54 let mut result = String::with_capacity(content.len() + 16);
55
56 for line in content.split_inclusive('\n') {
57 let trimmed = line.trim();
58
59 if trimmed.starts_with('[') && !trimmed.starts_with("[[") {
60 let without_comment = if let Some(idx) = trimmed.find('#') {
61 trimmed[..idx].trim()
62 } else {
63 trimmed
64 };
65 in_target_section = without_comment
66 .strip_prefix('[')
67 .and_then(|s| s.strip_suffix(']'))
68 .map(|s| {
69 let sec = s.trim();
70 sections.contains(&sec)
71 })
72 .unwrap_or(false);
73 } else if trimmed.starts_with("[[") {
74 in_target_section = false;
75 } else if in_target_section && !replaced {
76 if let Some(rest) = trimmed.strip_prefix("version") {
77 let rest = rest.trim_start();
78 if let Some(after_eq) = rest.strip_prefix('=') {
79 let after_eq = after_eq.trim_start();
80 if let Some(quote) = after_eq.chars().next() {
81 if quote == '"' || quote == '\'' {
82 let after_quote = &after_eq[quote.len_utf8()..];
83 if let Some(end_idx) = after_quote.find(quote) {
84 let current_version = &after_quote[..end_idx];
85 if current_version == clean_version {
86 return None;
87 }
88 let old_pattern = format!("{}{}{}", quote, current_version, quote);
89 let new_pattern = format!("{}{}{}", quote, clean_version, quote);
90 let updated_line = line.replacen(&old_pattern, &new_pattern, 1);
91 result.push_str(&updated_line);
92 replaced = true;
93 continue;
94 }
95 }
96 }
97 }
98 }
99 }
100
101 result.push_str(line);
102 }
103
104 if replaced {
105 Some(result)
106 } else {
107 None
108 }
109}
110
111pub fn update_cargo_toml_version(content: &str, new_version: &str) -> Option<String> {
138 update_toml_version(content, &["package"], new_version)
139}
140
141pub fn update_pyproject_toml_version(content: &str, new_version: &str) -> Option<String> {
175 update_toml_version(content, &["project", "tool.poetry"], new_version)
176}
177
178fn read_base_version_from_manifests(repo_root: &Path) -> Option<SemVer> {
180 let cargo_toml = repo_root.join("Cargo.toml");
181 if cargo_toml.exists() {
182 if let Ok(content) = std::fs::read_to_string(&cargo_toml) {
183 if let Ok(val) = content.parse::<toml::Value>() {
184 if let Some(ver) = val
185 .get("package")
186 .and_then(|p| p.get("version"))
187 .and_then(|v| v.as_str())
188 {
189 if let Some(mut sem) = parse_semver(ver) {
190 sem.has_v_prefix = true;
191 return Some(sem);
192 }
193 }
194 }
195 }
196 }
197 for manifest_name in ["pyproject.toml", "project.toml"] {
198 let manifest_path = repo_root.join(manifest_name);
199 if manifest_path.exists() {
200 if let Ok(content) = std::fs::read_to_string(&manifest_path) {
201 if let Ok(val) = content.parse::<toml::Value>() {
202 let ver = val
203 .get("project")
204 .and_then(|p| p.get("version"))
205 .and_then(|v| v.as_str())
206 .or_else(|| {
207 val.get("tool")
208 .and_then(|t| t.get("poetry"))
209 .and_then(|p| p.get("version"))
210 .and_then(|v| v.as_str())
211 });
212 if let Some(ver_str) = ver {
213 if let Some(mut sem) = parse_semver(ver_str) {
214 sem.has_v_prefix = true;
215 return Some(sem);
216 }
217 }
218 }
219 }
220 }
221 }
222 None
223}
224
225pub fn tag_release(options: TagOptions<'_>) -> Result<(), WorkflowError> {
258 let toplevel = resolve_toplevel(options.repo_root)?;
259 let repo_name = resolve_repo_name(&toplevel);
260
261 let config_path = toplevel.join(".git-claw.toml");
262 let config = Config::load_or_default(&config_path, &repo_name)?;
263
264 if !git_is_clean(&toplevel)? {
265 return Err(WorkflowError::DirtyWorkingTree);
266 }
267
268 let mut files_to_commit: Vec<&str> = Vec::new();
269
270 let target_version = match options.version {
271 Some(v) => {
272 if !validate_semver(v) {
273 return Err(WorkflowError::InvalidSemVer(v.to_string()));
274 }
275 v.to_string()
276 }
277 None => {
278 let latest_tag = git_get_latest_tag(&toplevel)?;
279 let raw_commits = git_get_commits_since(&toplevel, latest_tag.as_deref())?;
280 if raw_commits.is_empty() {
281 return Err(WorkflowError::ExecutionFailed(
282 "No commits found since last release tag".to_string(),
283 ));
284 }
285
286 let conventional_commits: Vec<ConventionalCommit> = raw_commits
287 .iter()
288 .filter_map(|msg| parse_conventional_commit(msg))
289 .collect();
290 let bump_type = determine_bump(&conventional_commits);
291
292 let base_semver = if let Some(tag) = &latest_tag {
293 parse_semver(tag).unwrap_or(SemVer {
294 major: 0,
295 minor: 1,
296 patch: 0,
297 has_v_prefix: tag.starts_with('v'),
298 })
299 } else {
300 read_base_version_from_manifests(&toplevel).unwrap_or(SemVer {
301 major: 0,
302 minor: 1,
303 patch: 0,
304 has_v_prefix: true,
305 })
306 };
307
308 let target = base_semver.bump(bump_type).to_string();
309 print_success(format!(
310 "Inferred bump: {:?} -> version '{}'",
311 bump_type, target
312 ));
313
314 let date = current_date_ymd();
315 let entry = generate_changelog_entry(&target, &date, &conventional_commits);
316 update_changelog_file(&toplevel, &entry)?;
317 files_to_commit.push("CHANGELOG.md");
318 print_success("Updated CHANGELOG.md".to_string());
319
320 target
321 }
322 };
323
324 let clean_version = target_version.trim_start_matches('v');
325
326 let cargo_toml_path = toplevel.join("Cargo.toml");
327 if cargo_toml_path.exists() {
328 let content = std::fs::read_to_string(&cargo_toml_path)?;
329 if let Some(new_content) = update_cargo_toml_version(&content, clean_version) {
330 std::fs::write(&cargo_toml_path, new_content)?;
331 files_to_commit.push("Cargo.toml");
332 print_success(format!("Updated Cargo.toml to version '{}'", clean_version));
333
334 let cargo_lock_path = toplevel.join("Cargo.lock");
335 if cargo_lock_path.exists() {
336 let _ = std::process::Command::new("cargo")
337 .current_dir(&toplevel)
338 .arg("check")
339 .output();
340 files_to_commit.push("Cargo.lock");
341 }
342 }
343 }
344
345 let mut python_updated = false;
346 for manifest_name in ["pyproject.toml", "project.toml"] {
347 let manifest_path = toplevel.join(manifest_name);
348 if manifest_path.exists() {
349 let content = std::fs::read_to_string(&manifest_path)?;
350 if let Some(new_content) = update_pyproject_toml_version(&content, clean_version) {
351 std::fs::write(&manifest_path, new_content)?;
352 files_to_commit.push(manifest_name);
353 print_success(format!(
354 "Updated {} to version '{}'",
355 manifest_name, clean_version
356 ));
357 python_updated = true;
358 }
359 }
360 }
361
362 if python_updated {
363 let uv_lock_path = toplevel.join("uv.lock");
364 if uv_lock_path.exists() {
365 let _ = std::process::Command::new("uv")
366 .arg("lock")
367 .current_dir(&toplevel)
368 .output();
369 files_to_commit.push("uv.lock");
370 }
371 }
372
373 if !files_to_commit.is_empty() {
374 let commit_msg = format!("chore: release {}", target_version);
375 git_commit_files(&toplevel, &files_to_commit, &commit_msg)?;
376 }
377
378 let message = format!("Release {}", target_version);
379 git_create_annotated_tag(&toplevel, &target_version, config.main_branch(), &message)?;
380
381 print_success(format!(
382 "Created annotated release tag '{}' on branch '{}'",
383 target_version,
384 config.main_branch()
385 ));
386
387 Ok(())
388}