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