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