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