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