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, discover_lock_files, is_supported_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 = if let Some(ref path) = self.config.path_filter {
173            self.git.commits_since_in_path(from_sha, path)?
174        } else {
175            self.git.commits_since(from_sha)?
176        };
177        if raw_commits.is_empty() {
178            // Force mode: re-release if HEAD is exactly at the latest tag
179            if self.force
180                && let Some(info) = tag_info
181            {
182                let head = self.git.head_sha()?;
183                if head == info.sha {
184                    let floating_tag_name = if self.config.floating_tags {
185                        Some(format!("{}{}", self.config.tag_prefix, info.version.major))
186                    } else {
187                        None
188                    };
189                    return Ok(ReleasePlan {
190                        current_version: Some(info.version.clone()),
191                        next_version: info.version.clone(),
192                        bump: BumpLevel::Patch,
193                        commits: vec![],
194                        tag_name: info.name.clone(),
195                        floating_tag_name,
196                        prerelease: is_prerelease,
197                    });
198                }
199            }
200            let (tag, sha) = match tag_info {
201                Some(info) => (info.name.clone(), info.sha.clone()),
202                None => ("(none)".into(), "(none)".into()),
203            };
204            return Err(ReleaseError::NoCommits { tag, sha });
205        }
206
207        let conventional_commits: Vec<ConventionalCommit> = raw_commits
208            .iter()
209            .filter(|c| !c.message.starts_with("chore(release):"))
210            .filter_map(|c| self.parser.parse(c).ok())
211            .collect();
212
213        let classifier = DefaultCommitClassifier::new(
214            self.config.types.clone(),
215            self.config.commit_pattern.clone(),
216        );
217        let tag_for_err = tag_info
218            .map(|i| i.name.clone())
219            .unwrap_or_else(|| "(none)".into());
220        let commit_count = conventional_commits.len();
221        let bump = match determine_bump(&conventional_commits, &classifier) {
222            Some(b) => b,
223            None if self.force => BumpLevel::Patch,
224            None => {
225                return Err(ReleaseError::NoBump {
226                    tag: tag_for_err,
227                    commit_count,
228                });
229            }
230        };
231
232        // For pre-releases, base the version on the latest *stable* tag
233        let base_version = if is_prerelease {
234            latest_stable
235                .map(|t| t.version.clone())
236                .or(current_version.clone())
237                .unwrap_or(Version::new(0, 0, 0))
238        } else {
239            current_version.clone().unwrap_or(Version::new(0, 0, 0))
240        };
241
242        let next_version = if let Some(ref prerelease_id) = self.config.prerelease {
243            let existing_versions: Vec<Version> =
244                all_tags.iter().map(|t| t.version.clone()).collect();
245            apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
246        } else {
247            apply_bump(&base_version, bump)
248        };
249
250        let tag_name = format!("{}{next_version}", self.config.tag_prefix);
251
252        // Don't update floating tags for pre-releases
253        let floating_tag_name = if self.config.floating_tags && !is_prerelease {
254            Some(format!("{}{}", self.config.tag_prefix, next_version.major))
255        } else {
256            None
257        };
258
259        Ok(ReleasePlan {
260            current_version,
261            next_version,
262            bump,
263            commits: conventional_commits,
264            tag_name,
265            floating_tag_name,
266            prerelease: is_prerelease,
267        })
268    }
269
270    fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
271        let version_str = plan.next_version.to_string();
272
273        if dry_run {
274            let changelog_body = self.format_changelog(plan)?;
275            if let Some(ref cmd) = self.config.pre_release_command {
276                eprintln!("[dry-run] Would run pre-release command: {cmd}");
277            }
278            let sign_label = if self.config.sign_tags {
279                " (signed)"
280            } else {
281                ""
282            };
283            eprintln!("[dry-run] Would create tag: {}{sign_label}", plan.tag_name);
284            eprintln!("[dry-run] Would push tag: {}", plan.tag_name);
285            if let Some(ref floating) = plan.floating_tag_name {
286                eprintln!("[dry-run] Would create/update floating tag: {floating}");
287                eprintln!("[dry-run] Would force-push floating tag: {floating}");
288            }
289            if self.vcs.is_some() {
290                let draft_label = if self.config.draft { " (draft)" } else { "" };
291                let release_name = self.release_name(plan);
292                eprintln!(
293                    "[dry-run] Would create GitHub release \"{release_name}\" for {}{draft_label}",
294                    plan.tag_name
295                );
296            }
297            for file in &self.config.version_files {
298                let filename = Path::new(file)
299                    .file_name()
300                    .and_then(|n| n.to_str())
301                    .unwrap_or_default();
302                if is_supported_version_file(filename) {
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(extra) => {
518                    bumped_files.push(file.clone());
519                    for extra_path in extra {
520                        bumped_files.push(extra_path.to_string_lossy().into_owned());
521                    }
522                }
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        // 2.5. Auto-discover and stage lock files associated with bumped manifests
531        for lock_file in discover_lock_files(&bumped_files) {
532            let lock_str = lock_file.to_string_lossy().into_owned();
533            if !bumped_files.contains(&lock_str) {
534                bumped_files.push(lock_str);
535            }
536        }
537
538        // 3. Write changelog file if configured
539        if let Some(ref changelog_file) = self.config.changelog.file {
540            let path = Path::new(changelog_file);
541            let existing = if path.exists() {
542                fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
543            } else {
544                String::new()
545            };
546            let new_content = if existing.is_empty() {
547                format!("# Changelog\n\n{changelog_body}\n")
548            } else {
549                match existing.find("\n\n") {
550                    Some(pos) => {
551                        let (header, rest) = existing.split_at(pos);
552                        format!("{header}\n\n{changelog_body}\n{rest}")
553                    }
554                    None => format!("{existing}\n\n{changelog_body}\n"),
555                }
556            };
557            fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
558        }
559
560        // 3.5. Run build command if configured
561        if let Some(ref cmd) = self.config.build_command {
562            eprintln!("Running build command: {cmd}");
563            run_hook(cmd, version_str, &plan.tag_name, "build_command")?;
564        }
565
566        Ok(bumped_files)
567    }
568}
569
570/// Restore file contents from snapshots (best-effort, used during rollback).
571fn restore_snapshots(snapshots: &[(String, Option<String>)]) {
572    for (file, contents) in snapshots {
573        let path = Path::new(file);
574        match contents {
575            Some(data) => {
576                if let Err(e) = fs::write(path, data) {
577                    eprintln!("warning: failed to restore {file}: {e}");
578                }
579            }
580            None => {
581                // File didn't exist before — remove it
582                if path.exists()
583                    && let Err(e) = fs::remove_file(path)
584                {
585                    eprintln!("warning: failed to remove {file}: {e}");
586                }
587            }
588        }
589    }
590}
591
592/// Run a shell hook command with SR_VERSION and SR_TAG env vars.
593fn run_hook(cmd: &str, version: &str, tag: &str, label: &str) -> Result<(), ReleaseError> {
594    let status = std::process::Command::new("sh")
595        .args(["-c", cmd])
596        .env("SR_VERSION", version)
597        .env("SR_TAG", tag)
598        .status()
599        .map_err(|e| ReleaseError::BuildCommand(format!("{label}: {e}")))?;
600    if !status.success() {
601        return Err(ReleaseError::BuildCommand(format!(
602            "{label} exited with {}",
603            status.code().unwrap_or(-1)
604        )));
605    }
606    Ok(())
607}
608
609/// Resolve glob patterns into a list of file paths.
610fn resolve_glob_patterns(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
611    let mut files = Vec::new();
612    for pattern in patterns {
613        let paths = glob::glob(pattern)
614            .map_err(|e| ReleaseError::Config(format!("invalid glob pattern '{pattern}': {e}")))?;
615        for entry in paths {
616            match entry {
617                Ok(path) if path.is_file() => {
618                    files.push(path.to_string_lossy().into_owned());
619                }
620                Ok(_) => {}
621                Err(e) => {
622                    eprintln!("warning: glob error: {e}");
623                }
624            }
625        }
626    }
627    Ok(files)
628}
629
630fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
631    let mut files = std::collections::BTreeSet::new();
632    for pattern in patterns {
633        let paths = glob::glob(pattern)
634            .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
635        for entry in paths {
636            match entry {
637                Ok(path) if path.is_file() => {
638                    files.insert(path.to_string_lossy().into_owned());
639                }
640                Ok(_) => {} // skip directories
641                Err(e) => {
642                    eprintln!("warning: glob error: {e}");
643                }
644            }
645        }
646    }
647    Ok(files.into_iter().collect())
648}
649
650/// Generate SHA256 checksum sidecar files for a list of artifact paths.
651/// Returns the paths to the generated `.sha256` files.
652fn generate_checksums(files: &[String]) -> Result<Vec<String>, ReleaseError> {
653    use sha2::{Digest, Sha256};
654
655    let mut checksum_paths = Vec::new();
656    for file_path in files {
657        let data = fs::read(file_path).map_err(|e| {
658            ReleaseError::Vcs(format!("failed to read {file_path} for checksum: {e}"))
659        })?;
660        let hash = Sha256::digest(&data);
661        let hex = format!("{hash:x}");
662        let file_name = Path::new(file_path)
663            .file_name()
664            .and_then(|n| n.to_str())
665            .unwrap_or("unknown");
666        let checksum_content = format!("{hex}  {file_name}\n");
667        let checksum_path = format!("{file_path}.sha256");
668        fs::write(&checksum_path, checksum_content)
669            .map_err(|e| ReleaseError::Vcs(format!("failed to write checksum file: {e}")))?;
670        checksum_paths.push(checksum_path);
671    }
672    Ok(checksum_paths)
673}
674
675pub fn today_string() -> String {
676    // Portable date calculation from UNIX epoch (no external deps or subprocess).
677    // Uses Howard Hinnant's civil_from_days algorithm.
678    let secs = std::time::SystemTime::now()
679        .duration_since(std::time::UNIX_EPOCH)
680        .unwrap_or_default()
681        .as_secs() as i64;
682
683    let z = secs / 86400 + 719468;
684    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
685    let doe = (z - era * 146097) as u32;
686    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
687    let y = yoe as i64 + era * 400;
688    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
689    let mp = (5 * doy + 2) / 153;
690    let d = doy - (153 * mp + 2) / 5 + 1;
691    let m = if mp < 10 { mp + 3 } else { mp - 9 };
692    let y = if m <= 2 { y + 1 } else { y };
693
694    format!("{y:04}-{m:02}-{d:02}")
695}
696
697#[cfg(test)]
698mod tests {
699    use std::sync::Mutex;
700
701    use super::*;
702    use crate::changelog::DefaultChangelogFormatter;
703    use crate::commit::{Commit, DefaultCommitParser};
704    use crate::config::ReleaseConfig;
705    use crate::git::{GitRepository, TagInfo};
706
707    // --- Fakes ---
708
709    struct FakeGit {
710        tags: Vec<TagInfo>,
711        commits: Vec<Commit>,
712        /// Commits returned when path filtering is active (None = fall back to `commits`).
713        path_commits: Option<Vec<Commit>>,
714        head: String,
715        created_tags: Mutex<Vec<String>>,
716        pushed_tags: Mutex<Vec<String>>,
717        committed: Mutex<Vec<(Vec<String>, String)>>,
718        push_count: Mutex<u32>,
719        force_created_tags: Mutex<Vec<String>>,
720        force_pushed_tags: Mutex<Vec<String>>,
721    }
722
723    impl FakeGit {
724        fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
725            let head = tags
726                .last()
727                .map(|t| t.sha.clone())
728                .unwrap_or_else(|| "0".repeat(40));
729            Self {
730                tags,
731                commits,
732                path_commits: None,
733                head,
734                created_tags: Mutex::new(Vec::new()),
735                pushed_tags: Mutex::new(Vec::new()),
736                committed: Mutex::new(Vec::new()),
737                push_count: Mutex::new(0),
738                force_created_tags: Mutex::new(Vec::new()),
739                force_pushed_tags: Mutex::new(Vec::new()),
740            }
741        }
742    }
743
744    impl GitRepository for FakeGit {
745        fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
746            Ok(self.tags.last().cloned())
747        }
748
749        fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
750            Ok(self.commits.clone())
751        }
752
753        fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
754            self.created_tags.lock().unwrap().push(name.to_string());
755            Ok(())
756        }
757
758        fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
759            self.pushed_tags.lock().unwrap().push(name.to_string());
760            Ok(())
761        }
762
763        fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
764            self.committed.lock().unwrap().push((
765                paths.iter().map(|s| s.to_string()).collect(),
766                message.to_string(),
767            ));
768            Ok(true)
769        }
770
771        fn push(&self) -> Result<(), ReleaseError> {
772            *self.push_count.lock().unwrap() += 1;
773            Ok(())
774        }
775
776        fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
777            Ok(self
778                .created_tags
779                .lock()
780                .unwrap()
781                .contains(&name.to_string()))
782        }
783
784        fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
785            Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
786        }
787
788        fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
789            Ok(self.tags.clone())
790        }
791
792        fn commits_between(
793            &self,
794            _from: Option<&str>,
795            _to: &str,
796        ) -> Result<Vec<Commit>, ReleaseError> {
797            Ok(self.commits.clone())
798        }
799
800        fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
801            Ok("2026-01-01".into())
802        }
803
804        fn force_create_tag(
805            &self,
806            name: &str,
807            _message: &str,
808            _sign: bool,
809        ) -> Result<(), ReleaseError> {
810            self.force_created_tags
811                .lock()
812                .unwrap()
813                .push(name.to_string());
814            Ok(())
815        }
816
817        fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
818            self.force_pushed_tags
819                .lock()
820                .unwrap()
821                .push(name.to_string());
822            Ok(())
823        }
824
825        fn head_sha(&self) -> Result<String, ReleaseError> {
826            Ok(self.head.clone())
827        }
828
829        fn commits_since_in_path(
830            &self,
831            _from: Option<&str>,
832            _path: &str,
833        ) -> Result<Vec<Commit>, ReleaseError> {
834            Ok(self
835                .path_commits
836                .clone()
837                .unwrap_or_else(|| self.commits.clone()))
838        }
839    }
840
841    struct FakeVcs {
842        releases: Mutex<Vec<(String, String)>>,
843        deleted_releases: Mutex<Vec<String>>,
844        uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
845    }
846
847    impl FakeVcs {
848        fn new() -> Self {
849            Self {
850                releases: Mutex::new(Vec::new()),
851                deleted_releases: Mutex::new(Vec::new()),
852                uploaded_assets: Mutex::new(Vec::new()),
853            }
854        }
855    }
856
857    impl VcsProvider for FakeVcs {
858        fn create_release(
859            &self,
860            tag: &str,
861            _name: &str,
862            body: &str,
863            _prerelease: bool,
864            _draft: bool,
865        ) -> Result<String, ReleaseError> {
866            self.releases
867                .lock()
868                .unwrap()
869                .push((tag.to_string(), body.to_string()));
870            Ok(format!("https://github.com/test/release/{tag}"))
871        }
872
873        fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
874            Ok(format!("https://github.com/test/compare/{base}...{head}"))
875        }
876
877        fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
878            Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
879        }
880
881        fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
882            self.deleted_releases.lock().unwrap().push(tag.to_string());
883            self.releases.lock().unwrap().retain(|(t, _)| t != tag);
884            Ok(())
885        }
886
887        fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
888            self.uploaded_assets.lock().unwrap().push((
889                tag.to_string(),
890                files.iter().map(|s| s.to_string()).collect(),
891            ));
892            Ok(())
893        }
894
895        fn repo_url(&self) -> Option<String> {
896            Some("https://github.com/test/repo".into())
897        }
898    }
899
900    // --- Helpers ---
901
902    fn raw_commit(msg: &str) -> Commit {
903        Commit {
904            sha: "a".repeat(40),
905            message: msg.into(),
906        }
907    }
908
909    fn make_strategy(
910        tags: Vec<TagInfo>,
911        commits: Vec<Commit>,
912        config: ReleaseConfig,
913    ) -> TrunkReleaseStrategy<FakeGit, FakeVcs, DefaultCommitParser, DefaultChangelogFormatter>
914    {
915        let types = config.types.clone();
916        let breaking_section = config.breaking_section.clone();
917        let misc_section = config.misc_section.clone();
918        TrunkReleaseStrategy {
919            git: FakeGit::new(tags, commits),
920            vcs: Some(FakeVcs::new()),
921            parser: DefaultCommitParser,
922            formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
923            config,
924            force: false,
925        }
926    }
927
928    // --- plan() tests ---
929
930    #[test]
931    fn plan_no_commits_returns_error() {
932        let s = make_strategy(vec![], vec![], ReleaseConfig::default());
933        let err = s.plan().unwrap_err();
934        assert!(matches!(err, ReleaseError::NoCommits { .. }));
935    }
936
937    #[test]
938    fn plan_no_releasable_returns_error() {
939        let s = make_strategy(
940            vec![],
941            vec![raw_commit("chore: tidy up")],
942            ReleaseConfig::default(),
943        );
944        let err = s.plan().unwrap_err();
945        assert!(matches!(err, ReleaseError::NoBump { .. }));
946    }
947
948    #[test]
949    fn force_releases_patch_when_no_releasable_commits() {
950        let tag = TagInfo {
951            name: "v1.2.3".into(),
952            version: Version::new(1, 2, 3),
953            sha: "d".repeat(40),
954        };
955        let mut s = make_strategy(
956            vec![tag],
957            vec![raw_commit("chore: rename package")],
958            ReleaseConfig::default(),
959        );
960        s.force = true;
961        let plan = s.plan().unwrap();
962        assert_eq!(plan.next_version, Version::new(1, 2, 4));
963        assert_eq!(plan.bump, BumpLevel::Patch);
964    }
965
966    #[test]
967    fn plan_first_release() {
968        let s = make_strategy(
969            vec![],
970            vec![raw_commit("feat: initial feature")],
971            ReleaseConfig::default(),
972        );
973        let plan = s.plan().unwrap();
974        assert_eq!(plan.next_version, Version::new(0, 1, 0));
975        assert_eq!(plan.tag_name, "v0.1.0");
976        assert!(plan.current_version.is_none());
977    }
978
979    #[test]
980    fn plan_increments_existing() {
981        let tag = TagInfo {
982            name: "v1.2.3".into(),
983            version: Version::new(1, 2, 3),
984            sha: "b".repeat(40),
985        };
986        let s = make_strategy(
987            vec![tag],
988            vec![raw_commit("fix: patch bug")],
989            ReleaseConfig::default(),
990        );
991        let plan = s.plan().unwrap();
992        assert_eq!(plan.next_version, Version::new(1, 2, 4));
993    }
994
995    #[test]
996    fn plan_breaking_bump() {
997        let tag = TagInfo {
998            name: "v1.2.3".into(),
999            version: Version::new(1, 2, 3),
1000            sha: "c".repeat(40),
1001        };
1002        let s = make_strategy(
1003            vec![tag],
1004            vec![raw_commit("feat!: breaking change")],
1005            ReleaseConfig::default(),
1006        );
1007        let plan = s.plan().unwrap();
1008        assert_eq!(plan.next_version, Version::new(2, 0, 0));
1009    }
1010
1011    // --- execute() tests ---
1012
1013    #[test]
1014    fn execute_dry_run_no_side_effects() {
1015        let s = make_strategy(
1016            vec![],
1017            vec![raw_commit("feat: something")],
1018            ReleaseConfig::default(),
1019        );
1020        let plan = s.plan().unwrap();
1021        s.execute(&plan, true).unwrap();
1022
1023        assert!(s.git.created_tags.lock().unwrap().is_empty());
1024        assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1025    }
1026
1027    #[test]
1028    fn execute_creates_and_pushes_tag() {
1029        let s = make_strategy(
1030            vec![],
1031            vec![raw_commit("feat: something")],
1032            ReleaseConfig::default(),
1033        );
1034        let plan = s.plan().unwrap();
1035        s.execute(&plan, false).unwrap();
1036
1037        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1038        assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1039    }
1040
1041    #[test]
1042    fn execute_calls_vcs_create_release() {
1043        let s = make_strategy(
1044            vec![],
1045            vec![raw_commit("feat: something")],
1046            ReleaseConfig::default(),
1047        );
1048        let plan = s.plan().unwrap();
1049        s.execute(&plan, false).unwrap();
1050
1051        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1052        assert_eq!(releases.len(), 1);
1053        assert_eq!(releases[0].0, "v0.1.0");
1054        assert!(!releases[0].1.is_empty());
1055    }
1056
1057    #[test]
1058    fn execute_commits_changelog_before_tag() {
1059        let dir = tempfile::tempdir().unwrap();
1060        let changelog_path = dir.path().join("CHANGELOG.md");
1061
1062        let mut config = ReleaseConfig::default();
1063        config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1064
1065        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1066        let plan = s.plan().unwrap();
1067        s.execute(&plan, false).unwrap();
1068
1069        // Verify changelog was committed
1070        let committed = s.git.committed.lock().unwrap();
1071        assert_eq!(committed.len(), 1);
1072        assert_eq!(
1073            committed[0].0,
1074            vec![changelog_path.to_str().unwrap().to_string()]
1075        );
1076        assert!(committed[0].1.contains("chore(release): v0.1.0"));
1077
1078        // Verify tag was created after commit
1079        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1080    }
1081
1082    #[test]
1083    fn execute_skips_existing_tag() {
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 the tag to simulate it already existing
1092        s.git
1093            .created_tags
1094            .lock()
1095            .unwrap()
1096            .push("v0.1.0".to_string());
1097
1098        s.execute(&plan, false).unwrap();
1099
1100        // Tag should not be created again (still only the one we pre-populated)
1101        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1102    }
1103
1104    #[test]
1105    fn execute_skips_existing_release() {
1106        let s = make_strategy(
1107            vec![],
1108            vec![raw_commit("feat: something")],
1109            ReleaseConfig::default(),
1110        );
1111        let plan = s.plan().unwrap();
1112
1113        // Pre-populate a release to simulate it already existing
1114        s.vcs
1115            .as_ref()
1116            .unwrap()
1117            .releases
1118            .lock()
1119            .unwrap()
1120            .push(("v0.1.0".to_string(), "old notes".to_string()));
1121
1122        s.execute(&plan, false).unwrap();
1123
1124        // Should have deleted the old release and created a new one
1125        let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
1126        assert_eq!(*deleted, vec!["v0.1.0"]);
1127
1128        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1129        assert_eq!(releases.len(), 1);
1130        assert_eq!(releases[0].0, "v0.1.0");
1131        assert_ne!(releases[0].1, "old notes");
1132    }
1133
1134    #[test]
1135    fn execute_idempotent_rerun() {
1136        let s = make_strategy(
1137            vec![],
1138            vec![raw_commit("feat: something")],
1139            ReleaseConfig::default(),
1140        );
1141        let plan = s.plan().unwrap();
1142
1143        // First run
1144        s.execute(&plan, false).unwrap();
1145
1146        // Second run should also succeed (idempotent)
1147        s.execute(&plan, false).unwrap();
1148
1149        // Tag should only have been created once (second run skips because tag_exists)
1150        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1151
1152        // Tag push should only happen once (second run skips because remote_tag_exists)
1153        assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1154
1155        // Push (commit) should happen twice (always safe)
1156        assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1157
1158        // Release should be deleted and recreated on second run
1159        let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
1160        assert_eq!(*deleted, vec!["v0.1.0"]);
1161
1162        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
1163        // One entry: delete removed the first, create added a replacement
1164        assert_eq!(releases.len(), 1);
1165        assert_eq!(releases[0].0, "v0.1.0");
1166    }
1167
1168    #[test]
1169    fn execute_bumps_version_files() {
1170        let dir = tempfile::tempdir().unwrap();
1171        let cargo_path = dir.path().join("Cargo.toml");
1172        std::fs::write(
1173            &cargo_path,
1174            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1175        )
1176        .unwrap();
1177
1178        let mut config = ReleaseConfig::default();
1179        config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1180
1181        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1182        let plan = s.plan().unwrap();
1183        s.execute(&plan, false).unwrap();
1184
1185        // Verify the file was bumped
1186        let contents = std::fs::read_to_string(&cargo_path).unwrap();
1187        assert!(contents.contains("version = \"0.1.0\""));
1188
1189        // Verify it was staged alongside the commit
1190        let committed = s.git.committed.lock().unwrap();
1191        assert_eq!(committed.len(), 1);
1192        assert!(
1193            committed[0]
1194                .0
1195                .contains(&cargo_path.to_str().unwrap().to_string())
1196        );
1197    }
1198
1199    #[test]
1200    fn execute_stages_changelog_and_version_files_together() {
1201        let dir = tempfile::tempdir().unwrap();
1202        let cargo_path = dir.path().join("Cargo.toml");
1203        std::fs::write(
1204            &cargo_path,
1205            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1206        )
1207        .unwrap();
1208
1209        let changelog_path = dir.path().join("CHANGELOG.md");
1210
1211        let mut config = ReleaseConfig::default();
1212        config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1213        config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1214
1215        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1216        let plan = s.plan().unwrap();
1217        s.execute(&plan, false).unwrap();
1218
1219        // Both changelog and version file should be staged in a single commit
1220        let committed = s.git.committed.lock().unwrap();
1221        assert_eq!(committed.len(), 1);
1222        assert!(
1223            committed[0]
1224                .0
1225                .contains(&changelog_path.to_str().unwrap().to_string())
1226        );
1227        assert!(
1228            committed[0]
1229                .0
1230                .contains(&cargo_path.to_str().unwrap().to_string())
1231        );
1232    }
1233
1234    // --- artifact upload tests ---
1235
1236    #[test]
1237    fn execute_uploads_artifacts() {
1238        let dir = tempfile::tempdir().unwrap();
1239        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1240        std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1241
1242        let mut config = ReleaseConfig::default();
1243        config.artifacts = vec![
1244            dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1245            dir.path().join("*.zip").to_str().unwrap().to_string(),
1246        ];
1247
1248        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1249        let plan = s.plan().unwrap();
1250        s.execute(&plan, false).unwrap();
1251
1252        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1253        assert_eq!(uploaded.len(), 1);
1254        assert_eq!(uploaded[0].0, "v0.1.0");
1255        // 2 artifacts + 2 SHA256 checksum sidecar files
1256        assert_eq!(uploaded[0].1.len(), 4);
1257        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1258        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1259        assert!(
1260            uploaded[0]
1261                .1
1262                .iter()
1263                .any(|f| f.ends_with("app.tar.gz.sha256"))
1264        );
1265        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip.sha256")));
1266    }
1267
1268    #[test]
1269    fn execute_dry_run_shows_artifacts() {
1270        let dir = tempfile::tempdir().unwrap();
1271        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1272
1273        let mut config = ReleaseConfig::default();
1274        config.artifacts = vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()];
1275
1276        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1277        let plan = s.plan().unwrap();
1278        s.execute(&plan, true).unwrap();
1279
1280        // No uploads should happen during dry-run
1281        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1282        assert!(uploaded.is_empty());
1283    }
1284
1285    #[test]
1286    fn execute_no_artifacts_skips_upload() {
1287        let s = make_strategy(
1288            vec![],
1289            vec![raw_commit("feat: something")],
1290            ReleaseConfig::default(),
1291        );
1292        let plan = s.plan().unwrap();
1293        s.execute(&plan, false).unwrap();
1294
1295        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1296        assert!(uploaded.is_empty());
1297    }
1298
1299    #[test]
1300    fn resolve_artifact_globs_basic() {
1301        let dir = tempfile::tempdir().unwrap();
1302        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1303        std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1304        std::fs::create_dir(dir.path().join("subdir")).unwrap();
1305
1306        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1307        let result = resolve_artifact_globs(&[pattern]).unwrap();
1308        assert_eq!(result.len(), 2);
1309        assert!(result.iter().any(|f| f.ends_with("a.txt")));
1310        assert!(result.iter().any(|f| f.ends_with("b.txt")));
1311    }
1312
1313    #[test]
1314    fn resolve_artifact_globs_deduplicates() {
1315        let dir = tempfile::tempdir().unwrap();
1316        std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1317
1318        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1319        // Same pattern twice should not produce duplicates
1320        let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
1321        assert_eq!(result.len(), 1);
1322    }
1323
1324    // --- floating tags tests ---
1325
1326    #[test]
1327    fn plan_floating_tag_when_enabled() {
1328        let tag = TagInfo {
1329            name: "v3.2.0".into(),
1330            version: Version::new(3, 2, 0),
1331            sha: "d".repeat(40),
1332        };
1333        let mut config = ReleaseConfig::default();
1334        config.floating_tags = true;
1335
1336        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1337        let plan = s.plan().unwrap();
1338        assert_eq!(plan.next_version, Version::new(3, 2, 1));
1339        assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1340    }
1341
1342    #[test]
1343    fn plan_no_floating_tag_when_disabled() {
1344        let s = make_strategy(
1345            vec![],
1346            vec![raw_commit("feat: something")],
1347            ReleaseConfig::default(),
1348        );
1349        let plan = s.plan().unwrap();
1350        assert!(plan.floating_tag_name.is_none());
1351    }
1352
1353    #[test]
1354    fn plan_floating_tag_custom_prefix() {
1355        let tag = TagInfo {
1356            name: "release-2.5.0".into(),
1357            version: Version::new(2, 5, 0),
1358            sha: "e".repeat(40),
1359        };
1360        let mut config = ReleaseConfig::default();
1361        config.floating_tags = true;
1362        config.tag_prefix = "release-".into();
1363
1364        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1365        let plan = s.plan().unwrap();
1366        assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1367    }
1368
1369    #[test]
1370    fn execute_floating_tags_force_create_and_push() {
1371        let mut config = ReleaseConfig::default();
1372        config.floating_tags = true;
1373
1374        let tag = TagInfo {
1375            name: "v1.2.3".into(),
1376            version: Version::new(1, 2, 3),
1377            sha: "f".repeat(40),
1378        };
1379        let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1380        let plan = s.plan().unwrap();
1381        assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1382
1383        s.execute(&plan, false).unwrap();
1384
1385        assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1386        assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1387    }
1388
1389    #[test]
1390    fn execute_no_floating_tags_when_disabled() {
1391        let s = make_strategy(
1392            vec![],
1393            vec![raw_commit("feat: something")],
1394            ReleaseConfig::default(),
1395        );
1396        let plan = s.plan().unwrap();
1397        assert!(plan.floating_tag_name.is_none());
1398
1399        s.execute(&plan, false).unwrap();
1400
1401        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1402        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1403    }
1404
1405    #[test]
1406    fn execute_floating_tags_dry_run_no_side_effects() {
1407        let mut config = ReleaseConfig::default();
1408        config.floating_tags = true;
1409
1410        let tag = TagInfo {
1411            name: "v2.0.0".into(),
1412            version: Version::new(2, 0, 0),
1413            sha: "a".repeat(40),
1414        };
1415        let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1416        let plan = s.plan().unwrap();
1417        assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1418
1419        s.execute(&plan, true).unwrap();
1420
1421        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1422        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1423    }
1424
1425    #[test]
1426    fn execute_floating_tags_idempotent() {
1427        let mut config = ReleaseConfig::default();
1428        config.floating_tags = true;
1429
1430        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1431        let plan = s.plan().unwrap();
1432        assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1433
1434        // Run twice
1435        s.execute(&plan, false).unwrap();
1436        s.execute(&plan, false).unwrap();
1437
1438        // Force ops run every time (correct for floating tags)
1439        assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1440        assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1441    }
1442
1443    // --- force mode tests ---
1444
1445    #[test]
1446    fn force_rerelease_when_tag_at_head() {
1447        let tag = TagInfo {
1448            name: "v1.2.3".into(),
1449            version: Version::new(1, 2, 3),
1450            sha: "a".repeat(40),
1451        };
1452        let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1453        // HEAD == tag SHA, and no new commits
1454        s.git.head = "a".repeat(40);
1455        s.force = true;
1456
1457        let plan = s.plan().unwrap();
1458        assert_eq!(plan.next_version, Version::new(1, 2, 3));
1459        assert_eq!(plan.tag_name, "v1.2.3");
1460        assert!(plan.commits.is_empty());
1461        assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1462    }
1463
1464    #[test]
1465    fn force_fails_when_tag_not_at_head() {
1466        let tag = TagInfo {
1467            name: "v1.2.3".into(),
1468            version: Version::new(1, 2, 3),
1469            sha: "a".repeat(40),
1470        };
1471        let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1472        // HEAD != tag SHA
1473        s.git.head = "b".repeat(40);
1474        s.force = true;
1475
1476        let err = s.plan().unwrap_err();
1477        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1478    }
1479
1480    // --- build_command tests ---
1481
1482    #[test]
1483    fn execute_runs_build_command_after_version_bump() {
1484        let dir = tempfile::tempdir().unwrap();
1485        let output_file = dir.path().join("sr_test_version");
1486
1487        let mut config = ReleaseConfig::default();
1488        config.build_command = Some(format!(
1489            "echo $SR_VERSION > {}",
1490            output_file.to_str().unwrap()
1491        ));
1492
1493        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1494        let plan = s.plan().unwrap();
1495        s.execute(&plan, false).unwrap();
1496
1497        let contents = std::fs::read_to_string(&output_file).unwrap();
1498        assert_eq!(contents.trim(), "0.1.0");
1499    }
1500
1501    #[test]
1502    fn execute_build_command_failure_aborts_release() {
1503        let mut config = ReleaseConfig::default();
1504        config.build_command = Some("exit 1".into());
1505
1506        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1507        let plan = s.plan().unwrap();
1508        let result = s.execute(&plan, false);
1509
1510        assert!(result.is_err());
1511        assert!(s.git.created_tags.lock().unwrap().is_empty());
1512    }
1513
1514    #[test]
1515    fn execute_dry_run_skips_build_command() {
1516        let dir = tempfile::tempdir().unwrap();
1517        let output_file = dir.path().join("sr_test_should_not_exist");
1518
1519        let mut config = ReleaseConfig::default();
1520        config.build_command = Some(format!("echo test > {}", output_file.to_str().unwrap()));
1521
1522        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1523        let plan = s.plan().unwrap();
1524        s.execute(&plan, true).unwrap();
1525
1526        assert!(!output_file.exists());
1527    }
1528
1529    #[test]
1530    fn force_fails_with_no_tags() {
1531        let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1532        s.force = true;
1533
1534        let err = s.plan().unwrap_err();
1535        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1536    }
1537
1538    // --- stage_files tests ---
1539
1540    #[test]
1541    fn execute_stages_extra_files() {
1542        let dir = tempfile::tempdir().unwrap();
1543        let lock_file = dir.path().join("Cargo.lock");
1544        std::fs::write(&lock_file, "old lock").unwrap();
1545
1546        let mut config = ReleaseConfig::default();
1547        config.build_command = Some(format!("echo 'new lock' > {}", lock_file.to_str().unwrap()));
1548        config.stage_files = vec![lock_file.to_str().unwrap().to_string()];
1549
1550        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1551        let plan = s.plan().unwrap();
1552        s.execute(&plan, false).unwrap();
1553
1554        let committed = s.git.committed.lock().unwrap();
1555        assert!(!committed.is_empty());
1556        let (staged, _) = &committed[0];
1557        assert!(
1558            staged.iter().any(|f| f.contains("Cargo.lock")),
1559            "Cargo.lock should be staged, got: {staged:?}"
1560        );
1561    }
1562
1563    #[test]
1564    fn execute_dry_run_shows_stage_files() {
1565        let mut config = ReleaseConfig::default();
1566        config.stage_files = vec!["Cargo.lock".into()];
1567
1568        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1569        let plan = s.plan().unwrap();
1570        // dry-run should not error
1571        s.execute(&plan, true).unwrap();
1572    }
1573
1574    // --- rollback tests ---
1575
1576    #[test]
1577    fn execute_build_failure_restores_version_files() {
1578        let dir = tempfile::tempdir().unwrap();
1579        let cargo_toml = dir.path().join("Cargo.toml");
1580        std::fs::write(
1581            &cargo_toml,
1582            "[package]\nname = \"test\"\nversion = \"1.0.0\"\n",
1583        )
1584        .unwrap();
1585
1586        let mut config = ReleaseConfig::default();
1587        config.version_files = vec![cargo_toml.to_str().unwrap().to_string()];
1588        config.build_command = Some("exit 1".into());
1589
1590        let tag = TagInfo {
1591            name: "v1.0.0".into(),
1592            version: Version::new(1, 0, 0),
1593            sha: "d".repeat(40),
1594        };
1595        let s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
1596        let plan = s.plan().unwrap();
1597        let result = s.execute(&plan, false);
1598
1599        assert!(result.is_err());
1600        // Version file should be restored to original contents
1601        let contents = std::fs::read_to_string(&cargo_toml).unwrap();
1602        assert!(
1603            contents.contains("version = \"1.0.0\""),
1604            "version should be restored, got: {contents}"
1605        );
1606    }
1607
1608    // --- pre/post release hook tests ---
1609
1610    #[test]
1611    fn execute_pre_release_command_runs() {
1612        let dir = tempfile::tempdir().unwrap();
1613        let marker = dir.path().join("pre_release_ran");
1614
1615        let mut config = ReleaseConfig::default();
1616        config.pre_release_command = Some(format!("touch {}", marker.to_str().unwrap()));
1617
1618        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1619        let plan = s.plan().unwrap();
1620        s.execute(&plan, false).unwrap();
1621
1622        assert!(marker.exists(), "pre-release command should have run");
1623    }
1624
1625    #[test]
1626    fn execute_post_release_command_runs() {
1627        let dir = tempfile::tempdir().unwrap();
1628        let marker = dir.path().join("post_release_ran");
1629
1630        let mut config = ReleaseConfig::default();
1631        config.post_release_command = Some(format!("touch {}", marker.to_str().unwrap()));
1632
1633        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1634        let plan = s.plan().unwrap();
1635        s.execute(&plan, false).unwrap();
1636
1637        assert!(marker.exists(), "post-release command should have run");
1638    }
1639
1640    #[test]
1641    fn execute_pre_release_failure_aborts_release() {
1642        let mut config = ReleaseConfig::default();
1643        config.pre_release_command = Some("exit 1".into());
1644
1645        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1646        let plan = s.plan().unwrap();
1647        let result = s.execute(&plan, false);
1648
1649        assert!(result.is_err());
1650        // Nothing should have been committed or tagged
1651        assert!(s.git.created_tags.lock().unwrap().is_empty());
1652        assert!(s.git.committed.lock().unwrap().is_empty());
1653    }
1654
1655    #[test]
1656    fn execute_hooks_receive_version_env_vars() {
1657        let dir = tempfile::tempdir().unwrap();
1658        let output_file = dir.path().join("hook_output");
1659
1660        let mut config = ReleaseConfig::default();
1661        config.post_release_command = Some(format!(
1662            "echo $SR_VERSION $SR_TAG > {}",
1663            output_file.to_str().unwrap()
1664        ));
1665
1666        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1667        let plan = s.plan().unwrap();
1668        s.execute(&plan, false).unwrap();
1669
1670        let contents = std::fs::read_to_string(&output_file).unwrap();
1671        assert!(contents.contains("0.1.0"), "SR_VERSION should be set");
1672        assert!(contents.contains("v0.1.0"), "SR_TAG should be set");
1673    }
1674
1675    #[test]
1676    fn execute_dry_run_skips_hooks() {
1677        let dir = tempfile::tempdir().unwrap();
1678        let pre_marker = dir.path().join("pre_hook");
1679        let post_marker = dir.path().join("post_hook");
1680
1681        let mut config = ReleaseConfig::default();
1682        config.pre_release_command = Some(format!("touch {}", pre_marker.to_str().unwrap()));
1683        config.post_release_command = Some(format!("touch {}", post_marker.to_str().unwrap()));
1684
1685        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1686        let plan = s.plan().unwrap();
1687        s.execute(&plan, true).unwrap();
1688
1689        assert!(
1690            !pre_marker.exists(),
1691            "pre-release hook should not run in dry-run"
1692        );
1693        assert!(
1694            !post_marker.exists(),
1695            "post-release hook should not run in dry-run"
1696        );
1697    }
1698
1699    // --- pre-release tests ---
1700
1701    #[test]
1702    fn plan_prerelease_first_release() {
1703        let mut config = ReleaseConfig::default();
1704        config.prerelease = Some("alpha".into());
1705
1706        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1707        let plan = s.plan().unwrap();
1708        assert_eq!(plan.next_version.to_string(), "0.1.0-alpha.1");
1709        assert_eq!(plan.tag_name, "v0.1.0-alpha.1");
1710        assert!(plan.prerelease);
1711    }
1712
1713    #[test]
1714    fn plan_prerelease_increments_from_stable() {
1715        let tag = TagInfo {
1716            name: "v1.0.0".into(),
1717            version: Version::new(1, 0, 0),
1718            sha: "d".repeat(40),
1719        };
1720        let mut config = ReleaseConfig::default();
1721        config.prerelease = Some("beta".into());
1722
1723        let s = make_strategy(vec![tag], vec![raw_commit("feat: new feature")], config);
1724        let plan = s.plan().unwrap();
1725        assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1726        assert!(plan.prerelease);
1727    }
1728
1729    #[test]
1730    fn plan_prerelease_increments_counter() {
1731        let tags = vec![
1732            TagInfo {
1733                name: "v1.0.0".into(),
1734                version: Version::new(1, 0, 0),
1735                sha: "a".repeat(40),
1736            },
1737            TagInfo {
1738                name: "v1.1.0-alpha.1".into(),
1739                version: Version::parse("1.1.0-alpha.1").unwrap(),
1740                sha: "b".repeat(40),
1741            },
1742            TagInfo {
1743                name: "v1.1.0-alpha.2".into(),
1744                version: Version::parse("1.1.0-alpha.2").unwrap(),
1745                sha: "c".repeat(40),
1746            },
1747        ];
1748        let mut config = ReleaseConfig::default();
1749        config.prerelease = Some("alpha".into());
1750
1751        let s = make_strategy(tags, vec![raw_commit("feat: another")], config);
1752        let plan = s.plan().unwrap();
1753        assert_eq!(plan.next_version.to_string(), "1.1.0-alpha.3");
1754    }
1755
1756    #[test]
1757    fn plan_prerelease_different_id_starts_at_1() {
1758        let tags = vec![
1759            TagInfo {
1760                name: "v1.0.0".into(),
1761                version: Version::new(1, 0, 0),
1762                sha: "a".repeat(40),
1763            },
1764            TagInfo {
1765                name: "v1.1.0-alpha.3".into(),
1766                version: Version::parse("1.1.0-alpha.3").unwrap(),
1767                sha: "b".repeat(40),
1768            },
1769        ];
1770        let mut config = ReleaseConfig::default();
1771        config.prerelease = Some("beta".into());
1772
1773        let s = make_strategy(tags, vec![raw_commit("feat: something")], config);
1774        let plan = s.plan().unwrap();
1775        assert_eq!(plan.next_version.to_string(), "1.1.0-beta.1");
1776    }
1777
1778    #[test]
1779    fn plan_prerelease_no_floating_tags() {
1780        let mut config = ReleaseConfig::default();
1781        config.prerelease = Some("rc".into());
1782        config.floating_tags = true;
1783
1784        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1785        let plan = s.plan().unwrap();
1786        assert!(
1787            plan.floating_tag_name.is_none(),
1788            "pre-releases should not create floating tags"
1789        );
1790    }
1791
1792    #[test]
1793    fn plan_stable_skips_prerelease_tags() {
1794        let tags = vec![
1795            TagInfo {
1796                name: "v1.0.0".into(),
1797                version: Version::new(1, 0, 0),
1798                sha: "a".repeat(40),
1799            },
1800            TagInfo {
1801                name: "v1.1.0-alpha.1".into(),
1802                version: Version::parse("1.1.0-alpha.1").unwrap(),
1803                sha: "b".repeat(40),
1804            },
1805        ];
1806        // No prerelease config — stable release
1807        let s = make_strategy(
1808            tags,
1809            vec![raw_commit("feat: something")],
1810            ReleaseConfig::default(),
1811        );
1812        let plan = s.plan().unwrap();
1813        // Should base on v1.0.0, not v1.1.0-alpha.1
1814        assert_eq!(plan.next_version, Version::new(1, 1, 0));
1815        assert!(!plan.prerelease);
1816    }
1817
1818    #[test]
1819    fn plan_prerelease_marks_plan_as_prerelease() {
1820        let mut config = ReleaseConfig::default();
1821        config.prerelease = Some("alpha".into());
1822
1823        let s = make_strategy(vec![], vec![raw_commit("fix: bug")], config);
1824        let plan = s.plan().unwrap();
1825        assert!(plan.prerelease);
1826        assert!(plan.next_version.to_string().contains("alpha"));
1827    }
1828
1829    // --- monorepo (path_filter) tests ---
1830
1831    #[test]
1832    fn plan_with_path_filter_uses_filtered_commits() {
1833        let mut config = ReleaseConfig::default();
1834        config.path_filter = Some("crates/core".into());
1835
1836        // All commits include a feat, but path-filtered commits only have a fix
1837        let mut s = make_strategy(
1838            vec![],
1839            vec![raw_commit("feat: big feature"), raw_commit("fix: patch")],
1840            config,
1841        );
1842        s.git.path_commits = Some(vec![raw_commit("fix: patch only in core")]);
1843
1844        let plan = s.plan().unwrap();
1845        // Should be a patch bump (from path-filtered commits), not minor
1846        assert_eq!(plan.bump, BumpLevel::Patch);
1847        assert_eq!(plan.commits.len(), 1);
1848        assert_eq!(plan.commits[0].description, "patch only in core");
1849    }
1850
1851    #[test]
1852    fn plan_without_path_filter_uses_all_commits() {
1853        let config = ReleaseConfig::default();
1854
1855        let mut s = make_strategy(vec![], vec![raw_commit("feat: big feature")], config);
1856        s.git.path_commits = Some(vec![raw_commit("fix: filtered")]);
1857
1858        let plan = s.plan().unwrap();
1859        // path_filter is None, so should use all commits (feat → minor)
1860        assert_eq!(plan.bump, BumpLevel::Minor);
1861    }
1862
1863    #[test]
1864    fn plan_with_path_filter_no_commits_returns_error() {
1865        let mut config = ReleaseConfig::default();
1866        config.path_filter = Some("crates/core".into());
1867
1868        let mut s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1869        s.git.path_commits = Some(vec![]);
1870
1871        let err = s.plan().unwrap_err();
1872        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1873    }
1874
1875    #[test]
1876    fn plan_with_path_filter_custom_tag_prefix() {
1877        let mut config = ReleaseConfig::default();
1878        config.path_filter = Some("crates/core".into());
1879        config.tag_prefix = "core/v".into();
1880
1881        let tag = TagInfo {
1882            name: "core/v1.0.0".into(),
1883            version: Version::new(1, 0, 0),
1884            sha: "a".repeat(40),
1885        };
1886        let mut s = make_strategy(vec![tag], vec![raw_commit("feat: something")], config);
1887        s.git.path_commits = Some(vec![raw_commit("fix: core bug")]);
1888
1889        let plan = s.plan().unwrap();
1890        assert_eq!(plan.tag_name, "core/v1.0.1");
1891        assert_eq!(plan.current_version, Some(Version::new(1, 0, 0)));
1892    }
1893}