Skip to main content

sr_core/
release.rs

1use std::fs;
2use std::path::Path;
3
4use semver::Version;
5use serde::Serialize;
6
7use crate::changelog::{ChangelogEntry, ChangelogFormatter};
8use crate::commit::{CommitParser, ConventionalCommit, DefaultCommitClassifier};
9use crate::config::ReleaseConfig;
10use crate::error::ReleaseError;
11use crate::git::GitRepository;
12use crate::version::{BumpLevel, apply_bump, apply_prerelease_bump, determine_bump};
13use crate::version_files::bump_version_file;
14
15/// The computed plan for a release, before execution.
16#[derive(Debug, Serialize)]
17pub struct ReleasePlan {
18    pub current_version: Option<Version>,
19    pub next_version: Version,
20    pub bump: BumpLevel,
21    pub commits: Vec<ConventionalCommit>,
22    pub tag_name: String,
23    pub floating_tag_name: Option<String>,
24    pub prerelease: bool,
25}
26
27/// Orchestrates the release flow.
28pub trait ReleaseStrategy: Send + Sync {
29    /// Plan the release without executing it.
30    fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
31
32    /// Execute the release.
33    fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
34}
35
36/// Abstraction over a remote VCS provider (e.g. GitHub, GitLab).
37pub trait VcsProvider: Send + Sync {
38    /// Create a release on the remote VCS.
39    fn create_release(
40        &self,
41        tag: &str,
42        name: &str,
43        body: &str,
44        prerelease: bool,
45        draft: bool,
46    ) -> Result<String, ReleaseError>;
47
48    /// Generate a compare URL between two refs.
49    fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
50
51    /// Check if a release already exists for the given tag.
52    fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
53
54    /// Delete a release by tag.
55    fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
56
57    /// Return the base URL of the repository (e.g. `https://github.com/owner/repo`).
58    fn repo_url(&self) -> Option<String> {
59        None
60    }
61
62    /// Update an existing release (name and body) using PATCH semantics,
63    /// preserving any previously uploaded assets.
64    fn update_release(
65        &self,
66        tag: &str,
67        name: &str,
68        body: &str,
69        prerelease: bool,
70        draft: bool,
71    ) -> Result<String, ReleaseError> {
72        self.delete_release(tag)?;
73        self.create_release(tag, name, body, prerelease, draft)
74    }
75
76    /// Upload asset files to an existing release identified by tag.
77    fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
78        Ok(())
79    }
80
81    /// Verify that a release exists and is in the expected state after creation.
82    fn verify_release(&self, _tag: &str) -> Result<(), ReleaseError> {
83        Ok(())
84    }
85}
86
87/// Concrete release strategy implementing the trunk-based release flow.
88pub struct TrunkReleaseStrategy<G, V, C, F> {
89    pub git: G,
90    pub vcs: Option<V>,
91    pub parser: C,
92    pub formatter: F,
93    pub config: ReleaseConfig,
94    /// When true, re-release the current tag if HEAD is at the latest tag.
95    pub force: bool,
96}
97
98impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
99where
100    G: GitRepository,
101    V: VcsProvider,
102    C: CommitParser,
103    F: ChangelogFormatter,
104{
105    fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
106        let today = today_string();
107        let compare_url = self.vcs.as_ref().and_then(|vcs| {
108            let base = match &plan.current_version {
109                Some(v) => format!("{}{v}", self.config.tag_prefix),
110                None => return None,
111            };
112            vcs.compare_url(&base, &plan.tag_name).ok()
113        });
114        let entry = ChangelogEntry {
115            version: plan.next_version.to_string(),
116            date: today,
117            commits: plan.commits.clone(),
118            compare_url,
119            repo_url: self.vcs.as_ref().and_then(|v| v.repo_url()),
120        };
121        self.formatter.format(&[entry])
122    }
123
124    /// Render the release name from the configured template, or fall back to the tag name.
125    fn release_name(&self, plan: &ReleasePlan) -> String {
126        if let Some(ref template_str) = self.config.release_name_template {
127            let mut env = minijinja::Environment::new();
128            if env.add_template("release_name", template_str).is_ok()
129                && let Ok(tmpl) = env.get_template("release_name")
130                && let Ok(rendered) = tmpl.render(minijinja::context! {
131                    version => plan.next_version.to_string(),
132                    tag_name => &plan.tag_name,
133                    tag_prefix => &self.config.tag_prefix,
134                })
135            {
136                return rendered;
137            }
138            eprintln!("warning: invalid release_name_template, falling back to tag name");
139        }
140        plan.tag_name.clone()
141    }
142}
143
144impl<G, V, C, F> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F>
145where
146    G: GitRepository,
147    V: VcsProvider,
148    C: CommitParser,
149    F: ChangelogFormatter,
150{
151    fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
152        let is_prerelease = self.config.prerelease.is_some();
153
154        // For stable releases, find the latest stable tag (skip pre-release tags).
155        // For pre-releases, find the latest tag of any kind to determine commits since.
156        let all_tags = self.git.all_tags(&self.config.tag_prefix)?;
157        let latest_stable = all_tags.iter().rev().find(|t| t.version.pre.is_empty());
158        let latest_any = all_tags.last();
159
160        // Use the latest tag (any kind) for commit range, but the latest stable for base version
161        let tag_info = if is_prerelease {
162            latest_any
163        } else {
164            latest_stable.or(latest_any)
165        };
166
167        let (current_version, from_sha) = match tag_info {
168            Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
169            None => (None, None),
170        };
171
172        let raw_commits = self.git.commits_since(from_sha)?;
173        if raw_commits.is_empty() {
174            // Force mode: re-release if HEAD is exactly at the latest tag
175            if self.force
176                && let Some(info) = tag_info
177            {
178                let head = self.git.head_sha()?;
179                if head == info.sha {
180                    let floating_tag_name = if self.config.floating_tags {
181                        Some(format!("{}{}", self.config.tag_prefix, info.version.major))
182                    } else {
183                        None
184                    };
185                    return Ok(ReleasePlan {
186                        current_version: Some(info.version.clone()),
187                        next_version: info.version.clone(),
188                        bump: BumpLevel::Patch,
189                        commits: vec![],
190                        tag_name: info.name.clone(),
191                        floating_tag_name,
192                        prerelease: is_prerelease,
193                    });
194                }
195            }
196            let (tag, sha) = match tag_info {
197                Some(info) => (info.name.clone(), info.sha.clone()),
198                None => ("(none)".into(), "(none)".into()),
199            };
200            return Err(ReleaseError::NoCommits { tag, sha });
201        }
202
203        let conventional_commits: Vec<ConventionalCommit> = raw_commits
204            .iter()
205            .filter(|c| !c.message.starts_with("chore(release):"))
206            .filter_map(|c| self.parser.parse(c).ok())
207            .collect();
208
209        let classifier = DefaultCommitClassifier::new(
210            self.config.types.clone(),
211            self.config.commit_pattern.clone(),
212        );
213        let tag_for_err = tag_info
214            .map(|i| i.name.clone())
215            .unwrap_or_else(|| "(none)".into());
216        let commit_count = conventional_commits.len();
217        let bump =
218            determine_bump(&conventional_commits, &classifier).ok_or(ReleaseError::NoBump {
219                tag: tag_for_err,
220                commit_count,
221            })?;
222
223        // For pre-releases, base the version on the latest *stable* tag
224        let base_version = if is_prerelease {
225            latest_stable
226                .map(|t| t.version.clone())
227                .or(current_version.clone())
228                .unwrap_or(Version::new(0, 0, 0))
229        } else {
230            current_version.clone().unwrap_or(Version::new(0, 0, 0))
231        };
232
233        let next_version = if let Some(ref prerelease_id) = self.config.prerelease {
234            let existing_versions: Vec<Version> =
235                all_tags.iter().map(|t| t.version.clone()).collect();
236            apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
237        } else {
238            apply_bump(&base_version, bump)
239        };
240
241        let tag_name = format!("{}{next_version}", self.config.tag_prefix);
242
243        // Don't update floating tags for pre-releases
244        let floating_tag_name = if self.config.floating_tags && !is_prerelease {
245            Some(format!("{}{}", self.config.tag_prefix, next_version.major))
246        } else {
247            None
248        };
249
250        Ok(ReleasePlan {
251            current_version,
252            next_version,
253            bump,
254            commits: conventional_commits,
255            tag_name,
256            floating_tag_name,
257            prerelease: is_prerelease,
258        })
259    }
260
261    fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
262        let version_str = plan.next_version.to_string();
263
264        if dry_run {
265            let changelog_body = self.format_changelog(plan)?;
266            if let Some(ref cmd) = self.config.pre_release_command {
267                eprintln!("[dry-run] Would run pre-release command: {cmd}");
268            }
269            let sign_label = if self.config.sign_tags {
270                " (signed)"
271            } else {
272                ""
273            };
274            eprintln!("[dry-run] Would create tag: {}{sign_label}", plan.tag_name);
275            eprintln!("[dry-run] Would push tag: {}", plan.tag_name);
276            if let Some(ref floating) = plan.floating_tag_name {
277                eprintln!("[dry-run] Would create/update floating tag: {floating}");
278                eprintln!("[dry-run] Would force-push floating tag: {floating}");
279            }
280            if self.vcs.is_some() {
281                let draft_label = if self.config.draft { " (draft)" } else { "" };
282                let release_name = self.release_name(plan);
283                eprintln!(
284                    "[dry-run] Would create GitHub release \"{release_name}\" for {}{draft_label}",
285                    plan.tag_name
286                );
287            }
288            for file in &self.config.version_files {
289                let filename = Path::new(file)
290                    .file_name()
291                    .and_then(|n| n.to_str())
292                    .unwrap_or_default();
293                let supported = matches!(
294                    filename,
295                    "Cargo.toml"
296                        | "package.json"
297                        | "pyproject.toml"
298                        | "pom.xml"
299                        | "build.gradle"
300                        | "build.gradle.kts"
301                ) || filename.ends_with(".go");
302                if supported {
303                    eprintln!("[dry-run] Would bump version in: {file}");
304                } else if self.config.version_files_strict {
305                    return Err(ReleaseError::VersionBump(format!(
306                        "unsupported version file: {filename}"
307                    )));
308                } else {
309                    eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
310                }
311            }
312            if !self.config.artifacts.is_empty() {
313                let resolved = resolve_artifact_globs(&self.config.artifacts)?;
314                if resolved.is_empty() {
315                    eprintln!("[dry-run] Artifact patterns matched no files");
316                } else {
317                    eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
318                    for f in &resolved {
319                        eprintln!("[dry-run]   {f}");
320                    }
321                }
322            }
323            if let Some(ref cmd) = self.config.build_command {
324                eprintln!("[dry-run] Would run build command: {cmd}");
325            }
326            if !self.config.stage_files.is_empty() {
327                eprintln!(
328                    "[dry-run] Would stage additional files: {}",
329                    self.config.stage_files.join(", ")
330                );
331            }
332            if let Some(ref cmd) = self.config.post_release_command {
333                eprintln!("[dry-run] Would run post-release command: {cmd}");
334            }
335            eprintln!("[dry-run] Changelog:\n{changelog_body}");
336            return Ok(());
337        }
338
339        // 0. Run pre-release command if configured
340        if let Some(ref cmd) = self.config.pre_release_command {
341            eprintln!("Running pre-release command: {cmd}");
342            run_hook(cmd, &version_str, &plan.tag_name, "pre_release_command")?;
343        }
344
345        // 1. Format changelog
346        let changelog_body = self.format_changelog(plan)?;
347
348        // 2. Snapshot files before mutation (for rollback on failure)
349        let mut file_snapshots: Vec<(String, Option<String>)> = Vec::new();
350        for file in &self.config.version_files {
351            let path = Path::new(file);
352            let contents = if path.exists() {
353                Some(
354                    fs::read_to_string(path)
355                        .map_err(|e| ReleaseError::VersionBump(e.to_string()))?,
356                )
357            } else {
358                None
359            };
360            file_snapshots.push((file.clone(), contents));
361        }
362        if let Some(ref changelog_file) = self.config.changelog.file {
363            let path = Path::new(changelog_file);
364            let contents = if path.exists() {
365                Some(fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?)
366            } else {
367                None
368            };
369            file_snapshots.push((changelog_file.clone(), contents));
370        }
371
372        // Run the mutable pre-commit steps with rollback on failure
373        let bumped_files = match self.execute_pre_commit(plan, &version_str, &changelog_body) {
374            Ok(files) => files,
375            Err(e) => {
376                eprintln!("error during pre-commit steps, restoring files...");
377                restore_snapshots(&file_snapshots);
378                return Err(e);
379            }
380        };
381
382        // 4. Resolve stage_files globs and collect all paths to stage
383        {
384            let mut paths_to_stage: Vec<String> = Vec::new();
385            if let Some(ref changelog_file) = self.config.changelog.file {
386                paths_to_stage.push(changelog_file.clone());
387            }
388            for file in &bumped_files {
389                paths_to_stage.push(file.clone());
390            }
391            if !self.config.stage_files.is_empty() {
392                let extra = resolve_glob_patterns(&self.config.stage_files)?;
393                paths_to_stage.extend(extra);
394            }
395            if !paths_to_stage.is_empty() {
396                let refs: Vec<&str> = paths_to_stage.iter().map(|s| s.as_str()).collect();
397                let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
398                self.git.stage_and_commit(&refs, &commit_msg)?;
399            }
400        }
401
402        // 5. Create tag (skip if it already exists locally)
403        if !self.git.tag_exists(&plan.tag_name)? {
404            let tag_message = format!("{}\n\n{}", plan.tag_name, changelog_body);
405            self.git
406                .create_tag(&plan.tag_name, &tag_message, self.config.sign_tags)?;
407        }
408
409        // 6. Push commit (safe to re-run — no-op if up to date)
410        self.git.push()?;
411
412        // 7. Push tag (skip if tag already exists on remote)
413        if !self.git.remote_tag_exists(&plan.tag_name)? {
414            self.git.push_tag(&plan.tag_name)?;
415        }
416
417        // 8. Force-create and force-push floating tag (e.g. v3)
418        if let Some(ref floating) = plan.floating_tag_name {
419            let floating_msg = format!("Floating tag for {}", plan.tag_name);
420            self.git
421                .force_create_tag(floating, &floating_msg, self.config.sign_tags)?;
422            self.git.force_push_tag(floating)?;
423        }
424
425        // 9. Create or update GitHub release
426        let release_name = self.release_name(plan);
427        if let Some(ref vcs) = self.vcs {
428            if vcs.release_exists(&plan.tag_name)? {
429                // PATCH update preserves existing assets
430                vcs.update_release(
431                    &plan.tag_name,
432                    &release_name,
433                    &changelog_body,
434                    plan.prerelease,
435                    self.config.draft,
436                )?;
437            } else {
438                vcs.create_release(
439                    &plan.tag_name,
440                    &release_name,
441                    &changelog_body,
442                    plan.prerelease,
443                    self.config.draft,
444                )?;
445            }
446        }
447
448        // 10. Upload artifacts (with SHA256 checksums)
449        if let Some(ref vcs) = self.vcs
450            && !self.config.artifacts.is_empty()
451        {
452            let resolved = resolve_artifact_globs(&self.config.artifacts)?;
453            if !resolved.is_empty() {
454                // Generate SHA256 checksum sidecar files
455                let checksum_files = generate_checksums(&resolved)?;
456                let mut all_files = resolved.clone();
457                all_files.extend(checksum_files.iter().cloned());
458
459                let file_refs: Vec<&str> = all_files.iter().map(|s| s.as_str()).collect();
460                vcs.upload_assets(&plan.tag_name, &file_refs)?;
461                eprintln!(
462                    "Uploaded {} artifact(s) + {} checksum(s) to {}",
463                    resolved.len(),
464                    checksum_files.len(),
465                    plan.tag_name
466                );
467
468                // Clean up generated checksum files
469                for f in &checksum_files {
470                    let _ = fs::remove_file(f);
471                }
472            }
473        }
474
475        // 11. Verify release was created/updated successfully
476        if let Some(ref vcs) = self.vcs
477            && let Err(e) = vcs.verify_release(&plan.tag_name)
478        {
479            eprintln!("warning: post-release verification failed: {e}");
480            eprintln!(
481                "  The tag {} was pushed but the GitHub release may be incomplete.",
482                plan.tag_name
483            );
484            eprintln!("  Re-run with --force to retry.");
485        }
486
487        // 12. Run post-release command if configured
488        if let Some(ref cmd) = self.config.post_release_command {
489            eprintln!("Running post-release command: {cmd}");
490            run_hook(cmd, &version_str, &plan.tag_name, "post_release_command")?;
491        }
492
493        eprintln!("Released {}", plan.tag_name);
494        Ok(())
495    }
496}
497
498impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
499where
500    G: GitRepository,
501    V: VcsProvider,
502    C: CommitParser,
503    F: ChangelogFormatter,
504{
505    /// Execute the mutable pre-commit steps: bump version files, write changelog, run build command.
506    /// Returns the list of bumped files on success. On error the caller restores snapshots.
507    fn execute_pre_commit(
508        &self,
509        plan: &ReleasePlan,
510        version_str: &str,
511        changelog_body: &str,
512    ) -> Result<Vec<String>, ReleaseError> {
513        // 2. Bump version files
514        let mut bumped_files: Vec<String> = Vec::new();
515        for file in &self.config.version_files {
516            match bump_version_file(Path::new(file), version_str) {
517                Ok(()) => bumped_files.push(file.clone()),
518                Err(e) if !self.config.version_files_strict => {
519                    eprintln!("warning: {e} — skipping {file}");
520                }
521                Err(e) => return Err(e),
522            }
523        }
524
525        // 3. Write changelog file if configured
526        if let Some(ref changelog_file) = self.config.changelog.file {
527            let path = Path::new(changelog_file);
528            let existing = if path.exists() {
529                fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
530            } else {
531                String::new()
532            };
533            let new_content = if existing.is_empty() {
534                format!("# Changelog\n\n{changelog_body}\n")
535            } else {
536                match existing.find("\n\n") {
537                    Some(pos) => {
538                        let (header, rest) = existing.split_at(pos);
539                        format!("{header}\n\n{changelog_body}\n{rest}")
540                    }
541                    None => format!("{existing}\n\n{changelog_body}\n"),
542                }
543            };
544            fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
545        }
546
547        // 3.5. Run build command if configured
548        if let Some(ref cmd) = self.config.build_command {
549            eprintln!("Running build command: {cmd}");
550            run_hook(cmd, version_str, &plan.tag_name, "build_command")?;
551        }
552
553        Ok(bumped_files)
554    }
555}
556
557/// Restore file contents from snapshots (best-effort, used during rollback).
558fn restore_snapshots(snapshots: &[(String, Option<String>)]) {
559    for (file, contents) in snapshots {
560        let path = Path::new(file);
561        match contents {
562            Some(data) => {
563                if let Err(e) = fs::write(path, data) {
564                    eprintln!("warning: failed to restore {file}: {e}");
565                }
566            }
567            None => {
568                // File didn't exist before — remove it
569                if path.exists()
570                    && let Err(e) = fs::remove_file(path)
571                {
572                    eprintln!("warning: failed to remove {file}: {e}");
573                }
574            }
575        }
576    }
577}
578
579/// Run a shell hook command with SR_VERSION and SR_TAG env vars.
580fn run_hook(cmd: &str, version: &str, tag: &str, label: &str) -> Result<(), ReleaseError> {
581    let status = std::process::Command::new("sh")
582        .args(["-c", cmd])
583        .env("SR_VERSION", version)
584        .env("SR_TAG", tag)
585        .status()
586        .map_err(|e| ReleaseError::BuildCommand(format!("{label}: {e}")))?;
587    if !status.success() {
588        return Err(ReleaseError::BuildCommand(format!(
589            "{label} exited with {}",
590            status.code().unwrap_or(-1)
591        )));
592    }
593    Ok(())
594}
595
596/// Resolve glob patterns into a list of file paths.
597fn resolve_glob_patterns(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
598    let mut files = Vec::new();
599    for pattern in patterns {
600        let paths = glob::glob(pattern)
601            .map_err(|e| ReleaseError::Config(format!("invalid glob pattern '{pattern}': {e}")))?;
602        for entry in paths {
603            match entry {
604                Ok(path) if path.is_file() => {
605                    files.push(path.to_string_lossy().into_owned());
606                }
607                Ok(_) => {}
608                Err(e) => {
609                    eprintln!("warning: glob error: {e}");
610                }
611            }
612        }
613    }
614    Ok(files)
615}
616
617fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
618    let mut files = std::collections::BTreeSet::new();
619    for pattern in patterns {
620        let paths = glob::glob(pattern)
621            .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
622        for entry in paths {
623            match entry {
624                Ok(path) if path.is_file() => {
625                    files.insert(path.to_string_lossy().into_owned());
626                }
627                Ok(_) => {} // skip directories
628                Err(e) => {
629                    eprintln!("warning: glob error: {e}");
630                }
631            }
632        }
633    }
634    Ok(files.into_iter().collect())
635}
636
637/// Generate SHA256 checksum sidecar files for a list of artifact paths.
638/// Returns the paths to the generated `.sha256` files.
639fn generate_checksums(files: &[String]) -> Result<Vec<String>, ReleaseError> {
640    use sha2::{Digest, Sha256};
641
642    let mut checksum_paths = Vec::new();
643    for file_path in files {
644        let data = fs::read(file_path).map_err(|e| {
645            ReleaseError::Vcs(format!("failed to read {file_path} for checksum: {e}"))
646        })?;
647        let hash = Sha256::digest(&data);
648        let hex = format!("{hash:x}");
649        let file_name = Path::new(file_path)
650            .file_name()
651            .and_then(|n| n.to_str())
652            .unwrap_or("unknown");
653        let checksum_content = format!("{hex}  {file_name}\n");
654        let checksum_path = format!("{file_path}.sha256");
655        fs::write(&checksum_path, checksum_content)
656            .map_err(|e| ReleaseError::Vcs(format!("failed to write checksum file: {e}")))?;
657        checksum_paths.push(checksum_path);
658    }
659    Ok(checksum_paths)
660}
661
662pub fn today_string() -> String {
663    // Portable date calculation from UNIX epoch (no external deps or subprocess).
664    // Uses Howard Hinnant's civil_from_days algorithm.
665    let secs = std::time::SystemTime::now()
666        .duration_since(std::time::UNIX_EPOCH)
667        .unwrap_or_default()
668        .as_secs() as i64;
669
670    let z = secs / 86400 + 719468;
671    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
672    let doe = (z - era * 146097) as u32;
673    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
674    let y = yoe as i64 + era * 400;
675    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
676    let mp = (5 * doy + 2) / 153;
677    let d = doy - (153 * mp + 2) / 5 + 1;
678    let m = if mp < 10 { mp + 3 } else { mp - 9 };
679    let y = if m <= 2 { y + 1 } else { y };
680
681    format!("{y:04}-{m:02}-{d:02}")
682}
683
684#[cfg(test)]
685mod tests {
686    use std::sync::Mutex;
687
688    use super::*;
689    use crate::changelog::DefaultChangelogFormatter;
690    use crate::commit::{Commit, DefaultCommitParser};
691    use crate::config::ReleaseConfig;
692    use crate::git::{GitRepository, TagInfo};
693
694    // --- Fakes ---
695
696    struct FakeGit {
697        tags: Vec<TagInfo>,
698        commits: Vec<Commit>,
699        head: String,
700        created_tags: Mutex<Vec<String>>,
701        pushed_tags: Mutex<Vec<String>>,
702        committed: Mutex<Vec<(Vec<String>, String)>>,
703        push_count: Mutex<u32>,
704        force_created_tags: Mutex<Vec<String>>,
705        force_pushed_tags: Mutex<Vec<String>>,
706    }
707
708    impl FakeGit {
709        fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
710            let head = tags
711                .last()
712                .map(|t| t.sha.clone())
713                .unwrap_or_else(|| "0".repeat(40));
714            Self {
715                tags,
716                commits,
717                head,
718                created_tags: Mutex::new(Vec::new()),
719                pushed_tags: Mutex::new(Vec::new()),
720                committed: Mutex::new(Vec::new()),
721                push_count: Mutex::new(0),
722                force_created_tags: Mutex::new(Vec::new()),
723                force_pushed_tags: Mutex::new(Vec::new()),
724            }
725        }
726    }
727
728    impl GitRepository for FakeGit {
729        fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
730            Ok(self.tags.last().cloned())
731        }
732
733        fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
734            Ok(self.commits.clone())
735        }
736
737        fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
738            self.created_tags.lock().unwrap().push(name.to_string());
739            Ok(())
740        }
741
742        fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
743            self.pushed_tags.lock().unwrap().push(name.to_string());
744            Ok(())
745        }
746
747        fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
748            self.committed.lock().unwrap().push((
749                paths.iter().map(|s| s.to_string()).collect(),
750                message.to_string(),
751            ));
752            Ok(true)
753        }
754
755        fn push(&self) -> Result<(), ReleaseError> {
756            *self.push_count.lock().unwrap() += 1;
757            Ok(())
758        }
759
760        fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
761            Ok(self
762                .created_tags
763                .lock()
764                .unwrap()
765                .contains(&name.to_string()))
766        }
767
768        fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
769            Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
770        }
771
772        fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
773            Ok(self.tags.clone())
774        }
775
776        fn commits_between(
777            &self,
778            _from: Option<&str>,
779            _to: &str,
780        ) -> Result<Vec<Commit>, ReleaseError> {
781            Ok(self.commits.clone())
782        }
783
784        fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
785            Ok("2026-01-01".into())
786        }
787
788        fn force_create_tag(
789            &self,
790            name: &str,
791            _message: &str,
792            _sign: bool,
793        ) -> Result<(), ReleaseError> {
794            self.force_created_tags
795                .lock()
796                .unwrap()
797                .push(name.to_string());
798            Ok(())
799        }
800
801        fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
802            self.force_pushed_tags
803                .lock()
804                .unwrap()
805                .push(name.to_string());
806            Ok(())
807        }
808
809        fn head_sha(&self) -> Result<String, ReleaseError> {
810            Ok(self.head.clone())
811        }
812    }
813
814    struct FakeVcs {
815        releases: Mutex<Vec<(String, String)>>,
816        deleted_releases: Mutex<Vec<String>>,
817        uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
818    }
819
820    impl FakeVcs {
821        fn new() -> Self {
822            Self {
823                releases: Mutex::new(Vec::new()),
824                deleted_releases: Mutex::new(Vec::new()),
825                uploaded_assets: Mutex::new(Vec::new()),
826            }
827        }
828    }
829
830    impl VcsProvider for FakeVcs {
831        fn create_release(
832            &self,
833            tag: &str,
834            _name: &str,
835            body: &str,
836            _prerelease: bool,
837            _draft: bool,
838        ) -> Result<String, ReleaseError> {
839            self.releases
840                .lock()
841                .unwrap()
842                .push((tag.to_string(), body.to_string()));
843            Ok(format!("https://github.com/test/release/{tag}"))
844        }
845
846        fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
847            Ok(format!("https://github.com/test/compare/{base}...{head}"))
848        }
849
850        fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
851            Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
852        }
853
854        fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
855            self.deleted_releases.lock().unwrap().push(tag.to_string());
856            self.releases.lock().unwrap().retain(|(t, _)| t != tag);
857            Ok(())
858        }
859
860        fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
861            self.uploaded_assets.lock().unwrap().push((
862                tag.to_string(),
863                files.iter().map(|s| s.to_string()).collect(),
864            ));
865            Ok(())
866        }
867
868        fn repo_url(&self) -> Option<String> {
869            Some("https://github.com/test/repo".into())
870        }
871    }
872
873    // --- Helpers ---
874
875    fn raw_commit(msg: &str) -> Commit {
876        Commit {
877            sha: "a".repeat(40),
878            message: msg.into(),
879        }
880    }
881
882    fn make_strategy(
883        tags: Vec<TagInfo>,
884        commits: Vec<Commit>,
885        config: ReleaseConfig,
886    ) -> TrunkReleaseStrategy<FakeGit, FakeVcs, DefaultCommitParser, DefaultChangelogFormatter>
887    {
888        let types = config.types.clone();
889        let breaking_section = config.breaking_section.clone();
890        let misc_section = config.misc_section.clone();
891        TrunkReleaseStrategy {
892            git: FakeGit::new(tags, commits),
893            vcs: Some(FakeVcs::new()),
894            parser: DefaultCommitParser,
895            formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
896            config,
897            force: false,
898        }
899    }
900
901    // --- plan() tests ---
902
903    #[test]
904    fn plan_no_commits_returns_error() {
905        let s = make_strategy(vec![], vec![], ReleaseConfig::default());
906        let err = s.plan().unwrap_err();
907        assert!(matches!(err, ReleaseError::NoCommits { .. }));
908    }
909
910    #[test]
911    fn plan_no_releasable_returns_error() {
912        let s = make_strategy(
913            vec![],
914            vec![raw_commit("chore: tidy up")],
915            ReleaseConfig::default(),
916        );
917        let err = s.plan().unwrap_err();
918        assert!(matches!(err, ReleaseError::NoBump { .. }));
919    }
920
921    #[test]
922    fn plan_first_release() {
923        let s = make_strategy(
924            vec![],
925            vec![raw_commit("feat: initial feature")],
926            ReleaseConfig::default(),
927        );
928        let plan = s.plan().unwrap();
929        assert_eq!(plan.next_version, Version::new(0, 1, 0));
930        assert_eq!(plan.tag_name, "v0.1.0");
931        assert!(plan.current_version.is_none());
932    }
933
934    #[test]
935    fn plan_increments_existing() {
936        let tag = TagInfo {
937            name: "v1.2.3".into(),
938            version: Version::new(1, 2, 3),
939            sha: "b".repeat(40),
940        };
941        let s = make_strategy(
942            vec![tag],
943            vec![raw_commit("fix: patch bug")],
944            ReleaseConfig::default(),
945        );
946        let plan = s.plan().unwrap();
947        assert_eq!(plan.next_version, Version::new(1, 2, 4));
948    }
949
950    #[test]
951    fn plan_breaking_bump() {
952        let tag = TagInfo {
953            name: "v1.2.3".into(),
954            version: Version::new(1, 2, 3),
955            sha: "c".repeat(40),
956        };
957        let s = make_strategy(
958            vec![tag],
959            vec![raw_commit("feat!: breaking change")],
960            ReleaseConfig::default(),
961        );
962        let plan = s.plan().unwrap();
963        assert_eq!(plan.next_version, Version::new(2, 0, 0));
964    }
965
966    // --- execute() tests ---
967
968    #[test]
969    fn execute_dry_run_no_side_effects() {
970        let s = make_strategy(
971            vec![],
972            vec![raw_commit("feat: something")],
973            ReleaseConfig::default(),
974        );
975        let plan = s.plan().unwrap();
976        s.execute(&plan, true).unwrap();
977
978        assert!(s.git.created_tags.lock().unwrap().is_empty());
979        assert!(s.git.pushed_tags.lock().unwrap().is_empty());
980    }
981
982    #[test]
983    fn execute_creates_and_pushes_tag() {
984        let s = make_strategy(
985            vec![],
986            vec![raw_commit("feat: something")],
987            ReleaseConfig::default(),
988        );
989        let plan = s.plan().unwrap();
990        s.execute(&plan, false).unwrap();
991
992        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
993        assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
994    }
995
996    #[test]
997    fn execute_calls_vcs_create_release() {
998        let s = make_strategy(
999            vec![],
1000            vec![raw_commit("feat: something")],
1001            ReleaseConfig::default(),
1002        );
1003        let plan = s.plan().unwrap();
1004        s.execute(&plan, false).unwrap();
1005
1006        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1007        assert_eq!(releases.len(), 1);
1008        assert_eq!(releases[0].0, "v0.1.0");
1009        assert!(!releases[0].1.is_empty());
1010    }
1011
1012    #[test]
1013    fn execute_commits_changelog_before_tag() {
1014        let dir = tempfile::tempdir().unwrap();
1015        let changelog_path = dir.path().join("CHANGELOG.md");
1016
1017        let mut config = ReleaseConfig::default();
1018        config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1019
1020        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1021        let plan = s.plan().unwrap();
1022        s.execute(&plan, false).unwrap();
1023
1024        // Verify changelog was committed
1025        let committed = s.git.committed.lock().unwrap();
1026        assert_eq!(committed.len(), 1);
1027        assert_eq!(
1028            committed[0].0,
1029            vec![changelog_path.to_str().unwrap().to_string()]
1030        );
1031        assert!(committed[0].1.contains("chore(release): v0.1.0"));
1032
1033        // Verify tag was created after commit
1034        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1035    }
1036
1037    #[test]
1038    fn execute_skips_existing_tag() {
1039        let s = make_strategy(
1040            vec![],
1041            vec![raw_commit("feat: something")],
1042            ReleaseConfig::default(),
1043        );
1044        let plan = s.plan().unwrap();
1045
1046        // Pre-populate the tag to simulate it already existing
1047        s.git
1048            .created_tags
1049            .lock()
1050            .unwrap()
1051            .push("v0.1.0".to_string());
1052
1053        s.execute(&plan, false).unwrap();
1054
1055        // Tag should not be created again (still only the one we pre-populated)
1056        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1057    }
1058
1059    #[test]
1060    fn execute_skips_existing_release() {
1061        let s = make_strategy(
1062            vec![],
1063            vec![raw_commit("feat: something")],
1064            ReleaseConfig::default(),
1065        );
1066        let plan = s.plan().unwrap();
1067
1068        // Pre-populate a release to simulate it already existing
1069        s.vcs
1070            .as_ref()
1071            .unwrap()
1072            .releases
1073            .lock()
1074            .unwrap()
1075            .push(("v0.1.0".to_string(), "old notes".to_string()));
1076
1077        s.execute(&plan, false).unwrap();
1078
1079        // Should have deleted the old release and created a new one
1080        let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
1081        assert_eq!(*deleted, vec!["v0.1.0"]);
1082
1083        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1084        assert_eq!(releases.len(), 1);
1085        assert_eq!(releases[0].0, "v0.1.0");
1086        assert_ne!(releases[0].1, "old notes");
1087    }
1088
1089    #[test]
1090    fn execute_idempotent_rerun() {
1091        let s = make_strategy(
1092            vec![],
1093            vec![raw_commit("feat: something")],
1094            ReleaseConfig::default(),
1095        );
1096        let plan = s.plan().unwrap();
1097
1098        // First run
1099        s.execute(&plan, false).unwrap();
1100
1101        // Second run should also succeed (idempotent)
1102        s.execute(&plan, false).unwrap();
1103
1104        // Tag should only have been created once (second run skips because tag_exists)
1105        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1106
1107        // Tag push should only happen once (second run skips because remote_tag_exists)
1108        assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1109
1110        // Push (commit) should happen twice (always safe)
1111        assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1112
1113        // Release should be deleted and recreated on second run
1114        let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
1115        assert_eq!(*deleted, vec!["v0.1.0"]);
1116
1117        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1118        // One entry: delete removed the first, create added a replacement
1119        assert_eq!(releases.len(), 1);
1120        assert_eq!(releases[0].0, "v0.1.0");
1121    }
1122
1123    #[test]
1124    fn execute_bumps_version_files() {
1125        let dir = tempfile::tempdir().unwrap();
1126        let cargo_path = dir.path().join("Cargo.toml");
1127        std::fs::write(
1128            &cargo_path,
1129            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1130        )
1131        .unwrap();
1132
1133        let mut config = ReleaseConfig::default();
1134        config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1135
1136        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1137        let plan = s.plan().unwrap();
1138        s.execute(&plan, false).unwrap();
1139
1140        // Verify the file was bumped
1141        let contents = std::fs::read_to_string(&cargo_path).unwrap();
1142        assert!(contents.contains("version = \"0.1.0\""));
1143
1144        // Verify it was staged alongside the commit
1145        let committed = s.git.committed.lock().unwrap();
1146        assert_eq!(committed.len(), 1);
1147        assert!(
1148            committed[0]
1149                .0
1150                .contains(&cargo_path.to_str().unwrap().to_string())
1151        );
1152    }
1153
1154    #[test]
1155    fn execute_stages_changelog_and_version_files_together() {
1156        let dir = tempfile::tempdir().unwrap();
1157        let cargo_path = dir.path().join("Cargo.toml");
1158        std::fs::write(
1159            &cargo_path,
1160            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1161        )
1162        .unwrap();
1163
1164        let changelog_path = dir.path().join("CHANGELOG.md");
1165
1166        let mut config = ReleaseConfig::default();
1167        config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1168        config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1169
1170        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1171        let plan = s.plan().unwrap();
1172        s.execute(&plan, false).unwrap();
1173
1174        // Both changelog and version file should be staged in a single commit
1175        let committed = s.git.committed.lock().unwrap();
1176        assert_eq!(committed.len(), 1);
1177        assert!(
1178            committed[0]
1179                .0
1180                .contains(&changelog_path.to_str().unwrap().to_string())
1181        );
1182        assert!(
1183            committed[0]
1184                .0
1185                .contains(&cargo_path.to_str().unwrap().to_string())
1186        );
1187    }
1188
1189    // --- artifact upload tests ---
1190
1191    #[test]
1192    fn execute_uploads_artifacts() {
1193        let dir = tempfile::tempdir().unwrap();
1194        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1195        std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1196
1197        let mut config = ReleaseConfig::default();
1198        config.artifacts = vec![
1199            dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1200            dir.path().join("*.zip").to_str().unwrap().to_string(),
1201        ];
1202
1203        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1204        let plan = s.plan().unwrap();
1205        s.execute(&plan, false).unwrap();
1206
1207        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1208        assert_eq!(uploaded.len(), 1);
1209        assert_eq!(uploaded[0].0, "v0.1.0");
1210        // 2 artifacts + 2 SHA256 checksum sidecar files
1211        assert_eq!(uploaded[0].1.len(), 4);
1212        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1213        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1214        assert!(
1215            uploaded[0]
1216                .1
1217                .iter()
1218                .any(|f| f.ends_with("app.tar.gz.sha256"))
1219        );
1220        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip.sha256")));
1221    }
1222
1223    #[test]
1224    fn execute_dry_run_shows_artifacts() {
1225        let dir = tempfile::tempdir().unwrap();
1226        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1227
1228        let mut config = ReleaseConfig::default();
1229        config.artifacts = vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()];
1230
1231        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1232        let plan = s.plan().unwrap();
1233        s.execute(&plan, true).unwrap();
1234
1235        // No uploads should happen during dry-run
1236        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1237        assert!(uploaded.is_empty());
1238    }
1239
1240    #[test]
1241    fn execute_no_artifacts_skips_upload() {
1242        let s = make_strategy(
1243            vec![],
1244            vec![raw_commit("feat: something")],
1245            ReleaseConfig::default(),
1246        );
1247        let plan = s.plan().unwrap();
1248        s.execute(&plan, false).unwrap();
1249
1250        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1251        assert!(uploaded.is_empty());
1252    }
1253
1254    #[test]
1255    fn resolve_artifact_globs_basic() {
1256        let dir = tempfile::tempdir().unwrap();
1257        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1258        std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1259        std::fs::create_dir(dir.path().join("subdir")).unwrap();
1260
1261        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1262        let result = resolve_artifact_globs(&[pattern]).unwrap();
1263        assert_eq!(result.len(), 2);
1264        assert!(result.iter().any(|f| f.ends_with("a.txt")));
1265        assert!(result.iter().any(|f| f.ends_with("b.txt")));
1266    }
1267
1268    #[test]
1269    fn resolve_artifact_globs_deduplicates() {
1270        let dir = tempfile::tempdir().unwrap();
1271        std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1272
1273        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1274        // Same pattern twice should not produce duplicates
1275        let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
1276        assert_eq!(result.len(), 1);
1277    }
1278
1279    // --- floating tags tests ---
1280
1281    #[test]
1282    fn plan_floating_tag_when_enabled() {
1283        let tag = TagInfo {
1284            name: "v3.2.0".into(),
1285            version: Version::new(3, 2, 0),
1286            sha: "d".repeat(40),
1287        };
1288        let mut config = ReleaseConfig::default();
1289        config.floating_tags = true;
1290
1291        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1292        let plan = s.plan().unwrap();
1293        assert_eq!(plan.next_version, Version::new(3, 2, 1));
1294        assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1295    }
1296
1297    #[test]
1298    fn plan_no_floating_tag_when_disabled() {
1299        let s = make_strategy(
1300            vec![],
1301            vec![raw_commit("feat: something")],
1302            ReleaseConfig::default(),
1303        );
1304        let plan = s.plan().unwrap();
1305        assert!(plan.floating_tag_name.is_none());
1306    }
1307
1308    #[test]
1309    fn plan_floating_tag_custom_prefix() {
1310        let tag = TagInfo {
1311            name: "release-2.5.0".into(),
1312            version: Version::new(2, 5, 0),
1313            sha: "e".repeat(40),
1314        };
1315        let mut config = ReleaseConfig::default();
1316        config.floating_tags = true;
1317        config.tag_prefix = "release-".into();
1318
1319        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1320        let plan = s.plan().unwrap();
1321        assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1322    }
1323
1324    #[test]
1325    fn execute_floating_tags_force_create_and_push() {
1326        let mut config = ReleaseConfig::default();
1327        config.floating_tags = true;
1328
1329        let tag = TagInfo {
1330            name: "v1.2.3".into(),
1331            version: Version::new(1, 2, 3),
1332            sha: "f".repeat(40),
1333        };
1334        let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1335        let plan = s.plan().unwrap();
1336        assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1337
1338        s.execute(&plan, false).unwrap();
1339
1340        assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1341        assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1342    }
1343
1344    #[test]
1345    fn execute_no_floating_tags_when_disabled() {
1346        let s = make_strategy(
1347            vec![],
1348            vec![raw_commit("feat: something")],
1349            ReleaseConfig::default(),
1350        );
1351        let plan = s.plan().unwrap();
1352        assert!(plan.floating_tag_name.is_none());
1353
1354        s.execute(&plan, false).unwrap();
1355
1356        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1357        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1358    }
1359
1360    #[test]
1361    fn execute_floating_tags_dry_run_no_side_effects() {
1362        let mut config = ReleaseConfig::default();
1363        config.floating_tags = true;
1364
1365        let tag = TagInfo {
1366            name: "v2.0.0".into(),
1367            version: Version::new(2, 0, 0),
1368            sha: "a".repeat(40),
1369        };
1370        let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1371        let plan = s.plan().unwrap();
1372        assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1373
1374        s.execute(&plan, true).unwrap();
1375
1376        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1377        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1378    }
1379
1380    #[test]
1381    fn execute_floating_tags_idempotent() {
1382        let mut config = ReleaseConfig::default();
1383        config.floating_tags = true;
1384
1385        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1386        let plan = s.plan().unwrap();
1387        assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1388
1389        // Run twice
1390        s.execute(&plan, false).unwrap();
1391        s.execute(&plan, false).unwrap();
1392
1393        // Force ops run every time (correct for floating tags)
1394        assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1395        assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1396    }
1397
1398    // --- force mode tests ---
1399
1400    #[test]
1401    fn force_rerelease_when_tag_at_head() {
1402        let tag = TagInfo {
1403            name: "v1.2.3".into(),
1404            version: Version::new(1, 2, 3),
1405            sha: "a".repeat(40),
1406        };
1407        let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1408        // HEAD == tag SHA, and no new commits
1409        s.git.head = "a".repeat(40);
1410        s.force = true;
1411
1412        let plan = s.plan().unwrap();
1413        assert_eq!(plan.next_version, Version::new(1, 2, 3));
1414        assert_eq!(plan.tag_name, "v1.2.3");
1415        assert!(plan.commits.is_empty());
1416        assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1417    }
1418
1419    #[test]
1420    fn force_fails_when_tag_not_at_head() {
1421        let tag = TagInfo {
1422            name: "v1.2.3".into(),
1423            version: Version::new(1, 2, 3),
1424            sha: "a".repeat(40),
1425        };
1426        let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1427        // HEAD != tag SHA
1428        s.git.head = "b".repeat(40);
1429        s.force = true;
1430
1431        let err = s.plan().unwrap_err();
1432        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1433    }
1434
1435    // --- build_command tests ---
1436
1437    #[test]
1438    fn execute_runs_build_command_after_version_bump() {
1439        let dir = tempfile::tempdir().unwrap();
1440        let output_file = dir.path().join("sr_test_version");
1441
1442        let mut config = ReleaseConfig::default();
1443        config.build_command = Some(format!(
1444            "echo $SR_VERSION > {}",
1445            output_file.to_str().unwrap()
1446        ));
1447
1448        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1449        let plan = s.plan().unwrap();
1450        s.execute(&plan, false).unwrap();
1451
1452        let contents = std::fs::read_to_string(&output_file).unwrap();
1453        assert_eq!(contents.trim(), "0.1.0");
1454    }
1455
1456    #[test]
1457    fn execute_build_command_failure_aborts_release() {
1458        let mut config = ReleaseConfig::default();
1459        config.build_command = Some("exit 1".into());
1460
1461        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1462        let plan = s.plan().unwrap();
1463        let result = s.execute(&plan, false);
1464
1465        assert!(result.is_err());
1466        assert!(s.git.created_tags.lock().unwrap().is_empty());
1467    }
1468
1469    #[test]
1470    fn execute_dry_run_skips_build_command() {
1471        let dir = tempfile::tempdir().unwrap();
1472        let output_file = dir.path().join("sr_test_should_not_exist");
1473
1474        let mut config = ReleaseConfig::default();
1475        config.build_command = Some(format!("echo test > {}", output_file.to_str().unwrap()));
1476
1477        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1478        let plan = s.plan().unwrap();
1479        s.execute(&plan, true).unwrap();
1480
1481        assert!(!output_file.exists());
1482    }
1483
1484    #[test]
1485    fn force_fails_with_no_tags() {
1486        let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1487        s.force = true;
1488
1489        let err = s.plan().unwrap_err();
1490        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1491    }
1492
1493    // --- stage_files tests ---
1494
1495    #[test]
1496    fn execute_stages_extra_files() {
1497        let dir = tempfile::tempdir().unwrap();
1498        let lock_file = dir.path().join("Cargo.lock");
1499        std::fs::write(&lock_file, "old lock").unwrap();
1500
1501        let mut config = ReleaseConfig::default();
1502        config.build_command = Some(format!("echo 'new lock' > {}", lock_file.to_str().unwrap()));
1503        config.stage_files = vec![lock_file.to_str().unwrap().to_string()];
1504
1505        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1506        let plan = s.plan().unwrap();
1507        s.execute(&plan, false).unwrap();
1508
1509        let committed = s.git.committed.lock().unwrap();
1510        assert!(!committed.is_empty());
1511        let (staged, _) = &committed[0];
1512        assert!(
1513            staged.iter().any(|f| f.contains("Cargo.lock")),
1514            "Cargo.lock should be staged, got: {staged:?}"
1515        );
1516    }
1517
1518    #[test]
1519    fn execute_dry_run_shows_stage_files() {
1520        let mut config = ReleaseConfig::default();
1521        config.stage_files = vec!["Cargo.lock".into()];
1522
1523        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1524        let plan = s.plan().unwrap();
1525        // dry-run should not error
1526        s.execute(&plan, true).unwrap();
1527    }
1528
1529    // --- rollback tests ---
1530
1531    #[test]
1532    fn execute_build_failure_restores_version_files() {
1533        let dir = tempfile::tempdir().unwrap();
1534        let cargo_toml = dir.path().join("Cargo.toml");
1535        std::fs::write(
1536            &cargo_toml,
1537            "[package]\nname = \"test\"\nversion = \"1.0.0\"\n",
1538        )
1539        .unwrap();
1540
1541        let mut config = ReleaseConfig::default();
1542        config.version_files = vec![cargo_toml.to_str().unwrap().to_string()];
1543        config.build_command = Some("exit 1".into());
1544
1545        let tag = TagInfo {
1546            name: "v1.0.0".into(),
1547            version: Version::new(1, 0, 0),
1548            sha: "d".repeat(40),
1549        };
1550        let s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
1551        let plan = s.plan().unwrap();
1552        let result = s.execute(&plan, false);
1553
1554        assert!(result.is_err());
1555        // Version file should be restored to original contents
1556        let contents = std::fs::read_to_string(&cargo_toml).unwrap();
1557        assert!(
1558            contents.contains("version = \"1.0.0\""),
1559            "version should be restored, got: {contents}"
1560        );
1561    }
1562
1563    // --- pre/post release hook tests ---
1564
1565    #[test]
1566    fn execute_pre_release_command_runs() {
1567        let dir = tempfile::tempdir().unwrap();
1568        let marker = dir.path().join("pre_release_ran");
1569
1570        let mut config = ReleaseConfig::default();
1571        config.pre_release_command = Some(format!("touch {}", marker.to_str().unwrap()));
1572
1573        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1574        let plan = s.plan().unwrap();
1575        s.execute(&plan, false).unwrap();
1576
1577        assert!(marker.exists(), "pre-release command should have run");
1578    }
1579
1580    #[test]
1581    fn execute_post_release_command_runs() {
1582        let dir = tempfile::tempdir().unwrap();
1583        let marker = dir.path().join("post_release_ran");
1584
1585        let mut config = ReleaseConfig::default();
1586        config.post_release_command = Some(format!("touch {}", marker.to_str().unwrap()));
1587
1588        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1589        let plan = s.plan().unwrap();
1590        s.execute(&plan, false).unwrap();
1591
1592        assert!(marker.exists(), "post-release command should have run");
1593    }
1594
1595    #[test]
1596    fn execute_pre_release_failure_aborts_release() {
1597        let mut config = ReleaseConfig::default();
1598        config.pre_release_command = Some("exit 1".into());
1599
1600        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1601        let plan = s.plan().unwrap();
1602        let result = s.execute(&plan, false);
1603
1604        assert!(result.is_err());
1605        // Nothing should have been committed or tagged
1606        assert!(s.git.created_tags.lock().unwrap().is_empty());
1607        assert!(s.git.committed.lock().unwrap().is_empty());
1608    }
1609
1610    #[test]
1611    fn execute_hooks_receive_version_env_vars() {
1612        let dir = tempfile::tempdir().unwrap();
1613        let output_file = dir.path().join("hook_output");
1614
1615        let mut config = ReleaseConfig::default();
1616        config.post_release_command = Some(format!(
1617            "echo $SR_VERSION $SR_TAG > {}",
1618            output_file.to_str().unwrap()
1619        ));
1620
1621        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1622        let plan = s.plan().unwrap();
1623        s.execute(&plan, false).unwrap();
1624
1625        let contents = std::fs::read_to_string(&output_file).unwrap();
1626        assert!(contents.contains("0.1.0"), "SR_VERSION should be set");
1627        assert!(contents.contains("v0.1.0"), "SR_TAG should be set");
1628    }
1629
1630    #[test]
1631    fn execute_dry_run_skips_hooks() {
1632        let dir = tempfile::tempdir().unwrap();
1633        let pre_marker = dir.path().join("pre_hook");
1634        let post_marker = dir.path().join("post_hook");
1635
1636        let mut config = ReleaseConfig::default();
1637        config.pre_release_command = Some(format!("touch {}", pre_marker.to_str().unwrap()));
1638        config.post_release_command = Some(format!("touch {}", post_marker.to_str().unwrap()));
1639
1640        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1641        let plan = s.plan().unwrap();
1642        s.execute(&plan, true).unwrap();
1643
1644        assert!(
1645            !pre_marker.exists(),
1646            "pre-release hook should not run in dry-run"
1647        );
1648        assert!(
1649            !post_marker.exists(),
1650            "post-release hook should not run in dry-run"
1651        );
1652    }
1653
1654    // --- pre-release tests ---
1655
1656    #[test]
1657    fn plan_prerelease_first_release() {
1658        let mut config = ReleaseConfig::default();
1659        config.prerelease = Some("alpha".into());
1660
1661        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1662        let plan = s.plan().unwrap();
1663        assert_eq!(plan.next_version.to_string(), "0.1.0-alpha.1");
1664        assert_eq!(plan.tag_name, "v0.1.0-alpha.1");
1665        assert!(plan.prerelease);
1666    }
1667
1668    #[test]
1669    fn plan_prerelease_increments_from_stable() {
1670        let tag = TagInfo {
1671            name: "v1.0.0".into(),
1672            version: Version::new(1, 0, 0),
1673            sha: "d".repeat(40),
1674        };
1675        let mut config = ReleaseConfig::default();
1676        config.prerelease = Some("beta".into());
1677
1678        let s = make_strategy(vec![tag], vec![raw_commit("feat: new feature")], config);
1679        let plan = s.plan().unwrap();
1680        assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1681        assert!(plan.prerelease);
1682    }
1683
1684    #[test]
1685    fn plan_prerelease_increments_counter() {
1686        let tags = vec![
1687            TagInfo {
1688                name: "v1.0.0".into(),
1689                version: Version::new(1, 0, 0),
1690                sha: "a".repeat(40),
1691            },
1692            TagInfo {
1693                name: "v1.1.0-alpha.1".into(),
1694                version: Version::parse("1.1.0-alpha.1").unwrap(),
1695                sha: "b".repeat(40),
1696            },
1697            TagInfo {
1698                name: "v1.1.0-alpha.2".into(),
1699                version: Version::parse("1.1.0-alpha.2").unwrap(),
1700                sha: "c".repeat(40),
1701            },
1702        ];
1703        let mut config = ReleaseConfig::default();
1704        config.prerelease = Some("alpha".into());
1705
1706        let s = make_strategy(tags, vec![raw_commit("feat: another")], config);
1707        let plan = s.plan().unwrap();
1708        assert_eq!(plan.next_version.to_string(), "1.1.0-alpha.3");
1709    }
1710
1711    #[test]
1712    fn plan_prerelease_different_id_starts_at_1() {
1713        let tags = vec![
1714            TagInfo {
1715                name: "v1.0.0".into(),
1716                version: Version::new(1, 0, 0),
1717                sha: "a".repeat(40),
1718            },
1719            TagInfo {
1720                name: "v1.1.0-alpha.3".into(),
1721                version: Version::parse("1.1.0-alpha.3").unwrap(),
1722                sha: "b".repeat(40),
1723            },
1724        ];
1725        let mut config = ReleaseConfig::default();
1726        config.prerelease = Some("beta".into());
1727
1728        let s = make_strategy(tags, vec![raw_commit("feat: something")], config);
1729        let plan = s.plan().unwrap();
1730        assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1731    }
1732
1733    #[test]
1734    fn plan_prerelease_no_floating_tags() {
1735        let mut config = ReleaseConfig::default();
1736        config.prerelease = Some("rc".into());
1737        config.floating_tags = true;
1738
1739        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1740        let plan = s.plan().unwrap();
1741        assert!(
1742            plan.floating_tag_name.is_none(),
1743            "pre-releases should not create floating tags"
1744        );
1745    }
1746
1747    #[test]
1748    fn plan_stable_skips_prerelease_tags() {
1749        let tags = vec![
1750            TagInfo {
1751                name: "v1.0.0".into(),
1752                version: Version::new(1, 0, 0),
1753                sha: "a".repeat(40),
1754            },
1755            TagInfo {
1756                name: "v1.1.0-alpha.1".into(),
1757                version: Version::parse("1.1.0-alpha.1").unwrap(),
1758                sha: "b".repeat(40),
1759            },
1760        ];
1761        // No prerelease config — stable release
1762        let s = make_strategy(
1763            tags,
1764            vec![raw_commit("feat: something")],
1765            ReleaseConfig::default(),
1766        );
1767        let plan = s.plan().unwrap();
1768        // Should base on v1.0.0, not v1.1.0-alpha.1
1769        assert_eq!(plan.next_version, Version::new(1, 1, 0));
1770        assert!(!plan.prerelease);
1771    }
1772
1773    #[test]
1774    fn plan_prerelease_marks_plan_as_prerelease() {
1775        let mut config = ReleaseConfig::default();
1776        config.prerelease = Some("alpha".into());
1777
1778        let s = make_strategy(vec![], vec![raw_commit("fix: bug")], config);
1779        let plan = s.plan().unwrap();
1780        assert!(plan.prerelease);
1781        assert!(plan.next_version.to_string().contains("alpha"));
1782    }
1783}