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::{Config, PackageConfig};
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: Config,
124    /// When true, re-release the current tag if HEAD is at the latest tag.
125    pub force: bool,
126    /// Pre-release identifier resolved from the active channel (None = stable).
127    pub prerelease_id: Option<String>,
128    /// Whether the GitHub release should be created as a draft.
129    pub draft: bool,
130}
131
132impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
133where
134    G: GitRepository,
135    V: VcsProvider,
136    C: CommitParser,
137    F: ChangelogFormatter,
138{
139    fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
140        let today = today_string();
141        let compare_url = match &plan.current_version {
142            Some(v) => {
143                let base = format!("{}{v}", self.config.git.tag_prefix);
144                self.vcs
145                    .compare_url(&base, &plan.tag_name)
146                    .ok()
147                    .filter(|s| !s.is_empty())
148            }
149            None => None,
150        };
151        let entry = ChangelogEntry {
152            version: plan.next_version.to_string(),
153            date: today,
154            commits: plan.commits.clone(),
155            compare_url,
156            repo_url: self.vcs.repo_url(),
157        };
158        self.formatter.format(&[entry])
159    }
160
161    /// Render the release name from the configured template, or fall back to the tag name.
162    fn release_name(&self, plan: &ReleasePlan) -> String {
163        if let Some(ref template_str) = self.config.vcs.github.release_name_template {
164            let mut env = minijinja::Environment::new();
165            if env.add_template("release_name", template_str).is_ok()
166                && let Ok(tmpl) = env.get_template("release_name")
167                && let Ok(rendered) = tmpl.render(minijinja::context! {
168                    version => plan.next_version.to_string(),
169                    tag_name => &plan.tag_name,
170                    tag_prefix => &self.config.git.tag_prefix,
171                })
172            {
173                return rendered;
174            }
175            eprintln!("warning: invalid release_name_template, falling back to tag name");
176        }
177        plan.tag_name.clone()
178    }
179
180    /// Return the active package for a single-package release (the root package or the only one).
181    /// Returns the root package (".") if present, otherwise the first package.
182    fn active_package(&self) -> Option<&PackageConfig> {
183        self.config
184            .packages
185            .iter()
186            .find(|p| p.path == ".")
187            .or_else(|| self.config.packages.first())
188    }
189}
190
191impl<G, V, C, F> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F>
192where
193    G: GitRepository,
194    V: VcsProvider,
195    C: CommitParser,
196    F: ChangelogFormatter,
197{
198    fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
199        let is_prerelease = self.prerelease_id.is_some();
200
201        // For stable releases, find the latest stable tag (skip pre-release tags).
202        // For pre-releases, find the latest tag of any kind to determine commits since.
203        let all_tags = self.git.all_tags(&self.config.git.tag_prefix)?;
204        let latest_stable = all_tags.iter().rev().find(|t| t.version.pre.is_empty());
205        let latest_any = all_tags.last();
206
207        // Use the latest tag (any kind) for commit range, but the latest stable for base version
208        let tag_info = if is_prerelease {
209            latest_any
210        } else {
211            latest_stable.or(latest_any)
212        };
213
214        let (current_version, from_sha) = match tag_info {
215            Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
216            None => (None, None),
217        };
218
219        let default_pkg = PackageConfig::default();
220        let pkg = self.active_package().unwrap_or(&default_pkg);
221        let path_filter = if pkg.path != "." {
222            Some(pkg.path.as_str())
223        } else {
224            None
225        };
226
227        let raw_commits = if let Some(path) = path_filter {
228            self.git.commits_since_in_path(from_sha, path)?
229        } else {
230            self.git.commits_since(from_sha)?
231        };
232
233        if raw_commits.is_empty() {
234            // Force mode: re-release if HEAD is exactly at the latest tag
235            if self.force
236                && let Some(info) = tag_info
237            {
238                let head = self.git.head_sha()?;
239                if head == info.sha {
240                    let floating_tag_name = if self.config.git.floating_tag {
241                        Some(format!(
242                            "{}{}",
243                            self.config.git.tag_prefix, info.version.major
244                        ))
245                    } else {
246                        None
247                    };
248                    return Ok(ReleasePlan {
249                        current_version: Some(info.version.clone()),
250                        next_version: info.version.clone(),
251                        bump: BumpLevel::Patch,
252                        commits: vec![],
253                        tag_name: info.name.clone(),
254                        floating_tag_name,
255                        prerelease: is_prerelease,
256                    });
257                }
258            }
259            let (tag, sha) = match tag_info {
260                Some(info) => (info.name.clone(), info.sha.clone()),
261                None => ("(none)".into(), "(none)".into()),
262            };
263            return Err(ReleaseError::NoCommits { tag, sha });
264        }
265
266        let conventional_commits: Vec<ConventionalCommit> = raw_commits
267            .iter()
268            .filter(|c| !c.message.starts_with("chore(release):"))
269            .filter_map(|c| self.parser.parse(c).ok())
270            .collect();
271
272        let classifier = DefaultCommitClassifier::new(self.config.commit.types.into_commit_types());
273        let tag_for_err = tag_info
274            .map(|i| i.name.clone())
275            .unwrap_or_else(|| "(none)".into());
276        let commit_count = conventional_commits.len();
277        let bump = match determine_bump(&conventional_commits, &classifier) {
278            Some(b) => b,
279            None if self.force => BumpLevel::Patch,
280            None => {
281                return Err(ReleaseError::NoBump {
282                    tag: tag_for_err,
283                    commit_count,
284                });
285            }
286        };
287
288        // For pre-releases, base the version on the latest *stable* tag
289        let base_version = if is_prerelease {
290            latest_stable
291                .map(|t| t.version.clone())
292                .or(current_version.clone())
293                .unwrap_or(Version::new(0, 0, 0))
294        } else {
295            current_version.clone().unwrap_or(Version::new(0, 0, 0))
296        };
297
298        // v0 protection: downshift Major → Minor when version is 0.x.y
299        // to prevent accidentally bumping to v1. Disable with git.v0_protection: false.
300        let bump =
301            if base_version.major == 0 && bump == BumpLevel::Major && self.config.git.v0_protection
302            {
303                eprintln!(
304                    "v0 protection: breaking change detected at v{base_version}, \
305                     downshifting major → minor (set git.v0_protection: false to bump to v1)"
306                );
307                BumpLevel::Minor
308            } else {
309                bump
310            };
311
312        let next_version = if let Some(ref prerelease_id) = self.prerelease_id {
313            let existing_versions: Vec<Version> =
314                all_tags.iter().map(|t| t.version.clone()).collect();
315            apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
316        } else {
317            apply_bump(&base_version, bump)
318        };
319
320        let tag_name = format!("{}{next_version}", self.config.git.tag_prefix);
321
322        // Don't update floating tags for pre-releases
323        let floating_tag_name = if self.config.git.floating_tag && !is_prerelease {
324            Some(format!(
325                "{}{}",
326                self.config.git.tag_prefix, next_version.major
327            ))
328        } else {
329            None
330        };
331
332        Ok(ReleasePlan {
333            current_version,
334            next_version,
335            bump,
336            commits: conventional_commits,
337            tag_name,
338            floating_tag_name,
339            prerelease: is_prerelease,
340        })
341    }
342
343    fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
344        let version_str = plan.next_version.to_string();
345
346        // 1. Run pre_release hooks
347        let env = release_env(&version_str, &plan.tag_name);
348        let env_refs: Vec<(&str, &str)> =
349            env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
350        if !dry_run
351            && let Some(pkg) = self.active_package()
352            && let Some(ref hooks) = pkg.hooks
353        {
354            crate::hooks::run_pre_release(hooks, &env_refs)?;
355        }
356
357        // 2. Generate changelog
358        let changelog_body = self.format_changelog(plan)?;
359
360        // 3. Bump, write changelog, stage, commit
361        self.bump_and_build(plan, &version_str, &changelog_body, dry_run)?;
362
363        // 4. Create and push tags
364        self.create_and_push_tags(plan, &changelog_body, dry_run)?;
365
366        // 5. GitHub release
367        self.create_or_update_release(plan, &changelog_body, dry_run)?;
368        self.upload_artifacts(plan, dry_run)?;
369        self.verify_release_exists(plan, dry_run)?;
370
371        // 6. Run post_release hooks
372        if !dry_run
373            && let Some(pkg) = self.active_package()
374            && let Some(ref hooks) = pkg.hooks
375        {
376            crate::hooks::run_post_release(hooks, &env_refs)?;
377        }
378
379        if dry_run {
380            eprintln!("[dry-run] Changelog:\n{changelog_body}");
381        } else {
382            eprintln!("Released {}", plan.tag_name);
383        }
384        Ok(())
385    }
386}
387
388/// Build release env vars as owned strings.
389fn release_env(version: &str, tag: &str) -> Vec<(String, String)> {
390    vec![
391        ("SR_VERSION".into(), version.into()),
392        ("SR_TAG".into(), tag.into()),
393    ]
394}
395
396impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
397where
398    G: GitRepository,
399    V: VcsProvider,
400    C: CommitParser,
401    F: ChangelogFormatter,
402{
403    fn bump_and_build(
404        &self,
405        plan: &ReleasePlan,
406        version_str: &str,
407        changelog_body: &str,
408        dry_run: bool,
409    ) -> Result<(), ReleaseError> {
410        let default_pkg = PackageConfig::default();
411        let pkg = self.active_package().unwrap_or(&default_pkg);
412        let version_files = self.config.version_files_for(pkg);
413        let version_files_strict = pkg.version_files_strict;
414        let stage_files = &pkg.stage_files;
415        let changelog_file = self.config.changelog_for(pkg).file.clone();
416
417        if dry_run {
418            for file in &version_files {
419                let filename = Path::new(file)
420                    .file_name()
421                    .and_then(|n| n.to_str())
422                    .unwrap_or_default();
423                if is_supported_version_file(filename) {
424                    eprintln!("[dry-run] Would bump version in: {file}");
425                } else if version_files_strict {
426                    return Err(ReleaseError::VersionBump(format!(
427                        "unsupported version file: {filename}"
428                    )));
429                } else {
430                    eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
431                }
432            }
433            if !stage_files.is_empty() {
434                eprintln!(
435                    "[dry-run] Would stage additional files: {}",
436                    stage_files.join(", ")
437                );
438            }
439            return Ok(());
440        }
441
442        let files_to_stage = self.execute_mutations(
443            version_str,
444            changelog_body,
445            &version_files,
446            &changelog_file,
447            version_files_strict,
448        )?;
449
450        // Resolve stage_files globs and collect all paths to stage
451        let mut paths_to_stage: Vec<String> = Vec::new();
452        if let Some(ref cf) = changelog_file {
453            paths_to_stage.push(cf.clone());
454        }
455        for file in &files_to_stage {
456            paths_to_stage.push(file.clone());
457        }
458        if !stage_files.is_empty() {
459            let extra = resolve_globs(stage_files).map_err(ReleaseError::Config)?;
460            paths_to_stage.extend(extra);
461        }
462        if !paths_to_stage.is_empty() {
463            let refs: Vec<&str> = paths_to_stage.iter().map(|s| s.as_str()).collect();
464            let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
465            self.git.stage_and_commit(&refs, &commit_msg)?;
466        }
467        Ok(())
468    }
469
470    fn create_and_push_tags(
471        &self,
472        plan: &ReleasePlan,
473        changelog_body: &str,
474        dry_run: bool,
475    ) -> Result<(), ReleaseError> {
476        if dry_run {
477            let sign_label = if self.config.git.sign_tags {
478                " (signed)"
479            } else {
480                ""
481            };
482            eprintln!("[dry-run] Would create tag: {}{sign_label}", plan.tag_name);
483            eprintln!("[dry-run] Would push commit and tag: {}", plan.tag_name);
484            if let Some(ref floating) = plan.floating_tag_name {
485                eprintln!("[dry-run] Would create/update floating tag: {floating}");
486                eprintln!("[dry-run] Would force-push floating tag: {floating}");
487            }
488            return Ok(());
489        }
490
491        // Create tag (skip if it already exists locally)
492        if !self.git.tag_exists(&plan.tag_name)? {
493            let tag_message = format!("{}\n\n{}", plan.tag_name, changelog_body);
494            self.git
495                .create_tag(&plan.tag_name, &tag_message, self.config.git.sign_tags)?;
496        }
497
498        // Push commit (safe to re-run — no-op if up to date)
499        self.git.push()?;
500
501        // Push tag (skip if tag already exists on remote)
502        if !self.git.remote_tag_exists(&plan.tag_name)? {
503            self.git.push_tag(&plan.tag_name)?;
504        }
505
506        // Force-create and force-push floating tag (e.g. v3)
507        if let Some(ref floating) = plan.floating_tag_name {
508            self.git.force_create_tag(floating)?;
509            self.git.force_push_tag(floating)?;
510        }
511        Ok(())
512    }
513
514    fn create_or_update_release(
515        &self,
516        plan: &ReleasePlan,
517        changelog_body: &str,
518        dry_run: bool,
519    ) -> Result<(), ReleaseError> {
520        if dry_run {
521            let draft_label = if self.draft { " (draft)" } else { "" };
522            let release_name = self.release_name(plan);
523            eprintln!(
524                "[dry-run] Would create GitHub release \"{release_name}\" for {}{draft_label}",
525                plan.tag_name
526            );
527            return Ok(());
528        }
529
530        let release_name = self.release_name(plan);
531        if self.vcs.release_exists(&plan.tag_name)? {
532            self.vcs.update_release(
533                &plan.tag_name,
534                &release_name,
535                changelog_body,
536                plan.prerelease,
537                self.draft,
538            )?;
539        } else {
540            self.vcs.create_release(
541                &plan.tag_name,
542                &release_name,
543                changelog_body,
544                plan.prerelease,
545                self.draft,
546            )?;
547        }
548        Ok(())
549    }
550
551    fn upload_artifacts(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
552        let all_artifacts = self.config.all_artifacts();
553        if all_artifacts.is_empty() {
554            return Ok(());
555        }
556
557        let resolved = resolve_globs(&all_artifacts).map_err(ReleaseError::Vcs)?;
558
559        if dry_run {
560            if resolved.is_empty() {
561                eprintln!("[dry-run] Artifact patterns matched no files");
562            } else {
563                eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
564                for f in &resolved {
565                    eprintln!("[dry-run]   {f}");
566                }
567            }
568            return Ok(());
569        }
570
571        if !resolved.is_empty() {
572            let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
573            self.vcs.upload_assets(&plan.tag_name, &file_refs)?;
574            eprintln!(
575                "Uploaded {} artifact(s) to {}",
576                resolved.len(),
577                plan.tag_name
578            );
579        }
580        Ok(())
581    }
582
583    fn verify_release_exists(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
584        if dry_run {
585            eprintln!("[dry-run] Would verify release: {}", plan.tag_name);
586            return Ok(());
587        }
588
589        if let Err(e) = self.vcs.verify_release(&plan.tag_name) {
590            eprintln!("warning: post-release verification failed: {e}");
591            eprintln!(
592                "  The tag {} was pushed but the GitHub release may be incomplete.",
593                plan.tag_name
594            );
595            eprintln!("  Re-run with --force to retry.");
596        }
597        Ok(())
598    }
599
600    /// Bump version files and write changelog.
601    /// Returns the list of bumped files on success.
602    fn execute_mutations(
603        &self,
604        version_str: &str,
605        changelog_body: &str,
606        version_files: &[String],
607        changelog_file: &Option<String>,
608        version_files_strict: bool,
609    ) -> Result<Vec<String>, ReleaseError> {
610        let mut files_to_stage: Vec<String> = Vec::new();
611        for file in version_files {
612            match bump_version_file(Path::new(file), version_str) {
613                Ok(extra) => {
614                    files_to_stage.push(file.clone());
615                    for extra_path in extra {
616                        files_to_stage.push(extra_path.to_string_lossy().into_owned());
617                    }
618                }
619                Err(e) if !version_files_strict => {
620                    eprintln!("warning: {e} — skipping {file}");
621                }
622                Err(e) => return Err(e),
623            }
624        }
625
626        // Auto-discover and stage lock files associated with bumped manifests
627        for lock_file in discover_lock_files(&files_to_stage) {
628            let lock_str = lock_file.to_string_lossy().into_owned();
629            if !files_to_stage.contains(&lock_str) {
630                files_to_stage.push(lock_str);
631            }
632        }
633
634        // Write changelog file if configured
635        if let Some(cf) = changelog_file {
636            let path = Path::new(cf);
637            let existing = if path.exists() {
638                fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
639            } else {
640                String::new()
641            };
642            let new_content = if existing.is_empty() {
643                format!("# Changelog\n\n{changelog_body}\n")
644            } else {
645                match existing.find("\n\n") {
646                    Some(pos) => {
647                        let (header, rest) = existing.split_at(pos);
648                        format!("{header}\n\n{changelog_body}\n{rest}")
649                    }
650                    None => format!("{existing}\n\n{changelog_body}\n"),
651                }
652            };
653            fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
654        }
655
656        Ok(files_to_stage)
657    }
658}
659
660/// Resolve glob patterns into a deduplicated, sorted list of file paths.
661fn resolve_globs(patterns: &[String]) -> Result<Vec<String>, String> {
662    let mut files = std::collections::BTreeSet::new();
663    for pattern in patterns {
664        let paths =
665            glob::glob(pattern).map_err(|e| format!("invalid glob pattern '{pattern}': {e}"))?;
666        for entry in paths {
667            match entry {
668                Ok(path) if path.is_file() => {
669                    files.insert(path.to_string_lossy().into_owned());
670                }
671                Ok(_) => {}
672                Err(e) => {
673                    return Err(format!("glob error for pattern '{pattern}': {e}"));
674                }
675            }
676        }
677    }
678    Ok(files.into_iter().collect())
679}
680
681pub fn today_string() -> String {
682    // Portable date calculation from UNIX epoch (no external deps or subprocess).
683    // Uses Howard Hinnant's civil_from_days algorithm.
684    let secs = std::time::SystemTime::now()
685        .duration_since(std::time::UNIX_EPOCH)
686        .unwrap_or_default()
687        .as_secs() as i64;
688
689    let z = secs / 86400 + 719468;
690    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
691    let doe = (z - era * 146097) as u32;
692    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
693    let y = yoe as i64 + era * 400;
694    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
695    let mp = (5 * doy + 2) / 153;
696    let d = doy - (153 * mp + 2) / 5 + 1;
697    let m = if mp < 10 { mp + 3 } else { mp - 9 };
698    let y = if m <= 2 { y + 1 } else { y };
699
700    format!("{y:04}-{m:02}-{d:02}")
701}
702
703#[cfg(test)]
704mod tests {
705    use std::sync::Mutex;
706
707    use super::*;
708    use crate::changelog::DefaultChangelogFormatter;
709    use crate::commit::{Commit, TypedCommitParser};
710    use crate::config::{
711        ChangelogConfig, Config, GitConfig, PackageConfig, default_changelog_groups,
712    };
713    use crate::git::{GitRepository, TagInfo};
714
715    // --- Fakes ---
716
717    struct FakeGit {
718        tags: Vec<TagInfo>,
719        commits: Vec<Commit>,
720        /// Commits returned when path filtering is active (None = fall back to `commits`).
721        path_commits: Option<Vec<Commit>>,
722        head: String,
723        created_tags: Mutex<Vec<String>>,
724        pushed_tags: Mutex<Vec<String>>,
725        committed: Mutex<Vec<(Vec<String>, String)>>,
726        push_count: Mutex<u32>,
727        force_created_tags: Mutex<Vec<String>>,
728        force_pushed_tags: Mutex<Vec<String>>,
729    }
730
731    impl FakeGit {
732        fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
733            let head = tags
734                .last()
735                .map(|t| t.sha.clone())
736                .unwrap_or_else(|| "0".repeat(40));
737            Self {
738                tags,
739                commits,
740                path_commits: None,
741                head,
742                created_tags: Mutex::new(Vec::new()),
743                pushed_tags: Mutex::new(Vec::new()),
744                committed: Mutex::new(Vec::new()),
745                push_count: Mutex::new(0),
746                force_created_tags: Mutex::new(Vec::new()),
747                force_pushed_tags: Mutex::new(Vec::new()),
748            }
749        }
750    }
751
752    impl GitRepository for FakeGit {
753        fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
754            Ok(self.tags.last().cloned())
755        }
756
757        fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
758            Ok(self.commits.clone())
759        }
760
761        fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
762            self.created_tags.lock().unwrap().push(name.to_string());
763            Ok(())
764        }
765
766        fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
767            self.pushed_tags.lock().unwrap().push(name.to_string());
768            Ok(())
769        }
770
771        fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
772            self.committed.lock().unwrap().push((
773                paths.iter().map(|s| s.to_string()).collect(),
774                message.to_string(),
775            ));
776            Ok(true)
777        }
778
779        fn push(&self) -> Result<(), ReleaseError> {
780            *self.push_count.lock().unwrap() += 1;
781            Ok(())
782        }
783
784        fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
785            Ok(self
786                .created_tags
787                .lock()
788                .unwrap()
789                .contains(&name.to_string()))
790        }
791
792        fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
793            Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
794        }
795
796        fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
797            Ok(self.tags.clone())
798        }
799
800        fn commits_between(
801            &self,
802            _from: Option<&str>,
803            _to: &str,
804        ) -> Result<Vec<Commit>, ReleaseError> {
805            Ok(self.commits.clone())
806        }
807
808        fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
809            Ok("2026-01-01".into())
810        }
811
812        fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
813            self.force_created_tags
814                .lock()
815                .unwrap()
816                .push(name.to_string());
817            Ok(())
818        }
819
820        fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
821            self.force_pushed_tags
822                .lock()
823                .unwrap()
824                .push(name.to_string());
825            Ok(())
826        }
827
828        fn head_sha(&self) -> Result<String, ReleaseError> {
829            Ok(self.head.clone())
830        }
831
832        fn commits_since_in_path(
833            &self,
834            _from: Option<&str>,
835            _path: &str,
836        ) -> Result<Vec<Commit>, ReleaseError> {
837            Ok(self
838                .path_commits
839                .clone()
840                .unwrap_or_else(|| self.commits.clone()))
841        }
842    }
843
844    struct FakeVcs {
845        releases: Mutex<Vec<(String, String)>>,
846        deleted_releases: Mutex<Vec<String>>,
847        uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
848    }
849
850    impl FakeVcs {
851        fn new() -> Self {
852            Self {
853                releases: Mutex::new(Vec::new()),
854                deleted_releases: Mutex::new(Vec::new()),
855                uploaded_assets: Mutex::new(Vec::new()),
856            }
857        }
858    }
859
860    impl VcsProvider for FakeVcs {
861        fn create_release(
862            &self,
863            tag: &str,
864            _name: &str,
865            body: &str,
866            _prerelease: bool,
867            _draft: bool,
868        ) -> Result<String, ReleaseError> {
869            self.releases
870                .lock()
871                .unwrap()
872                .push((tag.to_string(), body.to_string()));
873            Ok(format!("https://github.com/test/release/{tag}"))
874        }
875
876        fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
877            Ok(format!("https://github.com/test/compare/{base}...{head}"))
878        }
879
880        fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
881            Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
882        }
883
884        fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
885            self.deleted_releases.lock().unwrap().push(tag.to_string());
886            self.releases.lock().unwrap().retain(|(t, _)| t != tag);
887            Ok(())
888        }
889
890        fn update_release(
891            &self,
892            tag: &str,
893            _name: &str,
894            body: &str,
895            _prerelease: bool,
896            _draft: bool,
897        ) -> Result<String, ReleaseError> {
898            let mut releases = self.releases.lock().unwrap();
899            if let Some(entry) = releases.iter_mut().find(|(t, _)| t == tag) {
900                entry.1 = body.to_string();
901            }
902            Ok(format!("https://github.com/test/release/{tag}"))
903        }
904
905        fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
906            self.uploaded_assets.lock().unwrap().push((
907                tag.to_string(),
908                files.iter().map(|s| s.to_string()).collect(),
909            ));
910            Ok(())
911        }
912
913        fn repo_url(&self) -> Option<String> {
914            Some("https://github.com/test/repo".into())
915        }
916    }
917
918    // --- Helpers ---
919
920    type TestStrategy =
921        TrunkReleaseStrategy<FakeGit, FakeVcs, TypedCommitParser, DefaultChangelogFormatter>;
922
923    /// Build a Config with changelog file disabled so tests don't pollute the real CHANGELOG.md.
924    fn test_config() -> Config {
925        Config {
926            changelog: ChangelogConfig {
927                file: None,
928                ..Default::default()
929            },
930            ..Default::default()
931        }
932    }
933
934    /// Build a Config with custom floating_tag / tag_prefix settings.
935    fn config_with_git(git: GitConfig) -> Config {
936        Config {
937            git,
938            changelog: ChangelogConfig {
939                file: None,
940                ..Default::default()
941            },
942            ..Default::default()
943        }
944    }
945
946    fn make_strategy(tags: Vec<TagInfo>, commits: Vec<Commit>, config: Config) -> TestStrategy {
947        TrunkReleaseStrategy {
948            git: FakeGit::new(tags, commits),
949            vcs: FakeVcs::new(),
950            parser: TypedCommitParser::default(),
951            formatter: DefaultChangelogFormatter::new(None, default_changelog_groups()),
952            config,
953            force: false,
954            prerelease_id: None,
955            draft: false,
956        }
957    }
958
959    fn raw_commit(msg: &str) -> Commit {
960        Commit {
961            sha: "a".repeat(40),
962            message: msg.into(),
963        }
964    }
965
966    // --- plan() tests ---
967
968    #[test]
969    fn plan_no_commits_returns_error() {
970        let s = make_strategy(vec![], vec![], Config::default());
971        let err = s.plan().unwrap_err();
972        assert!(matches!(err, ReleaseError::NoCommits { .. }));
973    }
974
975    #[test]
976    fn plan_no_releasable_returns_error() {
977        let s = make_strategy(
978            vec![],
979            vec![raw_commit("chore: tidy up")],
980            Config::default(),
981        );
982        let err = s.plan().unwrap_err();
983        assert!(matches!(err, ReleaseError::NoBump { .. }));
984    }
985
986    #[test]
987    fn force_releases_patch_when_no_releasable_commits() {
988        let tag = TagInfo {
989            name: "v1.2.3".into(),
990            version: Version::new(1, 2, 3),
991            sha: "d".repeat(40),
992        };
993        let mut s = make_strategy(
994            vec![tag],
995            vec![raw_commit("chore: rename package")],
996            Config::default(),
997        );
998        s.force = true;
999        let plan = s.plan().unwrap();
1000        assert_eq!(plan.next_version, Version::new(1, 2, 4));
1001        assert_eq!(plan.bump, BumpLevel::Patch);
1002    }
1003
1004    #[test]
1005    fn plan_first_release() {
1006        let s = make_strategy(
1007            vec![],
1008            vec![raw_commit("feat: initial feature")],
1009            Config::default(),
1010        );
1011        let plan = s.plan().unwrap();
1012        assert_eq!(plan.next_version, Version::new(0, 1, 0));
1013        assert_eq!(plan.tag_name, "v0.1.0");
1014        assert!(plan.current_version.is_none());
1015    }
1016
1017    #[test]
1018    fn plan_increments_existing() {
1019        let tag = TagInfo {
1020            name: "v1.2.3".into(),
1021            version: Version::new(1, 2, 3),
1022            sha: "b".repeat(40),
1023        };
1024        let s = make_strategy(
1025            vec![tag],
1026            vec![raw_commit("fix: patch bug")],
1027            Config::default(),
1028        );
1029        let plan = s.plan().unwrap();
1030        assert_eq!(plan.next_version, Version::new(1, 2, 4));
1031    }
1032
1033    #[test]
1034    fn plan_breaking_bump() {
1035        let tag = TagInfo {
1036            name: "v1.2.3".into(),
1037            version: Version::new(1, 2, 3),
1038            sha: "c".repeat(40),
1039        };
1040        let s = make_strategy(
1041            vec![tag],
1042            vec![raw_commit("feat!: breaking change")],
1043            Config::default(),
1044        );
1045        let plan = s.plan().unwrap();
1046        assert_eq!(plan.next_version, Version::new(2, 0, 0));
1047    }
1048
1049    #[test]
1050    fn plan_v0_breaking_downshifts_to_minor() {
1051        let tag = TagInfo {
1052            name: "v0.5.0".into(),
1053            version: Version::new(0, 5, 0),
1054            sha: "c".repeat(40),
1055        };
1056        let s = make_strategy(
1057            vec![tag],
1058            vec![raw_commit("feat!: breaking change")],
1059            Config::default(),
1060        );
1061        let plan = s.plan().unwrap();
1062        // v0 protection: Major → Minor, so 0.5.0 → 0.6.0 (not 1.0.0)
1063        assert_eq!(plan.next_version, Version::new(0, 6, 0));
1064        assert_eq!(plan.bump, BumpLevel::Minor);
1065    }
1066
1067    #[test]
1068    fn plan_v0_breaking_with_protection_disabled_bumps_major() {
1069        let tag = TagInfo {
1070            name: "v0.5.0".into(),
1071            version: Version::new(0, 5, 0),
1072            sha: "c".repeat(40),
1073        };
1074        let mut config = Config::default();
1075        config.git.v0_protection = false;
1076        let s = make_strategy(
1077            vec![tag],
1078            vec![raw_commit("feat!: breaking change")],
1079            config,
1080        );
1081        let plan = s.plan().unwrap();
1082        // v0_protection: false allows bumping to v1
1083        assert_eq!(plan.next_version, Version::new(1, 0, 0));
1084        assert_eq!(plan.bump, BumpLevel::Major);
1085    }
1086
1087    #[test]
1088    fn plan_v0_feat_stays_minor() {
1089        let tag = TagInfo {
1090            name: "v0.5.0".into(),
1091            version: Version::new(0, 5, 0),
1092            sha: "c".repeat(40),
1093        };
1094        let s = make_strategy(
1095            vec![tag],
1096            vec![raw_commit("feat: new feature")],
1097            Config::default(),
1098        );
1099        let plan = s.plan().unwrap();
1100        // Non-breaking feat in v0 stays as minor bump
1101        assert_eq!(plan.next_version, Version::new(0, 6, 0));
1102        assert_eq!(plan.bump, BumpLevel::Minor);
1103    }
1104
1105    #[test]
1106    fn plan_v0_fix_stays_patch() {
1107        let tag = TagInfo {
1108            name: "v0.5.0".into(),
1109            version: Version::new(0, 5, 0),
1110            sha: "c".repeat(40),
1111        };
1112        let s = make_strategy(
1113            vec![tag],
1114            vec![raw_commit("fix: bug fix")],
1115            Config::default(),
1116        );
1117        let plan = s.plan().unwrap();
1118        // Fix in v0 stays as patch
1119        assert_eq!(plan.next_version, Version::new(0, 5, 1));
1120        assert_eq!(plan.bump, BumpLevel::Patch);
1121    }
1122
1123    // --- execute() tests ---
1124
1125    #[test]
1126    fn execute_dry_run_no_side_effects() {
1127        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1128        let plan = s.plan().unwrap();
1129        s.execute(&plan, true).unwrap();
1130
1131        assert!(s.git.created_tags.lock().unwrap().is_empty());
1132        assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1133    }
1134
1135    #[test]
1136    fn execute_creates_and_pushes_tag() {
1137        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1138        let plan = s.plan().unwrap();
1139        s.execute(&plan, false).unwrap();
1140
1141        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1142        assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1143    }
1144
1145    #[test]
1146    fn execute_calls_vcs_create_release() {
1147        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1148        let plan = s.plan().unwrap();
1149        s.execute(&plan, false).unwrap();
1150
1151        let releases = s.vcs.releases.lock().unwrap();
1152        assert_eq!(releases.len(), 1);
1153        assert_eq!(releases[0].0, "v0.1.0");
1154        assert!(!releases[0].1.is_empty());
1155    }
1156
1157    #[test]
1158    fn execute_commits_changelog_before_tag() {
1159        let dir = tempfile::tempdir().unwrap();
1160        let changelog_path = dir.path().join("CHANGELOG.md");
1161
1162        // Use the temp dir as the package path so auto-detection finds no version files.
1163        let config = Config {
1164            changelog: ChangelogConfig {
1165                file: Some(changelog_path.to_str().unwrap().to_string()),
1166                ..Default::default()
1167            },
1168            packages: vec![PackageConfig {
1169                path: dir.path().to_str().unwrap().to_string(),
1170                ..Default::default()
1171            }],
1172            ..Default::default()
1173        };
1174
1175        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1176        let plan = s.plan().unwrap();
1177        s.execute(&plan, false).unwrap();
1178
1179        // Verify changelog was committed
1180        let committed = s.git.committed.lock().unwrap();
1181        assert_eq!(committed.len(), 1);
1182        assert_eq!(
1183            committed[0].0,
1184            vec![changelog_path.to_str().unwrap().to_string()]
1185        );
1186        assert!(committed[0].1.contains("chore(release): v0.1.0"));
1187
1188        // Verify tag was created after commit
1189        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1190    }
1191
1192    #[test]
1193    fn execute_skips_existing_tag() {
1194        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1195        let plan = s.plan().unwrap();
1196
1197        // Pre-populate the tag to simulate it already existing
1198        s.git
1199            .created_tags
1200            .lock()
1201            .unwrap()
1202            .push("v0.1.0".to_string());
1203
1204        s.execute(&plan, false).unwrap();
1205
1206        // Tag should not be created again (still only the one we pre-populated)
1207        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1208    }
1209
1210    #[test]
1211    fn execute_skips_existing_release() {
1212        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1213        let plan = s.plan().unwrap();
1214
1215        // Pre-populate a release to simulate it already existing
1216        s.vcs
1217            .releases
1218            .lock()
1219            .unwrap()
1220            .push(("v0.1.0".to_string(), "old notes".to_string()));
1221
1222        s.execute(&plan, false).unwrap();
1223
1224        // Should have updated in place without deleting
1225        let deleted = s.vcs.deleted_releases.lock().unwrap();
1226        assert!(deleted.is_empty(), "update should not delete");
1227
1228        let releases = s.vcs.releases.lock().unwrap();
1229        assert_eq!(releases.len(), 1);
1230        assert_eq!(releases[0].0, "v0.1.0");
1231        assert_ne!(releases[0].1, "old notes");
1232    }
1233
1234    #[test]
1235    fn execute_idempotent_rerun() {
1236        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1237        let plan = s.plan().unwrap();
1238
1239        // First run
1240        s.execute(&plan, false).unwrap();
1241
1242        // Second run should also succeed (idempotent)
1243        s.execute(&plan, false).unwrap();
1244
1245        // Tag should only have been created once (second run skips because tag_exists)
1246        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1247
1248        // Tag push should only happen once (second run skips because remote_tag_exists)
1249        assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1250
1251        // Push (commit) should happen twice (always safe)
1252        assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1253
1254        // Release should be updated in place on second run (no delete)
1255        let deleted = s.vcs.deleted_releases.lock().unwrap();
1256        assert!(deleted.is_empty(), "update should not delete");
1257
1258        let releases = s.vcs.releases.lock().unwrap();
1259        assert_eq!(releases.len(), 1);
1260        assert_eq!(releases[0].0, "v0.1.0");
1261    }
1262
1263    #[test]
1264    fn execute_bumps_version_files() {
1265        let dir = tempfile::tempdir().unwrap();
1266        let cargo_path = dir.path().join("Cargo.toml");
1267        std::fs::write(
1268            &cargo_path,
1269            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1270        )
1271        .unwrap();
1272
1273        let config = Config {
1274            changelog: ChangelogConfig {
1275                file: None,
1276                ..Default::default()
1277            },
1278            packages: vec![PackageConfig {
1279                path: ".".into(),
1280                version_files: vec![cargo_path.to_str().unwrap().to_string()],
1281                ..Default::default()
1282            }],
1283            ..Default::default()
1284        };
1285
1286        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1287        let plan = s.plan().unwrap();
1288        s.execute(&plan, false).unwrap();
1289
1290        // Verify the file was bumped
1291        let contents = std::fs::read_to_string(&cargo_path).unwrap();
1292        assert!(contents.contains("version = \"0.1.0\""));
1293
1294        // Verify it was staged alongside the commit
1295        let committed = s.git.committed.lock().unwrap();
1296        assert_eq!(committed.len(), 1);
1297        assert!(
1298            committed[0]
1299                .0
1300                .contains(&cargo_path.to_str().unwrap().to_string())
1301        );
1302    }
1303
1304    #[test]
1305    fn execute_stages_changelog_and_version_files_together() {
1306        let dir = tempfile::tempdir().unwrap();
1307        let cargo_path = dir.path().join("Cargo.toml");
1308        std::fs::write(
1309            &cargo_path,
1310            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1311        )
1312        .unwrap();
1313
1314        let changelog_path = dir.path().join("CHANGELOG.md");
1315
1316        let config = Config {
1317            changelog: ChangelogConfig {
1318                file: Some(changelog_path.to_str().unwrap().to_string()),
1319                ..Default::default()
1320            },
1321            packages: vec![PackageConfig {
1322                path: ".".into(),
1323                version_files: vec![cargo_path.to_str().unwrap().to_string()],
1324                ..Default::default()
1325            }],
1326            ..Default::default()
1327        };
1328
1329        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1330        let plan = s.plan().unwrap();
1331        s.execute(&plan, false).unwrap();
1332
1333        // Both changelog and version file should be staged in a single commit
1334        let committed = s.git.committed.lock().unwrap();
1335        assert_eq!(committed.len(), 1);
1336        assert!(
1337            committed[0]
1338                .0
1339                .contains(&changelog_path.to_str().unwrap().to_string())
1340        );
1341        assert!(
1342            committed[0]
1343                .0
1344                .contains(&cargo_path.to_str().unwrap().to_string())
1345        );
1346    }
1347
1348    // --- artifact upload tests ---
1349
1350    #[test]
1351    fn execute_uploads_artifacts() {
1352        let dir = tempfile::tempdir().unwrap();
1353        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1354        std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1355
1356        let config = Config {
1357            changelog: ChangelogConfig {
1358                file: None,
1359                ..Default::default()
1360            },
1361            packages: vec![PackageConfig {
1362                path: ".".into(),
1363                artifacts: vec![
1364                    dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1365                    dir.path().join("*.zip").to_str().unwrap().to_string(),
1366                ],
1367                ..Default::default()
1368            }],
1369            ..Default::default()
1370        };
1371
1372        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1373        let plan = s.plan().unwrap();
1374        s.execute(&plan, false).unwrap();
1375
1376        let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1377        assert_eq!(uploaded.len(), 1);
1378        assert_eq!(uploaded[0].0, "v0.1.0");
1379        assert_eq!(uploaded[0].1.len(), 2);
1380        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1381        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1382    }
1383
1384    #[test]
1385    fn execute_dry_run_shows_artifacts() {
1386        let dir = tempfile::tempdir().unwrap();
1387        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1388
1389        let config = Config {
1390            changelog: ChangelogConfig {
1391                file: None,
1392                ..Default::default()
1393            },
1394            packages: vec![PackageConfig {
1395                path: ".".into(),
1396                artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1397                ..Default::default()
1398            }],
1399            ..Default::default()
1400        };
1401
1402        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1403        let plan = s.plan().unwrap();
1404        s.execute(&plan, true).unwrap();
1405
1406        // No uploads should happen during dry-run
1407        let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1408        assert!(uploaded.is_empty());
1409    }
1410
1411    #[test]
1412    fn execute_no_artifacts_skips_upload() {
1413        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1414        let plan = s.plan().unwrap();
1415        s.execute(&plan, false).unwrap();
1416
1417        let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1418        assert!(uploaded.is_empty());
1419    }
1420
1421    #[test]
1422    fn resolve_globs_basic() {
1423        let dir = tempfile::tempdir().unwrap();
1424        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1425        std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1426        std::fs::create_dir(dir.path().join("subdir")).unwrap();
1427
1428        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1429        let result = resolve_globs(&[pattern]).unwrap();
1430        assert_eq!(result.len(), 2);
1431        assert!(result.iter().any(|f: &String| f.ends_with("a.txt")));
1432        assert!(result.iter().any(|f: &String| f.ends_with("b.txt")));
1433    }
1434
1435    #[test]
1436    fn resolve_globs_deduplicates() {
1437        let dir = tempfile::tempdir().unwrap();
1438        std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1439
1440        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1441        // Same pattern twice should not produce duplicates
1442        let result = resolve_globs(&[pattern.clone(), pattern]).unwrap();
1443        assert_eq!(result.len(), 1);
1444    }
1445
1446    // --- floating tags tests ---
1447
1448    #[test]
1449    fn plan_floating_tag_when_enabled() {
1450        let tag = TagInfo {
1451            name: "v3.2.0".into(),
1452            version: Version::new(3, 2, 0),
1453            sha: "d".repeat(40),
1454        };
1455        let config = config_with_git(GitConfig {
1456            floating_tag: true,
1457            ..Default::default()
1458        });
1459
1460        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1461        let plan = s.plan().unwrap();
1462        assert_eq!(plan.next_version, Version::new(3, 2, 1));
1463        assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1464    }
1465
1466    #[test]
1467    fn plan_no_floating_tag_when_disabled() {
1468        let s = make_strategy(
1469            vec![],
1470            vec![raw_commit("feat: something")],
1471            config_with_git(GitConfig {
1472                floating_tag: false,
1473                ..Default::default()
1474            }),
1475        );
1476        let plan = s.plan().unwrap();
1477        assert!(plan.floating_tag_name.is_none());
1478    }
1479
1480    #[test]
1481    fn plan_floating_tag_custom_prefix() {
1482        let tag = TagInfo {
1483            name: "release-2.5.0".into(),
1484            version: Version::new(2, 5, 0),
1485            sha: "e".repeat(40),
1486        };
1487        let config = config_with_git(GitConfig {
1488            floating_tag: true,
1489            tag_prefix: "release-".into(),
1490            ..Default::default()
1491        });
1492
1493        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1494        let plan = s.plan().unwrap();
1495        assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1496    }
1497
1498    #[test]
1499    fn execute_floating_tags_force_create_and_push() {
1500        let config = config_with_git(GitConfig {
1501            floating_tag: true,
1502            ..Default::default()
1503        });
1504
1505        let tag = TagInfo {
1506            name: "v1.2.3".into(),
1507            version: Version::new(1, 2, 3),
1508            sha: "f".repeat(40),
1509        };
1510        let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1511        let plan = s.plan().unwrap();
1512        assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1513
1514        s.execute(&plan, false).unwrap();
1515
1516        assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1517        assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1518    }
1519
1520    #[test]
1521    fn execute_no_floating_tags_when_disabled() {
1522        let s = make_strategy(
1523            vec![],
1524            vec![raw_commit("feat: something")],
1525            config_with_git(GitConfig {
1526                floating_tag: false,
1527                ..Default::default()
1528            }),
1529        );
1530        let plan = s.plan().unwrap();
1531        assert!(plan.floating_tag_name.is_none());
1532
1533        s.execute(&plan, false).unwrap();
1534
1535        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1536        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1537    }
1538
1539    #[test]
1540    fn execute_floating_tags_dry_run_no_side_effects() {
1541        let config = config_with_git(GitConfig {
1542            floating_tag: true,
1543            ..Default::default()
1544        });
1545
1546        let tag = TagInfo {
1547            name: "v2.0.0".into(),
1548            version: Version::new(2, 0, 0),
1549            sha: "a".repeat(40),
1550        };
1551        let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1552        let plan = s.plan().unwrap();
1553        assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1554
1555        s.execute(&plan, true).unwrap();
1556
1557        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1558        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1559    }
1560
1561    #[test]
1562    fn execute_floating_tags_idempotent() {
1563        let config = config_with_git(GitConfig {
1564            floating_tag: true,
1565            ..Default::default()
1566        });
1567
1568        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1569        let plan = s.plan().unwrap();
1570        assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1571
1572        // Run twice
1573        s.execute(&plan, false).unwrap();
1574        s.execute(&plan, false).unwrap();
1575
1576        // Force ops run every time (correct for floating tags)
1577        assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1578        assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1579    }
1580
1581    // --- force mode tests ---
1582
1583    #[test]
1584    fn force_rerelease_when_tag_at_head() {
1585        let tag = TagInfo {
1586            name: "v1.2.3".into(),
1587            version: Version::new(1, 2, 3),
1588            sha: "a".repeat(40),
1589        };
1590        let mut s = make_strategy(vec![tag], vec![], Config::default());
1591        // HEAD == tag SHA, and no new commits
1592        s.git.head = "a".repeat(40);
1593        s.force = true;
1594
1595        let plan = s.plan().unwrap();
1596        assert_eq!(plan.next_version, Version::new(1, 2, 3));
1597        assert_eq!(plan.tag_name, "v1.2.3");
1598        assert!(plan.commits.is_empty());
1599        assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1600    }
1601
1602    #[test]
1603    fn force_fails_when_tag_not_at_head() {
1604        let tag = TagInfo {
1605            name: "v1.2.3".into(),
1606            version: Version::new(1, 2, 3),
1607            sha: "a".repeat(40),
1608        };
1609        let mut s = make_strategy(vec![tag], vec![], Config::default());
1610        // HEAD != tag SHA
1611        s.git.head = "b".repeat(40);
1612        s.force = true;
1613
1614        let err = s.plan().unwrap_err();
1615        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1616    }
1617}