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