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::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
17/// Options for release tagging workflow.
18pub struct TagOptions<'a> {
19    /// Target release version string (e.g., "v0.3.0" or "0.3.0").
20    /// If omitted (`None`), inferred automatically from conventional commits.
21    pub version: Option<&'a str>,
22    /// Optional explicit path to the repository root.
23    pub repo_root: Option<&'a Path>,
24}
25
26/// Updates the `version` field under specified section headers in a TOML string.
27///
28/// Preserves comments, indentation, and formatting.
29/// Returns `None` if no matching section contains a `version` key,
30/// or if the version is already equal to `new_version`.
31///
32/// # Arguments
33///
34/// * `content` - Raw string content of the TOML file.
35/// * `sections` - Slice of target section names (e.g., `&["project", "tool.poetry"]`).
36/// * `new_version` - Target SemVer version string (with or without 'v' prefix).
37///
38/// # Example
39///
40/// ```
41/// use git_claw::workflow::update_toml_version;
42///
43/// let toml = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n";
44/// let updated = update_toml_version(toml, &["package"], "0.2.0");
45/// assert_eq!(
46///     updated,
47///     Some("[package]\nname = \"demo\"\nversion = \"0.2.0\"\n".to_string())
48/// );
49/// ```
50pub 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
111/// Updates the `version` field under `[package]` in a `Cargo.toml` string.
112///
113/// Preserves comments, indentation, and formatting.
114/// Returns `None` if `[package]` is not found, the `version` key is missing,
115/// or the version is already equal to `new_version`.
116///
117/// # Arguments
118///
119/// * `content` - Raw string content of `Cargo.toml`.
120/// * `new_version` - Target SemVer version string (without 'v' prefix).
121///
122/// # Example
123///
124/// ```
125/// use git_claw::workflow::update_cargo_toml_version;
126///
127/// let toml = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n";
128/// let updated = update_cargo_toml_version(toml, "0.2.0");
129/// assert_eq!(
130///     updated,
131///     Some("[package]\nname = \"demo\"\nversion = \"0.2.0\"\n".to_string())
132/// );
133///
134/// let unchanged = update_cargo_toml_version(toml, "0.1.0");
135/// assert_eq!(unchanged, None);
136/// ```
137pub fn update_cargo_toml_version(content: &str, new_version: &str) -> Option<String> {
138    update_toml_version(content, &["package"], new_version)
139}
140
141/// Updates the `version` field under `[project]` or `[tool.poetry]` in a `pyproject.toml` or `project.toml` string.
142///
143/// Preserves comments, indentation, and formatting.
144/// Returns `None` if neither section contains a `version` key,
145/// or if the version is already equal to `new_version`.
146///
147/// # Arguments
148///
149/// * `content` - Raw string content of `pyproject.toml` or `project.toml`.
150/// * `new_version` - Target SemVer version string (with or without 'v' prefix).
151///
152/// # Example
153///
154/// ```
155/// use git_claw::workflow::update_pyproject_toml_version;
156///
157/// let toml = "[project]\nname = \"demo\"\nversion = \"0.1.0\"\n";
158/// let updated = update_pyproject_toml_version(toml, "0.2.0");
159/// assert_eq!(
160///     updated,
161///     Some("[project]\nname = \"demo\"\nversion = \"0.2.0\"\n".to_string())
162/// );
163///
164/// let poetry_toml = "[tool.poetry]\nname = \"demo\"\nversion = \"0.1.0\"\n";
165/// let updated_poetry = update_pyproject_toml_version(poetry_toml, "0.2.0");
166/// assert_eq!(
167///     updated_poetry,
168///     Some("[tool.poetry]\nname = \"demo\"\nversion = \"0.2.0\"\n".to_string())
169/// );
170///
171/// let unchanged = update_pyproject_toml_version(toml, "0.1.0");
172/// assert_eq!(unchanged, None);
173/// ```
174pub fn update_pyproject_toml_version(content: &str, new_version: &str) -> Option<String> {
175    update_toml_version(content, &["project", "tool.poetry"], new_version)
176}
177
178/// Helper to extract base SemVer from manifest files (Cargo.toml, pyproject.toml, project.toml).
179fn 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
225/// Validates SemVer, checks clean trunk, synchronizes manifest versions if present
226/// (Rust `Cargo.toml` and Python `pyproject.toml` / `project.toml`), and creates
227/// an annotated Git release tag.
228///
229/// If `options.version` is omitted (`None`), the next SemVer version and release notes
230/// are automatically inferred from Conventional Commits since the latest Git tag.
231///
232/// # Arguments
233///
234/// * `options` - Tagging options specifying version and optional repository root.
235///
236/// # Example
237///
238/// ```no_run
239/// use std::path::Path;
240/// use git_claw::workflow::{tag_release, TagOptions};
241///
242/// # fn run() -> Result<(), git_claw::workflow::WorkflowError> {
243/// // Explicit version:
244/// tag_release(TagOptions {
245///     version: Some("v0.3.0"),
246///     repo_root: Some(Path::new(".")),
247/// })?;
248///
249/// // Inferred from conventional commits:
250/// tag_release(TagOptions {
251///     version: None,
252///     repo_root: Some(Path::new(".")),
253/// })?;
254/// # Ok(())
255/// # }
256/// ```
257pub 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}