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