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