Skip to main content

git_claw/workflow/
tag.rs

1//! SemVer release tag manager.
2
3use 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
12/// Options for release tagging workflow.
13pub struct TagOptions<'a> {
14    /// Target release version string (e.g., "v0.3.0" or "0.3.0").
15    pub version: &'a str,
16    /// Optional explicit path to the repository root.
17    pub repo_root: Option<&'a Path>,
18}
19
20/// Updates the `version` field under `[package]` in a `Cargo.toml` string.
21///
22/// Preserves comments, indentation, and formatting.
23/// Returns `None` if `[package]` is not found, the `version` key is missing,
24/// or the version is already equal to `new_version`.
25///
26/// # Arguments
27///
28/// * `content` - Raw string content of `Cargo.toml`.
29/// * `new_version` - Target SemVer version string (without 'v' prefix).
30///
31/// # Example
32///
33/// ```
34/// use git_claw::workflow::update_cargo_toml_version;
35///
36/// let toml = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n";
37/// let updated = update_cargo_toml_version(toml, "0.2.0");
38/// assert_eq!(
39///     updated,
40///     Some("[package]\nname = \"demo\"\nversion = \"0.2.0\"\n".to_string())
41/// );
42///
43/// let unchanged = update_cargo_toml_version(toml, "0.1.0");
44/// assert_eq!(unchanged, None);
45/// ```
46pub 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
102/// Validates SemVer, checks clean trunk, synchronizes Cargo.toml version if present,
103/// and creates an annotated Git release tag.
104///
105/// # Arguments
106///
107/// * `options` - Tagging options specifying version and optional repository root.
108///
109/// # Example
110///
111/// ```no_run
112/// use std::path::Path;
113/// use git_claw::workflow::{tag_release, TagOptions};
114///
115/// # fn run() -> Result<(), git_claw::workflow::WorkflowError> {
116/// tag_release(TagOptions {
117///     version: "v0.3.0",
118///     repo_root: Some(Path::new(".")),
119/// })?;
120/// # Ok(())
121/// # }
122/// ```
123pub 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}