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