Skip to main content

sr_core/
release.rs

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