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