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::hooks::{HookCommand, HookContext, HookRunner};
13use crate::version::{BumpLevel, apply_bump, determine_bump};
14use crate::version_files::bump_version_file;
15
16/// Build a `HookContext` populated with `SR_*` environment variables from a release plan.
17fn build_hook_context(plan: &ReleasePlan, phase: &str, dry_run: bool) -> HookContext {
18    HookContext::default()
19        .set("SR_VERSION", plan.next_version.to_string())
20        .set(
21            "SR_PREVIOUS_VERSION",
22            plan.current_version
23                .as_ref()
24                .map(|v| v.to_string())
25                .unwrap_or_default(),
26        )
27        .set("SR_TAG", &plan.tag_name)
28        .set("SR_BUMP_LEVEL", plan.bump.to_string())
29        .set("SR_DRY_RUN", dry_run.to_string())
30        .set("SR_COMMIT_COUNT", plan.commits.len().to_string())
31        .set("SR_HOOK_PHASE", phase)
32        .set(
33            "SR_FLOATING_TAG",
34            plan.floating_tag_name.as_deref().unwrap_or(""),
35        )
36}
37
38/// The computed plan for a release, before execution.
39#[derive(Debug, Serialize)]
40pub struct ReleasePlan {
41    pub current_version: Option<Version>,
42    pub next_version: Version,
43    pub bump: BumpLevel,
44    pub commits: Vec<ConventionalCommit>,
45    pub tag_name: String,
46    pub floating_tag_name: Option<String>,
47}
48
49/// Orchestrates the release flow.
50pub trait ReleaseStrategy: Send + Sync {
51    /// Plan the release without executing it.
52    fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
53
54    /// Execute the release.
55    fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
56}
57
58/// Abstraction over a remote VCS provider (e.g. GitHub, GitLab).
59pub trait VcsProvider: Send + Sync {
60    /// Create a release on the remote VCS.
61    fn create_release(
62        &self,
63        tag: &str,
64        name: &str,
65        body: &str,
66        prerelease: bool,
67    ) -> Result<String, ReleaseError>;
68
69    /// Generate a compare URL between two refs.
70    fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
71
72    /// Check if a release already exists for the given tag.
73    fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
74
75    /// Delete a release by tag.
76    fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
77
78    /// Return the base URL of the repository (e.g. `https://github.com/owner/repo`).
79    fn repo_url(&self) -> Option<String> {
80        None
81    }
82
83    /// Upload asset files to an existing release identified by tag.
84    fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
85        Ok(())
86    }
87
88    /// Resolve git author names to GitHub `@username` strings.
89    /// Each entry in `author_shas` is `(author_name, commit_sha)`.
90    /// Returns a map of `author_name -> @username`. Failures are silently skipped.
91    fn resolve_contributors(
92        &self,
93        _author_shas: &[(&str, &str)],
94    ) -> std::collections::HashMap<String, String> {
95        std::collections::HashMap::new()
96    }
97}
98
99/// Concrete release strategy implementing the trunk-based release flow.
100pub struct TrunkReleaseStrategy<G, V, C, F, H> {
101    pub git: G,
102    pub vcs: Option<V>,
103    pub parser: C,
104    pub formatter: F,
105    pub hooks: H,
106    pub config: ReleaseConfig,
107}
108
109fn to_hook_commands(commands: &[String]) -> Vec<HookCommand> {
110    commands
111        .iter()
112        .map(|c| HookCommand { command: c.clone() })
113        .collect()
114}
115
116impl<G, V, C, F, H> TrunkReleaseStrategy<G, V, C, F, H>
117where
118    G: GitRepository,
119    V: VcsProvider,
120    C: CommitParser,
121    F: ChangelogFormatter,
122    H: HookRunner,
123{
124    fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
125        let today = today_string();
126        let mut entry = ChangelogEntry {
127            version: plan.next_version.to_string(),
128            date: today,
129            commits: plan.commits.clone(),
130            compare_url: None,
131            repo_url: self.vcs.as_ref().and_then(|v| v.repo_url()),
132            contributor_map: std::collections::HashMap::new(),
133        };
134        if let Some(ref vcs) = self.vcs {
135            let author_shas = entry.unique_author_shas();
136            entry.contributor_map = vcs.resolve_contributors(&author_shas);
137        }
138        self.formatter.format(&[entry])
139    }
140}
141
142impl<G, V, C, F, H> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F, H>
143where
144    G: GitRepository,
145    V: VcsProvider,
146    C: CommitParser,
147    F: ChangelogFormatter,
148    H: HookRunner,
149{
150    fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
151        let tag_info = self.git.latest_tag(&self.config.tag_prefix)?;
152
153        let (current_version, from_sha) = match &tag_info {
154            Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
155            None => (None, None),
156        };
157
158        let raw_commits = self.git.commits_since(from_sha)?;
159        if raw_commits.is_empty() {
160            return Err(ReleaseError::NoCommits);
161        }
162
163        let conventional_commits: Vec<ConventionalCommit> = raw_commits
164            .iter()
165            .filter_map(|c| self.parser.parse(c).ok())
166            .collect();
167
168        let classifier = DefaultCommitClassifier::new(
169            self.config.types.clone(),
170            self.config.commit_pattern.clone(),
171        );
172        let bump =
173            determine_bump(&conventional_commits, &classifier).ok_or(ReleaseError::NoBump)?;
174
175        let base_version = current_version.clone().unwrap_or(Version::new(0, 0, 0));
176        let next_version = apply_bump(&base_version, bump);
177        let tag_name = format!("{}{next_version}", self.config.tag_prefix);
178
179        let floating_tag_name = if self.config.floating_tags {
180            Some(format!("{}{}", self.config.tag_prefix, next_version.major))
181        } else {
182            None
183        };
184
185        Ok(ReleasePlan {
186            current_version,
187            next_version,
188            bump,
189            commits: conventional_commits,
190            tag_name,
191            floating_tag_name,
192        })
193    }
194
195    fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
196        if dry_run {
197            let changelog_body = self.format_changelog(plan)?;
198            let ctx = build_hook_context(plan, "pre_release", true);
199            let mut env_keys: Vec<&String> = ctx.env.keys().collect();
200            env_keys.sort();
201            eprintln!("[dry-run] Hook environment variables:");
202            for key in env_keys {
203                eprintln!("[dry-run]   {key}={}", ctx.env[key]);
204            }
205            eprintln!("[dry-run] Would create tag: {}", plan.tag_name);
206            eprintln!("[dry-run] Would push tag: {}", plan.tag_name);
207            if let Some(ref floating) = plan.floating_tag_name {
208                eprintln!("[dry-run] Would create/update floating tag: {floating}");
209                eprintln!("[dry-run] Would force-push floating tag: {floating}");
210            }
211            if self.vcs.is_some() {
212                eprintln!(
213                    "[dry-run] Would create GitHub release for {}",
214                    plan.tag_name
215                );
216            }
217            for file in &self.config.version_files {
218                let filename = Path::new(file)
219                    .file_name()
220                    .and_then(|n| n.to_str())
221                    .unwrap_or_default();
222                let supported = matches!(
223                    filename,
224                    "Cargo.toml"
225                        | "package.json"
226                        | "pyproject.toml"
227                        | "pom.xml"
228                        | "build.gradle"
229                        | "build.gradle.kts"
230                ) || filename.ends_with(".go");
231                if supported {
232                    eprintln!("[dry-run] Would bump version in: {file}");
233                } else if self.config.version_files_strict {
234                    return Err(ReleaseError::VersionBump(format!(
235                        "unsupported version file: {filename}"
236                    )));
237                } else {
238                    eprintln!("[dry-run] warning: unsupported version file, would skip: {file}");
239                }
240            }
241            if !self.config.artifacts.is_empty() {
242                let resolved = resolve_artifact_globs(&self.config.artifacts)?;
243                if resolved.is_empty() {
244                    eprintln!("[dry-run] Artifact patterns matched no files");
245                } else {
246                    eprintln!("[dry-run] Would upload {} artifact(s):", resolved.len());
247                    for f in &resolved {
248                        eprintln!("[dry-run]   {f}");
249                    }
250                }
251            }
252            for hook in &self.config.hooks.pre_release {
253                eprintln!("[dry-run] Would run pre-release hook: {hook}");
254            }
255            for hook in &self.config.hooks.post_tag {
256                eprintln!("[dry-run] Would run post-tag hook: {hook}");
257            }
258            for hook in &self.config.hooks.post_release {
259                eprintln!("[dry-run] Would run post-release hook: {hook}");
260            }
261            eprintln!("[dry-run] Changelog:\n{changelog_body}");
262            return Ok(());
263        }
264
265        let failure_ctx = build_hook_context(plan, "on_failure", false);
266        let run_failure_hooks = |err: ReleaseError| -> ReleaseError {
267            let _ = self.hooks.run(
268                &to_hook_commands(&self.config.hooks.on_failure),
269                &failure_ctx,
270            );
271            err
272        };
273
274        // 1. Pre-release hooks
275        let pre_ctx = build_hook_context(plan, "pre_release", false);
276        self.hooks
277            .run(&to_hook_commands(&self.config.hooks.pre_release), &pre_ctx)
278            .map_err(&run_failure_hooks)?;
279
280        // 2. Format changelog
281        let changelog_body = self.format_changelog(plan).map_err(&run_failure_hooks)?;
282
283        // 2.5 Bump version files
284        let version_str = plan.next_version.to_string();
285        let mut bumped_files: Vec<&str> = Vec::new();
286        for file in &self.config.version_files {
287            match bump_version_file(Path::new(file), &version_str) {
288                Ok(()) => bumped_files.push(file.as_str()),
289                Err(e) if !self.config.version_files_strict => {
290                    eprintln!("warning: {e} — skipping {file}");
291                }
292                Err(e) => return Err(run_failure_hooks(e)),
293            }
294        }
295
296        // 3. Write changelog file if configured
297        if let Some(ref changelog_file) = self.config.changelog.file {
298            let path = Path::new(changelog_file);
299            let existing = if path.exists() {
300                fs::read_to_string(path)
301                    .map_err(|e| ReleaseError::Changelog(e.to_string()))
302                    .map_err(&run_failure_hooks)?
303            } else {
304                String::new()
305            };
306            let new_content = if existing.is_empty() {
307                format!("# Changelog\n\n{changelog_body}\n")
308            } else {
309                // Insert after the first heading line
310                match existing.find("\n\n") {
311                    Some(pos) => {
312                        let (header, rest) = existing.split_at(pos);
313                        format!("{header}\n\n{changelog_body}\n{rest}")
314                    }
315                    None => format!("{existing}\n\n{changelog_body}\n"),
316                }
317            };
318            fs::write(path, new_content)
319                .map_err(|e| ReleaseError::Changelog(e.to_string()))
320                .map_err(&run_failure_hooks)?;
321        }
322
323        // 4. Stage and commit changelog + version files (skip if nothing to stage)
324        {
325            let mut paths_to_stage: Vec<&str> = Vec::new();
326            if let Some(ref changelog_file) = self.config.changelog.file {
327                paths_to_stage.push(changelog_file.as_str());
328            }
329            for file in &bumped_files {
330                paths_to_stage.push(*file);
331            }
332            if !paths_to_stage.is_empty() {
333                let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
334                self.git
335                    .stage_and_commit(&paths_to_stage, &commit_msg)
336                    .map_err(&run_failure_hooks)?;
337            }
338        }
339
340        // 5. Create tag (skip if it already exists locally)
341        if !self
342            .git
343            .tag_exists(&plan.tag_name)
344            .map_err(&run_failure_hooks)?
345        {
346            self.git
347                .create_tag(&plan.tag_name, &changelog_body)
348                .map_err(&run_failure_hooks)?;
349        }
350
351        // 6. Push commit (safe to re-run — no-op if up to date)
352        self.git.push().map_err(&run_failure_hooks)?;
353
354        // 7. Push tag (skip if tag already exists on remote)
355        if !self
356            .git
357            .remote_tag_exists(&plan.tag_name)
358            .map_err(&run_failure_hooks)?
359        {
360            self.git
361                .push_tag(&plan.tag_name)
362                .map_err(&run_failure_hooks)?;
363        }
364
365        // 7.5 Force-create and force-push floating tag (e.g. v3)
366        if let Some(ref floating) = plan.floating_tag_name {
367            let floating_msg = format!("Floating tag for {}", plan.tag_name);
368            self.git
369                .force_create_tag(floating, &floating_msg)
370                .map_err(&run_failure_hooks)?;
371            self.git
372                .force_push_tag(floating)
373                .map_err(&run_failure_hooks)?;
374        }
375
376        // 8. Post-tag hooks
377        let post_tag_ctx = build_hook_context(plan, "post_tag", false);
378        self.hooks
379            .run(
380                &to_hook_commands(&self.config.hooks.post_tag),
381                &post_tag_ctx,
382            )
383            .map_err(&run_failure_hooks)?;
384
385        // 9. Create GitHub release (skip if exists, or update it)
386        if let Some(ref vcs) = self.vcs {
387            let release_name = format!("{} {}", self.config.tag_prefix, plan.next_version);
388            if vcs
389                .release_exists(&plan.tag_name)
390                .map_err(&run_failure_hooks)?
391            {
392                // Delete and recreate to update the release notes
393                vcs.delete_release(&plan.tag_name)
394                    .map_err(&run_failure_hooks)?;
395            }
396            vcs.create_release(&plan.tag_name, &release_name, &changelog_body, false)
397                .map_err(&run_failure_hooks)?;
398        }
399
400        // 9.5 Upload artifacts
401        if let Some(ref vcs) = self.vcs
402            && !self.config.artifacts.is_empty()
403        {
404            let resolved =
405                resolve_artifact_globs(&self.config.artifacts).map_err(&run_failure_hooks)?;
406            if !resolved.is_empty() {
407                let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
408                vcs.upload_assets(&plan.tag_name, &file_refs)
409                    .map_err(&run_failure_hooks)?;
410                eprintln!(
411                    "Uploaded {} artifact(s) to {}",
412                    resolved.len(),
413                    plan.tag_name
414                );
415            }
416        }
417
418        // 10. Post-release hooks
419        let post_release_ctx = build_hook_context(plan, "post_release", false);
420        self.hooks
421            .run(
422                &to_hook_commands(&self.config.hooks.post_release),
423                &post_release_ctx,
424            )
425            .map_err(&run_failure_hooks)?;
426
427        eprintln!("Released {}", plan.tag_name);
428        Ok(())
429    }
430}
431
432fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
433    let mut files = std::collections::BTreeSet::new();
434    for pattern in patterns {
435        let paths = glob::glob(pattern)
436            .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
437        for entry in paths {
438            match entry {
439                Ok(path) if path.is_file() => {
440                    files.insert(path.to_string_lossy().into_owned());
441                }
442                Ok(_) => {} // skip directories
443                Err(e) => {
444                    eprintln!("warning: glob error: {e}");
445                }
446            }
447        }
448    }
449    Ok(files.into_iter().collect())
450}
451
452pub fn today_string() -> String {
453    // Use a simple approach: read from the `date` command or fallback
454    std::process::Command::new("date")
455        .arg("+%Y-%m-%d")
456        .output()
457        .ok()
458        .and_then(|o| {
459            if o.status.success() {
460                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
461            } else {
462                None
463            }
464        })
465        .unwrap_or_else(|| "unknown".to_string())
466}
467
468#[cfg(test)]
469mod tests {
470    use std::sync::Mutex;
471
472    use super::*;
473    use crate::changelog::DefaultChangelogFormatter;
474    use crate::commit::{Commit, DefaultCommitParser};
475    use crate::config::ReleaseConfig;
476    use crate::git::{GitRepository, TagInfo};
477    use crate::hooks::{HookCommand, HookContext, HookRunner};
478
479    // --- Fakes ---
480
481    struct FakeGit {
482        tags: Vec<TagInfo>,
483        commits: Vec<Commit>,
484        created_tags: Mutex<Vec<String>>,
485        pushed_tags: Mutex<Vec<String>>,
486        committed: Mutex<Vec<(Vec<String>, String)>>,
487        push_count: Mutex<u32>,
488        force_created_tags: Mutex<Vec<String>>,
489        force_pushed_tags: Mutex<Vec<String>>,
490    }
491
492    impl FakeGit {
493        fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
494            Self {
495                tags,
496                commits,
497                created_tags: Mutex::new(Vec::new()),
498                pushed_tags: Mutex::new(Vec::new()),
499                committed: Mutex::new(Vec::new()),
500                push_count: Mutex::new(0),
501                force_created_tags: Mutex::new(Vec::new()),
502                force_pushed_tags: Mutex::new(Vec::new()),
503            }
504        }
505    }
506
507    impl GitRepository for FakeGit {
508        fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
509            Ok(self.tags.last().cloned())
510        }
511
512        fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
513            Ok(self.commits.clone())
514        }
515
516        fn create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
517            self.created_tags.lock().unwrap().push(name.to_string());
518            Ok(())
519        }
520
521        fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
522            self.pushed_tags.lock().unwrap().push(name.to_string());
523            Ok(())
524        }
525
526        fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
527            self.committed.lock().unwrap().push((
528                paths.iter().map(|s| s.to_string()).collect(),
529                message.to_string(),
530            ));
531            Ok(true)
532        }
533
534        fn push(&self) -> Result<(), ReleaseError> {
535            *self.push_count.lock().unwrap() += 1;
536            Ok(())
537        }
538
539        fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
540            Ok(self
541                .created_tags
542                .lock()
543                .unwrap()
544                .contains(&name.to_string()))
545        }
546
547        fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
548            Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
549        }
550
551        fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
552            Ok(self.tags.clone())
553        }
554
555        fn commits_between(
556            &self,
557            _from: Option<&str>,
558            _to: &str,
559        ) -> Result<Vec<Commit>, ReleaseError> {
560            Ok(self.commits.clone())
561        }
562
563        fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
564            Ok("2026-01-01".into())
565        }
566
567        fn force_create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
568            self.force_created_tags
569                .lock()
570                .unwrap()
571                .push(name.to_string());
572            Ok(())
573        }
574
575        fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
576            self.force_pushed_tags
577                .lock()
578                .unwrap()
579                .push(name.to_string());
580            Ok(())
581        }
582    }
583
584    struct FakeVcs {
585        releases: Mutex<Vec<(String, String)>>,
586        deleted_releases: Mutex<Vec<String>>,
587        uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
588    }
589
590    impl FakeVcs {
591        fn new() -> Self {
592            Self {
593                releases: Mutex::new(Vec::new()),
594                deleted_releases: Mutex::new(Vec::new()),
595                uploaded_assets: Mutex::new(Vec::new()),
596            }
597        }
598    }
599
600    impl VcsProvider for FakeVcs {
601        fn create_release(
602            &self,
603            tag: &str,
604            _name: &str,
605            body: &str,
606            _prerelease: bool,
607        ) -> Result<String, ReleaseError> {
608            self.releases
609                .lock()
610                .unwrap()
611                .push((tag.to_string(), body.to_string()));
612            Ok(format!("https://github.com/test/release/{tag}"))
613        }
614
615        fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
616            Ok(format!("https://github.com/test/compare/{base}...{head}"))
617        }
618
619        fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
620            Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
621        }
622
623        fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
624            self.deleted_releases.lock().unwrap().push(tag.to_string());
625            self.releases.lock().unwrap().retain(|(t, _)| t != tag);
626            Ok(())
627        }
628
629        fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
630            self.uploaded_assets.lock().unwrap().push((
631                tag.to_string(),
632                files.iter().map(|s| s.to_string()).collect(),
633            ));
634            Ok(())
635        }
636
637        fn repo_url(&self) -> Option<String> {
638            Some("https://github.com/test/repo".into())
639        }
640    }
641
642    struct FakeHooks {
643        run_log: Mutex<Vec<String>>,
644        ctx_log: Mutex<Vec<HookContext>>,
645    }
646
647    impl FakeHooks {
648        fn new() -> Self {
649            Self {
650                run_log: Mutex::new(Vec::new()),
651                ctx_log: Mutex::new(Vec::new()),
652            }
653        }
654    }
655
656    impl HookRunner for FakeHooks {
657        fn run(&self, hooks: &[HookCommand], ctx: &HookContext) -> Result<(), ReleaseError> {
658            for h in hooks {
659                self.run_log.lock().unwrap().push(h.command.clone());
660            }
661            if !hooks.is_empty() {
662                self.ctx_log.lock().unwrap().push(ctx.clone());
663            }
664            Ok(())
665        }
666    }
667
668    // --- Helpers ---
669
670    fn raw_commit(msg: &str) -> Commit {
671        Commit {
672            sha: "a".repeat(40),
673            message: msg.into(),
674            author: None,
675        }
676    }
677
678    fn make_strategy(
679        tags: Vec<TagInfo>,
680        commits: Vec<Commit>,
681        config: ReleaseConfig,
682    ) -> TrunkReleaseStrategy<
683        FakeGit,
684        FakeVcs,
685        DefaultCommitParser,
686        DefaultChangelogFormatter,
687        FakeHooks,
688    > {
689        let types = config.types.clone();
690        let breaking_section = config.breaking_section.clone();
691        let misc_section = config.misc_section.clone();
692        TrunkReleaseStrategy {
693            git: FakeGit::new(tags, commits),
694            vcs: Some(FakeVcs::new()),
695            parser: DefaultCommitParser,
696            formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
697            hooks: FakeHooks::new(),
698            config,
699        }
700    }
701
702    // --- plan() tests ---
703
704    #[test]
705    fn plan_no_commits_returns_error() {
706        let s = make_strategy(vec![], vec![], ReleaseConfig::default());
707        let err = s.plan().unwrap_err();
708        assert!(matches!(err, ReleaseError::NoCommits));
709    }
710
711    #[test]
712    fn plan_no_releasable_returns_error() {
713        let s = make_strategy(
714            vec![],
715            vec![raw_commit("chore: tidy up")],
716            ReleaseConfig::default(),
717        );
718        let err = s.plan().unwrap_err();
719        assert!(matches!(err, ReleaseError::NoBump));
720    }
721
722    #[test]
723    fn plan_first_release() {
724        let s = make_strategy(
725            vec![],
726            vec![raw_commit("feat: initial feature")],
727            ReleaseConfig::default(),
728        );
729        let plan = s.plan().unwrap();
730        assert_eq!(plan.next_version, Version::new(0, 1, 0));
731        assert_eq!(plan.tag_name, "v0.1.0");
732        assert!(plan.current_version.is_none());
733    }
734
735    #[test]
736    fn plan_increments_existing() {
737        let tag = TagInfo {
738            name: "v1.2.3".into(),
739            version: Version::new(1, 2, 3),
740            sha: "b".repeat(40),
741        };
742        let s = make_strategy(
743            vec![tag],
744            vec![raw_commit("fix: patch bug")],
745            ReleaseConfig::default(),
746        );
747        let plan = s.plan().unwrap();
748        assert_eq!(plan.next_version, Version::new(1, 2, 4));
749    }
750
751    #[test]
752    fn plan_breaking_bump() {
753        let tag = TagInfo {
754            name: "v1.2.3".into(),
755            version: Version::new(1, 2, 3),
756            sha: "c".repeat(40),
757        };
758        let s = make_strategy(
759            vec![tag],
760            vec![raw_commit("feat!: breaking change")],
761            ReleaseConfig::default(),
762        );
763        let plan = s.plan().unwrap();
764        assert_eq!(plan.next_version, Version::new(2, 0, 0));
765    }
766
767    // --- execute() tests ---
768
769    #[test]
770    fn execute_dry_run_no_side_effects() {
771        let mut config = ReleaseConfig::default();
772        config.hooks.pre_release = vec!["echo pre".into()];
773        config.hooks.post_tag = vec!["echo post-tag".into()];
774        config.hooks.post_release = vec!["echo post-release".into()];
775
776        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
777        let plan = s.plan().unwrap();
778        s.execute(&plan, true).unwrap();
779
780        assert!(s.git.created_tags.lock().unwrap().is_empty());
781        assert!(s.git.pushed_tags.lock().unwrap().is_empty());
782        assert!(s.hooks.run_log.lock().unwrap().is_empty());
783    }
784
785    #[test]
786    fn execute_creates_and_pushes_tag() {
787        let s = make_strategy(
788            vec![],
789            vec![raw_commit("feat: something")],
790            ReleaseConfig::default(),
791        );
792        let plan = s.plan().unwrap();
793        s.execute(&plan, false).unwrap();
794
795        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
796        assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
797    }
798
799    #[test]
800    fn execute_runs_hooks_in_order() {
801        let mut config = ReleaseConfig::default();
802        config.hooks.pre_release = vec!["echo pre".into()];
803        config.hooks.post_tag = vec!["echo post-tag".into()];
804        config.hooks.post_release = vec!["echo post-release".into()];
805
806        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
807        let plan = s.plan().unwrap();
808        s.execute(&plan, false).unwrap();
809
810        let log = s.hooks.run_log.lock().unwrap();
811        assert_eq!(*log, vec!["echo pre", "echo post-tag", "echo post-release"]);
812    }
813
814    #[test]
815    fn execute_calls_vcs_create_release() {
816        let s = make_strategy(
817            vec![],
818            vec![raw_commit("feat: something")],
819            ReleaseConfig::default(),
820        );
821        let plan = s.plan().unwrap();
822        s.execute(&plan, false).unwrap();
823
824        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
825        assert_eq!(releases.len(), 1);
826        assert_eq!(releases[0].0, "v0.1.0");
827        assert!(!releases[0].1.is_empty());
828    }
829
830    #[test]
831    fn execute_commits_changelog_before_tag() {
832        let dir = tempfile::tempdir().unwrap();
833        let changelog_path = dir.path().join("CHANGELOG.md");
834
835        let mut config = ReleaseConfig::default();
836        config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
837
838        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
839        let plan = s.plan().unwrap();
840        s.execute(&plan, false).unwrap();
841
842        // Verify changelog was committed
843        let committed = s.git.committed.lock().unwrap();
844        assert_eq!(committed.len(), 1);
845        assert_eq!(
846            committed[0].0,
847            vec![changelog_path.to_str().unwrap().to_string()]
848        );
849        assert!(committed[0].1.contains("chore(release): v0.1.0"));
850
851        // Verify tag was created after commit
852        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
853    }
854
855    #[test]
856    fn execute_skips_existing_tag() {
857        let s = make_strategy(
858            vec![],
859            vec![raw_commit("feat: something")],
860            ReleaseConfig::default(),
861        );
862        let plan = s.plan().unwrap();
863
864        // Pre-populate the tag to simulate it already existing
865        s.git
866            .created_tags
867            .lock()
868            .unwrap()
869            .push("v0.1.0".to_string());
870
871        s.execute(&plan, false).unwrap();
872
873        // Tag should not be created again (still only the one we pre-populated)
874        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
875    }
876
877    #[test]
878    fn execute_skips_existing_release() {
879        let s = make_strategy(
880            vec![],
881            vec![raw_commit("feat: something")],
882            ReleaseConfig::default(),
883        );
884        let plan = s.plan().unwrap();
885
886        // Pre-populate a release to simulate it already existing
887        s.vcs
888            .as_ref()
889            .unwrap()
890            .releases
891            .lock()
892            .unwrap()
893            .push(("v0.1.0".to_string(), "old notes".to_string()));
894
895        s.execute(&plan, false).unwrap();
896
897        // Should have deleted the old release and created a new one
898        let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
899        assert_eq!(*deleted, vec!["v0.1.0"]);
900
901        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
902        assert_eq!(releases.len(), 1);
903        assert_eq!(releases[0].0, "v0.1.0");
904        assert_ne!(releases[0].1, "old notes");
905    }
906
907    #[test]
908    fn execute_idempotent_rerun() {
909        let s = make_strategy(
910            vec![],
911            vec![raw_commit("feat: something")],
912            ReleaseConfig::default(),
913        );
914        let plan = s.plan().unwrap();
915
916        // First run
917        s.execute(&plan, false).unwrap();
918
919        // Second run should also succeed (idempotent)
920        s.execute(&plan, false).unwrap();
921
922        // Tag should only have been created once (second run skips because tag_exists)
923        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
924
925        // Tag push should only happen once (second run skips because remote_tag_exists)
926        assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
927
928        // Push (commit) should happen twice (always safe)
929        assert_eq!(*s.git.push_count.lock().unwrap(), 2);
930
931        // Release should be deleted and recreated on second run
932        let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
933        assert_eq!(*deleted, vec!["v0.1.0"]);
934
935        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
936        // One entry: delete removed the first, create added a replacement
937        assert_eq!(releases.len(), 1);
938        assert_eq!(releases[0].0, "v0.1.0");
939    }
940
941    #[test]
942    fn execute_passes_hook_context_with_version_info() {
943        let tag = TagInfo {
944            name: "v1.2.3".into(),
945            version: Version::new(1, 2, 3),
946            sha: "b".repeat(40),
947        };
948        let mut config = ReleaseConfig::default();
949        config.hooks.pre_release = vec!["echo pre".into()];
950        config.hooks.post_tag = vec!["echo post-tag".into()];
951        config.hooks.post_release = vec!["echo post-release".into()];
952
953        let s = make_strategy(
954            vec![tag],
955            vec![raw_commit("feat: first"), raw_commit("fix: second")],
956            config,
957        );
958        let plan = s.plan().unwrap();
959        s.execute(&plan, false).unwrap();
960
961        let ctx_log = s.hooks.ctx_log.lock().unwrap();
962        assert_eq!(ctx_log.len(), 3);
963
964        // pre_release context
965        let pre = &ctx_log[0];
966        assert_eq!(pre.env.get("SR_VERSION").unwrap(), "1.3.0");
967        assert_eq!(pre.env.get("SR_PREVIOUS_VERSION").unwrap(), "1.2.3");
968        assert_eq!(pre.env.get("SR_TAG").unwrap(), "v1.3.0");
969        assert_eq!(pre.env.get("SR_BUMP_LEVEL").unwrap(), "minor");
970        assert_eq!(pre.env.get("SR_DRY_RUN").unwrap(), "false");
971        assert_eq!(pre.env.get("SR_COMMIT_COUNT").unwrap(), "2");
972        assert_eq!(pre.env.get("SR_HOOK_PHASE").unwrap(), "pre_release");
973
974        // post_tag context
975        let post_tag = &ctx_log[1];
976        assert_eq!(post_tag.env.get("SR_HOOK_PHASE").unwrap(), "post_tag");
977        assert_eq!(post_tag.env.get("SR_VERSION").unwrap(), "1.3.0");
978
979        // post_release context
980        let post_release = &ctx_log[2];
981        assert_eq!(
982            post_release.env.get("SR_HOOK_PHASE").unwrap(),
983            "post_release"
984        );
985        assert_eq!(post_release.env.get("SR_VERSION").unwrap(), "1.3.0");
986    }
987
988    #[test]
989    fn hook_context_first_release_has_empty_previous_version() {
990        let mut config = ReleaseConfig::default();
991        config.hooks.pre_release = vec!["echo pre".into()];
992
993        let s = make_strategy(vec![], vec![raw_commit("feat: initial")], config);
994        let plan = s.plan().unwrap();
995        s.execute(&plan, false).unwrap();
996
997        let ctx_log = s.hooks.ctx_log.lock().unwrap();
998        assert!(!ctx_log.is_empty());
999        let pre = &ctx_log[0];
1000        assert_eq!(pre.env.get("SR_PREVIOUS_VERSION").unwrap(), "");
1001        assert_eq!(pre.env.get("SR_VERSION").unwrap(), "0.1.0");
1002    }
1003
1004    #[test]
1005    fn execute_bumps_version_files() {
1006        let dir = tempfile::tempdir().unwrap();
1007        let cargo_path = dir.path().join("Cargo.toml");
1008        std::fs::write(
1009            &cargo_path,
1010            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1011        )
1012        .unwrap();
1013
1014        let mut config = ReleaseConfig::default();
1015        config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1016
1017        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1018        let plan = s.plan().unwrap();
1019        s.execute(&plan, false).unwrap();
1020
1021        // Verify the file was bumped
1022        let contents = std::fs::read_to_string(&cargo_path).unwrap();
1023        assert!(contents.contains("version = \"0.1.0\""));
1024
1025        // Verify it was staged alongside the commit
1026        let committed = s.git.committed.lock().unwrap();
1027        assert_eq!(committed.len(), 1);
1028        assert!(
1029            committed[0]
1030                .0
1031                .contains(&cargo_path.to_str().unwrap().to_string())
1032        );
1033    }
1034
1035    #[test]
1036    fn execute_stages_changelog_and_version_files_together() {
1037        let dir = tempfile::tempdir().unwrap();
1038        let cargo_path = dir.path().join("Cargo.toml");
1039        std::fs::write(
1040            &cargo_path,
1041            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1042        )
1043        .unwrap();
1044
1045        let changelog_path = dir.path().join("CHANGELOG.md");
1046
1047        let mut config = ReleaseConfig::default();
1048        config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
1049        config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
1050
1051        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1052        let plan = s.plan().unwrap();
1053        s.execute(&plan, false).unwrap();
1054
1055        // Both changelog and version file should be staged in a single commit
1056        let committed = s.git.committed.lock().unwrap();
1057        assert_eq!(committed.len(), 1);
1058        assert!(
1059            committed[0]
1060                .0
1061                .contains(&changelog_path.to_str().unwrap().to_string())
1062        );
1063        assert!(
1064            committed[0]
1065                .0
1066                .contains(&cargo_path.to_str().unwrap().to_string())
1067        );
1068    }
1069
1070    // --- artifact upload tests ---
1071
1072    #[test]
1073    fn execute_uploads_artifacts() {
1074        let dir = tempfile::tempdir().unwrap();
1075        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1076        std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1077
1078        let mut config = ReleaseConfig::default();
1079        config.artifacts = vec![
1080            dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1081            dir.path().join("*.zip").to_str().unwrap().to_string(),
1082        ];
1083
1084        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1085        let plan = s.plan().unwrap();
1086        s.execute(&plan, false).unwrap();
1087
1088        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1089        assert_eq!(uploaded.len(), 1);
1090        assert_eq!(uploaded[0].0, "v0.1.0");
1091        assert_eq!(uploaded[0].1.len(), 2);
1092        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
1093        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
1094    }
1095
1096    #[test]
1097    fn execute_dry_run_shows_artifacts() {
1098        let dir = tempfile::tempdir().unwrap();
1099        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1100
1101        let mut config = ReleaseConfig::default();
1102        config.artifacts = vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()];
1103
1104        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1105        let plan = s.plan().unwrap();
1106        s.execute(&plan, true).unwrap();
1107
1108        // No uploads should happen during dry-run
1109        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1110        assert!(uploaded.is_empty());
1111    }
1112
1113    #[test]
1114    fn execute_no_artifacts_skips_upload() {
1115        let s = make_strategy(
1116            vec![],
1117            vec![raw_commit("feat: something")],
1118            ReleaseConfig::default(),
1119        );
1120        let plan = s.plan().unwrap();
1121        s.execute(&plan, false).unwrap();
1122
1123        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
1124        assert!(uploaded.is_empty());
1125    }
1126
1127    #[test]
1128    fn resolve_artifact_globs_basic() {
1129        let dir = tempfile::tempdir().unwrap();
1130        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1131        std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1132        std::fs::create_dir(dir.path().join("subdir")).unwrap();
1133
1134        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1135        let result = resolve_artifact_globs(&[pattern]).unwrap();
1136        assert_eq!(result.len(), 2);
1137        assert!(result.iter().any(|f| f.ends_with("a.txt")));
1138        assert!(result.iter().any(|f| f.ends_with("b.txt")));
1139    }
1140
1141    #[test]
1142    fn resolve_artifact_globs_deduplicates() {
1143        let dir = tempfile::tempdir().unwrap();
1144        std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1145
1146        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1147        // Same pattern twice should not produce duplicates
1148        let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
1149        assert_eq!(result.len(), 1);
1150    }
1151
1152    // --- floating tags tests ---
1153
1154    #[test]
1155    fn plan_floating_tag_when_enabled() {
1156        let tag = TagInfo {
1157            name: "v3.2.0".into(),
1158            version: Version::new(3, 2, 0),
1159            sha: "d".repeat(40),
1160        };
1161        let mut config = ReleaseConfig::default();
1162        config.floating_tags = true;
1163
1164        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1165        let plan = s.plan().unwrap();
1166        assert_eq!(plan.next_version, Version::new(3, 2, 1));
1167        assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1168    }
1169
1170    #[test]
1171    fn plan_no_floating_tag_when_disabled() {
1172        let s = make_strategy(
1173            vec![],
1174            vec![raw_commit("feat: something")],
1175            ReleaseConfig::default(),
1176        );
1177        let plan = s.plan().unwrap();
1178        assert!(plan.floating_tag_name.is_none());
1179    }
1180
1181    #[test]
1182    fn plan_floating_tag_custom_prefix() {
1183        let tag = TagInfo {
1184            name: "release-2.5.0".into(),
1185            version: Version::new(2, 5, 0),
1186            sha: "e".repeat(40),
1187        };
1188        let mut config = ReleaseConfig::default();
1189        config.floating_tags = true;
1190        config.tag_prefix = "release-".into();
1191
1192        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1193        let plan = s.plan().unwrap();
1194        assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1195    }
1196
1197    #[test]
1198    fn execute_floating_tags_force_create_and_push() {
1199        let mut config = ReleaseConfig::default();
1200        config.floating_tags = true;
1201
1202        let tag = TagInfo {
1203            name: "v1.2.3".into(),
1204            version: Version::new(1, 2, 3),
1205            sha: "f".repeat(40),
1206        };
1207        let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1208        let plan = s.plan().unwrap();
1209        assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1210
1211        s.execute(&plan, false).unwrap();
1212
1213        assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1214        assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1215    }
1216
1217    #[test]
1218    fn execute_no_floating_tags_when_disabled() {
1219        let s = make_strategy(
1220            vec![],
1221            vec![raw_commit("feat: something")],
1222            ReleaseConfig::default(),
1223        );
1224        let plan = s.plan().unwrap();
1225        assert!(plan.floating_tag_name.is_none());
1226
1227        s.execute(&plan, false).unwrap();
1228
1229        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1230        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1231    }
1232
1233    #[test]
1234    fn execute_floating_tags_dry_run_no_side_effects() {
1235        let mut config = ReleaseConfig::default();
1236        config.floating_tags = true;
1237
1238        let tag = TagInfo {
1239            name: "v2.0.0".into(),
1240            version: Version::new(2, 0, 0),
1241            sha: "a".repeat(40),
1242        };
1243        let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1244        let plan = s.plan().unwrap();
1245        assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1246
1247        s.execute(&plan, true).unwrap();
1248
1249        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1250        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1251    }
1252
1253    #[test]
1254    fn execute_floating_tags_idempotent() {
1255        let mut config = ReleaseConfig::default();
1256        config.floating_tags = true;
1257
1258        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1259        let plan = s.plan().unwrap();
1260        assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1261
1262        // Run twice
1263        s.execute(&plan, false).unwrap();
1264        s.execute(&plan, false).unwrap();
1265
1266        // Force ops run every time (correct for floating tags)
1267        assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1268        assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1269    }
1270
1271    #[test]
1272    fn hook_context_includes_floating_tag() {
1273        let mut config = ReleaseConfig::default();
1274        config.floating_tags = true;
1275        config.hooks.pre_release = vec!["echo pre".into()];
1276
1277        let tag = TagInfo {
1278            name: "v3.0.0".into(),
1279            version: Version::new(3, 0, 0),
1280            sha: "b".repeat(40),
1281        };
1282        let s = make_strategy(vec![tag], vec![raw_commit("feat: new")], config);
1283        let plan = s.plan().unwrap();
1284        s.execute(&plan, false).unwrap();
1285
1286        let ctx_log = s.hooks.ctx_log.lock().unwrap();
1287        assert!(!ctx_log.is_empty());
1288        assert_eq!(ctx_log[0].env.get("SR_FLOATING_TAG").unwrap(), "v3");
1289    }
1290
1291    #[test]
1292    fn hook_context_floating_tag_empty_when_disabled() {
1293        let mut config = ReleaseConfig::default();
1294        config.hooks.pre_release = vec!["echo pre".into()];
1295
1296        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1297        let plan = s.plan().unwrap();
1298        s.execute(&plan, false).unwrap();
1299
1300        let ctx_log = s.hooks.ctx_log.lock().unwrap();
1301        assert!(!ctx_log.is_empty());
1302        assert_eq!(ctx_log[0].env.get("SR_FLOATING_TAG").unwrap(), "");
1303    }
1304}