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