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            if let Some(ref cmd) = self.config.build_command {
233                eprintln!("[dry-run] Would run build command: {cmd}");
234            }
235            eprintln!("[dry-run] Changelog:\n{changelog_body}");
236            return Ok(());
237        }
238
239        // 1. Format changelog
240        let changelog_body = self.format_changelog(plan)?;
241
242        // 2. Bump version files
243        let version_str = plan.next_version.to_string();
244        let mut bumped_files: Vec<&str> = Vec::new();
245        for file in &self.config.version_files {
246            match bump_version_file(Path::new(file), &version_str) {
247                Ok(()) => bumped_files.push(file.as_str()),
248                Err(e) if !self.config.version_files_strict => {
249                    eprintln!("warning: {e} — skipping {file}");
250                }
251                Err(e) => return Err(e),
252            }
253        }
254
255        // 3. Write changelog file if configured
256        if let Some(ref changelog_file) = self.config.changelog.file {
257            let path = Path::new(changelog_file);
258            let existing = if path.exists() {
259                fs::read_to_string(path).map_err(|e| ReleaseError::Changelog(e.to_string()))?
260            } else {
261                String::new()
262            };
263            let new_content = if existing.is_empty() {
264                format!("# Changelog\n\n{changelog_body}\n")
265            } else {
266                // Insert after the first heading line
267                match existing.find("\n\n") {
268                    Some(pos) => {
269                        let (header, rest) = existing.split_at(pos);
270                        format!("{header}\n\n{changelog_body}\n{rest}")
271                    }
272                    None => format!("{existing}\n\n{changelog_body}\n"),
273                }
274            };
275            fs::write(path, new_content).map_err(|e| ReleaseError::Changelog(e.to_string()))?;
276        }
277
278        // 3.5. Run build command if configured
279        if let Some(ref cmd) = self.config.build_command {
280            eprintln!("Running build command: {cmd}");
281            let status = std::process::Command::new("sh")
282                .args(["-c", cmd])
283                .env("SR_VERSION", &version_str)
284                .env("SR_TAG", &plan.tag_name)
285                .status()
286                .map_err(|e| ReleaseError::BuildCommand(e.to_string()))?;
287            if !status.success() {
288                return Err(ReleaseError::BuildCommand(format!(
289                    "command exited with {}",
290                    status.code().unwrap_or(-1)
291                )));
292            }
293        }
294
295        // 4. Stage and commit changelog + version files (skip if nothing to stage)
296        {
297            let mut paths_to_stage: Vec<&str> = Vec::new();
298            if let Some(ref changelog_file) = self.config.changelog.file {
299                paths_to_stage.push(changelog_file.as_str());
300            }
301            for file in &bumped_files {
302                paths_to_stage.push(*file);
303            }
304            if !paths_to_stage.is_empty() {
305                let commit_msg = format!("chore(release): {} [skip ci]", plan.tag_name);
306                self.git.stage_and_commit(&paths_to_stage, &commit_msg)?;
307            }
308        }
309
310        // 5. Create tag (skip if it already exists locally)
311        if !self.git.tag_exists(&plan.tag_name)? {
312            self.git.create_tag(&plan.tag_name, &changelog_body)?;
313        }
314
315        // 6. Push commit (safe to re-run — no-op if up to date)
316        self.git.push()?;
317
318        // 7. Push tag (skip if tag already exists on remote)
319        if !self.git.remote_tag_exists(&plan.tag_name)? {
320            self.git.push_tag(&plan.tag_name)?;
321        }
322
323        // 8. Force-create and force-push floating tag (e.g. v3)
324        if let Some(ref floating) = plan.floating_tag_name {
325            let floating_msg = format!("Floating tag for {}", plan.tag_name);
326            self.git.force_create_tag(floating, &floating_msg)?;
327            self.git.force_push_tag(floating)?;
328        }
329
330        // 9. Create GitHub release (skip if exists, or update it)
331        if let Some(ref vcs) = self.vcs {
332            let release_name = format!("{} {}", self.config.tag_prefix, plan.next_version);
333            if vcs.release_exists(&plan.tag_name)? {
334                // Delete and recreate to update the release notes
335                vcs.delete_release(&plan.tag_name)?;
336            }
337            vcs.create_release(&plan.tag_name, &release_name, &changelog_body, false)?;
338        }
339
340        // 10. Upload artifacts
341        if let Some(ref vcs) = self.vcs
342            && !self.config.artifacts.is_empty()
343        {
344            let resolved = resolve_artifact_globs(&self.config.artifacts)?;
345            if !resolved.is_empty() {
346                let file_refs: Vec<&str> = resolved.iter().map(|s| s.as_str()).collect();
347                vcs.upload_assets(&plan.tag_name, &file_refs)?;
348                eprintln!(
349                    "Uploaded {} artifact(s) to {}",
350                    resolved.len(),
351                    plan.tag_name
352                );
353            }
354        }
355
356        eprintln!("Released {}", plan.tag_name);
357        Ok(())
358    }
359}
360
361fn resolve_artifact_globs(patterns: &[String]) -> Result<Vec<String>, ReleaseError> {
362    let mut files = std::collections::BTreeSet::new();
363    for pattern in patterns {
364        let paths = glob::glob(pattern)
365            .map_err(|e| ReleaseError::Vcs(format!("invalid glob pattern '{pattern}': {e}")))?;
366        for entry in paths {
367            match entry {
368                Ok(path) if path.is_file() => {
369                    files.insert(path.to_string_lossy().into_owned());
370                }
371                Ok(_) => {} // skip directories
372                Err(e) => {
373                    eprintln!("warning: glob error: {e}");
374                }
375            }
376        }
377    }
378    Ok(files.into_iter().collect())
379}
380
381pub fn today_string() -> String {
382    // Use a simple approach: read from the `date` command or fallback
383    std::process::Command::new("date")
384        .arg("+%Y-%m-%d")
385        .output()
386        .ok()
387        .and_then(|o| {
388            if o.status.success() {
389                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
390            } else {
391                None
392            }
393        })
394        .unwrap_or_else(|| "unknown".to_string())
395}
396
397#[cfg(test)]
398mod tests {
399    use std::sync::Mutex;
400
401    use super::*;
402    use crate::changelog::DefaultChangelogFormatter;
403    use crate::commit::{Commit, DefaultCommitParser};
404    use crate::config::ReleaseConfig;
405    use crate::git::{GitRepository, TagInfo};
406
407    // --- Fakes ---
408
409    struct FakeGit {
410        tags: Vec<TagInfo>,
411        commits: Vec<Commit>,
412        head: String,
413        created_tags: Mutex<Vec<String>>,
414        pushed_tags: Mutex<Vec<String>>,
415        committed: Mutex<Vec<(Vec<String>, String)>>,
416        push_count: Mutex<u32>,
417        force_created_tags: Mutex<Vec<String>>,
418        force_pushed_tags: Mutex<Vec<String>>,
419    }
420
421    impl FakeGit {
422        fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
423            let head = tags
424                .last()
425                .map(|t| t.sha.clone())
426                .unwrap_or_else(|| "0".repeat(40));
427            Self {
428                tags,
429                commits,
430                head,
431                created_tags: Mutex::new(Vec::new()),
432                pushed_tags: Mutex::new(Vec::new()),
433                committed: Mutex::new(Vec::new()),
434                push_count: Mutex::new(0),
435                force_created_tags: Mutex::new(Vec::new()),
436                force_pushed_tags: Mutex::new(Vec::new()),
437            }
438        }
439    }
440
441    impl GitRepository for FakeGit {
442        fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
443            Ok(self.tags.last().cloned())
444        }
445
446        fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
447            Ok(self.commits.clone())
448        }
449
450        fn create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
451            self.created_tags.lock().unwrap().push(name.to_string());
452            Ok(())
453        }
454
455        fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
456            self.pushed_tags.lock().unwrap().push(name.to_string());
457            Ok(())
458        }
459
460        fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
461            self.committed.lock().unwrap().push((
462                paths.iter().map(|s| s.to_string()).collect(),
463                message.to_string(),
464            ));
465            Ok(true)
466        }
467
468        fn push(&self) -> Result<(), ReleaseError> {
469            *self.push_count.lock().unwrap() += 1;
470            Ok(())
471        }
472
473        fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
474            Ok(self
475                .created_tags
476                .lock()
477                .unwrap()
478                .contains(&name.to_string()))
479        }
480
481        fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
482            Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
483        }
484
485        fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
486            Ok(self.tags.clone())
487        }
488
489        fn commits_between(
490            &self,
491            _from: Option<&str>,
492            _to: &str,
493        ) -> Result<Vec<Commit>, ReleaseError> {
494            Ok(self.commits.clone())
495        }
496
497        fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
498            Ok("2026-01-01".into())
499        }
500
501        fn force_create_tag(&self, name: &str, _message: &str) -> Result<(), ReleaseError> {
502            self.force_created_tags
503                .lock()
504                .unwrap()
505                .push(name.to_string());
506            Ok(())
507        }
508
509        fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
510            self.force_pushed_tags
511                .lock()
512                .unwrap()
513                .push(name.to_string());
514            Ok(())
515        }
516
517        fn head_sha(&self) -> Result<String, ReleaseError> {
518            Ok(self.head.clone())
519        }
520    }
521
522    struct FakeVcs {
523        releases: Mutex<Vec<(String, String)>>,
524        deleted_releases: Mutex<Vec<String>>,
525        uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
526    }
527
528    impl FakeVcs {
529        fn new() -> Self {
530            Self {
531                releases: Mutex::new(Vec::new()),
532                deleted_releases: Mutex::new(Vec::new()),
533                uploaded_assets: Mutex::new(Vec::new()),
534            }
535        }
536    }
537
538    impl VcsProvider for FakeVcs {
539        fn create_release(
540            &self,
541            tag: &str,
542            _name: &str,
543            body: &str,
544            _prerelease: bool,
545        ) -> Result<String, ReleaseError> {
546            self.releases
547                .lock()
548                .unwrap()
549                .push((tag.to_string(), body.to_string()));
550            Ok(format!("https://github.com/test/release/{tag}"))
551        }
552
553        fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
554            Ok(format!("https://github.com/test/compare/{base}...{head}"))
555        }
556
557        fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
558            Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
559        }
560
561        fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
562            self.deleted_releases.lock().unwrap().push(tag.to_string());
563            self.releases.lock().unwrap().retain(|(t, _)| t != tag);
564            Ok(())
565        }
566
567        fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
568            self.uploaded_assets.lock().unwrap().push((
569                tag.to_string(),
570                files.iter().map(|s| s.to_string()).collect(),
571            ));
572            Ok(())
573        }
574
575        fn repo_url(&self) -> Option<String> {
576            Some("https://github.com/test/repo".into())
577        }
578    }
579
580    // --- Helpers ---
581
582    fn raw_commit(msg: &str) -> Commit {
583        Commit {
584            sha: "a".repeat(40),
585            message: msg.into(),
586        }
587    }
588
589    fn make_strategy(
590        tags: Vec<TagInfo>,
591        commits: Vec<Commit>,
592        config: ReleaseConfig,
593    ) -> TrunkReleaseStrategy<FakeGit, FakeVcs, DefaultCommitParser, DefaultChangelogFormatter>
594    {
595        let types = config.types.clone();
596        let breaking_section = config.breaking_section.clone();
597        let misc_section = config.misc_section.clone();
598        TrunkReleaseStrategy {
599            git: FakeGit::new(tags, commits),
600            vcs: Some(FakeVcs::new()),
601            parser: DefaultCommitParser,
602            formatter: DefaultChangelogFormatter::new(None, types, breaking_section, misc_section),
603            config,
604            force: false,
605        }
606    }
607
608    // --- plan() tests ---
609
610    #[test]
611    fn plan_no_commits_returns_error() {
612        let s = make_strategy(vec![], vec![], ReleaseConfig::default());
613        let err = s.plan().unwrap_err();
614        assert!(matches!(err, ReleaseError::NoCommits { .. }));
615    }
616
617    #[test]
618    fn plan_no_releasable_returns_error() {
619        let s = make_strategy(
620            vec![],
621            vec![raw_commit("chore: tidy up")],
622            ReleaseConfig::default(),
623        );
624        let err = s.plan().unwrap_err();
625        assert!(matches!(err, ReleaseError::NoBump { .. }));
626    }
627
628    #[test]
629    fn plan_first_release() {
630        let s = make_strategy(
631            vec![],
632            vec![raw_commit("feat: initial feature")],
633            ReleaseConfig::default(),
634        );
635        let plan = s.plan().unwrap();
636        assert_eq!(plan.next_version, Version::new(0, 1, 0));
637        assert_eq!(plan.tag_name, "v0.1.0");
638        assert!(plan.current_version.is_none());
639    }
640
641    #[test]
642    fn plan_increments_existing() {
643        let tag = TagInfo {
644            name: "v1.2.3".into(),
645            version: Version::new(1, 2, 3),
646            sha: "b".repeat(40),
647        };
648        let s = make_strategy(
649            vec![tag],
650            vec![raw_commit("fix: patch bug")],
651            ReleaseConfig::default(),
652        );
653        let plan = s.plan().unwrap();
654        assert_eq!(plan.next_version, Version::new(1, 2, 4));
655    }
656
657    #[test]
658    fn plan_breaking_bump() {
659        let tag = TagInfo {
660            name: "v1.2.3".into(),
661            version: Version::new(1, 2, 3),
662            sha: "c".repeat(40),
663        };
664        let s = make_strategy(
665            vec![tag],
666            vec![raw_commit("feat!: breaking change")],
667            ReleaseConfig::default(),
668        );
669        let plan = s.plan().unwrap();
670        assert_eq!(plan.next_version, Version::new(2, 0, 0));
671    }
672
673    // --- execute() tests ---
674
675    #[test]
676    fn execute_dry_run_no_side_effects() {
677        let s = make_strategy(
678            vec![],
679            vec![raw_commit("feat: something")],
680            ReleaseConfig::default(),
681        );
682        let plan = s.plan().unwrap();
683        s.execute(&plan, true).unwrap();
684
685        assert!(s.git.created_tags.lock().unwrap().is_empty());
686        assert!(s.git.pushed_tags.lock().unwrap().is_empty());
687    }
688
689    #[test]
690    fn execute_creates_and_pushes_tag() {
691        let s = make_strategy(
692            vec![],
693            vec![raw_commit("feat: something")],
694            ReleaseConfig::default(),
695        );
696        let plan = s.plan().unwrap();
697        s.execute(&plan, false).unwrap();
698
699        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
700        assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
701    }
702
703    #[test]
704    fn execute_calls_vcs_create_release() {
705        let s = make_strategy(
706            vec![],
707            vec![raw_commit("feat: something")],
708            ReleaseConfig::default(),
709        );
710        let plan = s.plan().unwrap();
711        s.execute(&plan, false).unwrap();
712
713        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
714        assert_eq!(releases.len(), 1);
715        assert_eq!(releases[0].0, "v0.1.0");
716        assert!(!releases[0].1.is_empty());
717    }
718
719    #[test]
720    fn execute_commits_changelog_before_tag() {
721        let dir = tempfile::tempdir().unwrap();
722        let changelog_path = dir.path().join("CHANGELOG.md");
723
724        let mut config = ReleaseConfig::default();
725        config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
726
727        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
728        let plan = s.plan().unwrap();
729        s.execute(&plan, false).unwrap();
730
731        // Verify changelog was committed
732        let committed = s.git.committed.lock().unwrap();
733        assert_eq!(committed.len(), 1);
734        assert_eq!(
735            committed[0].0,
736            vec![changelog_path.to_str().unwrap().to_string()]
737        );
738        assert!(committed[0].1.contains("chore(release): v0.1.0"));
739
740        // Verify tag was created after commit
741        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
742    }
743
744    #[test]
745    fn execute_skips_existing_tag() {
746        let s = make_strategy(
747            vec![],
748            vec![raw_commit("feat: something")],
749            ReleaseConfig::default(),
750        );
751        let plan = s.plan().unwrap();
752
753        // Pre-populate the tag to simulate it already existing
754        s.git
755            .created_tags
756            .lock()
757            .unwrap()
758            .push("v0.1.0".to_string());
759
760        s.execute(&plan, false).unwrap();
761
762        // Tag should not be created again (still only the one we pre-populated)
763        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
764    }
765
766    #[test]
767    fn execute_skips_existing_release() {
768        let s = make_strategy(
769            vec![],
770            vec![raw_commit("feat: something")],
771            ReleaseConfig::default(),
772        );
773        let plan = s.plan().unwrap();
774
775        // Pre-populate a release to simulate it already existing
776        s.vcs
777            .as_ref()
778            .unwrap()
779            .releases
780            .lock()
781            .unwrap()
782            .push(("v0.1.0".to_string(), "old notes".to_string()));
783
784        s.execute(&plan, false).unwrap();
785
786        // Should have deleted the old release and created a new one
787        let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
788        assert_eq!(*deleted, vec!["v0.1.0"]);
789
790        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
791        assert_eq!(releases.len(), 1);
792        assert_eq!(releases[0].0, "v0.1.0");
793        assert_ne!(releases[0].1, "old notes");
794    }
795
796    #[test]
797    fn execute_idempotent_rerun() {
798        let s = make_strategy(
799            vec![],
800            vec![raw_commit("feat: something")],
801            ReleaseConfig::default(),
802        );
803        let plan = s.plan().unwrap();
804
805        // First run
806        s.execute(&plan, false).unwrap();
807
808        // Second run should also succeed (idempotent)
809        s.execute(&plan, false).unwrap();
810
811        // Tag should only have been created once (second run skips because tag_exists)
812        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
813
814        // Tag push should only happen once (second run skips because remote_tag_exists)
815        assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
816
817        // Push (commit) should happen twice (always safe)
818        assert_eq!(*s.git.push_count.lock().unwrap(), 2);
819
820        // Release should be deleted and recreated on second run
821        let deleted = s.vcs.as_ref().unwrap().deleted_releases.lock().unwrap();
822        assert_eq!(*deleted, vec!["v0.1.0"]);
823
824        let releases = s.vcs.as_ref().unwrap().releases.lock().unwrap();
825        // One entry: delete removed the first, create added a replacement
826        assert_eq!(releases.len(), 1);
827        assert_eq!(releases[0].0, "v0.1.0");
828    }
829
830    #[test]
831    fn execute_bumps_version_files() {
832        let dir = tempfile::tempdir().unwrap();
833        let cargo_path = dir.path().join("Cargo.toml");
834        std::fs::write(
835            &cargo_path,
836            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
837        )
838        .unwrap();
839
840        let mut config = ReleaseConfig::default();
841        config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
842
843        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
844        let plan = s.plan().unwrap();
845        s.execute(&plan, false).unwrap();
846
847        // Verify the file was bumped
848        let contents = std::fs::read_to_string(&cargo_path).unwrap();
849        assert!(contents.contains("version = \"0.1.0\""));
850
851        // Verify it was staged alongside the commit
852        let committed = s.git.committed.lock().unwrap();
853        assert_eq!(committed.len(), 1);
854        assert!(
855            committed[0]
856                .0
857                .contains(&cargo_path.to_str().unwrap().to_string())
858        );
859    }
860
861    #[test]
862    fn execute_stages_changelog_and_version_files_together() {
863        let dir = tempfile::tempdir().unwrap();
864        let cargo_path = dir.path().join("Cargo.toml");
865        std::fs::write(
866            &cargo_path,
867            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
868        )
869        .unwrap();
870
871        let changelog_path = dir.path().join("CHANGELOG.md");
872
873        let mut config = ReleaseConfig::default();
874        config.changelog.file = Some(changelog_path.to_str().unwrap().to_string());
875        config.version_files = vec![cargo_path.to_str().unwrap().to_string()];
876
877        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
878        let plan = s.plan().unwrap();
879        s.execute(&plan, false).unwrap();
880
881        // Both changelog and version file should be staged in a single commit
882        let committed = s.git.committed.lock().unwrap();
883        assert_eq!(committed.len(), 1);
884        assert!(
885            committed[0]
886                .0
887                .contains(&changelog_path.to_str().unwrap().to_string())
888        );
889        assert!(
890            committed[0]
891                .0
892                .contains(&cargo_path.to_str().unwrap().to_string())
893        );
894    }
895
896    // --- artifact upload tests ---
897
898    #[test]
899    fn execute_uploads_artifacts() {
900        let dir = tempfile::tempdir().unwrap();
901        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
902        std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
903
904        let mut config = ReleaseConfig::default();
905        config.artifacts = vec![
906            dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
907            dir.path().join("*.zip").to_str().unwrap().to_string(),
908        ];
909
910        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
911        let plan = s.plan().unwrap();
912        s.execute(&plan, false).unwrap();
913
914        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
915        assert_eq!(uploaded.len(), 1);
916        assert_eq!(uploaded[0].0, "v0.1.0");
917        assert_eq!(uploaded[0].1.len(), 2);
918        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.tar.gz")));
919        assert!(uploaded[0].1.iter().any(|f| f.ends_with("app.zip")));
920    }
921
922    #[test]
923    fn execute_dry_run_shows_artifacts() {
924        let dir = tempfile::tempdir().unwrap();
925        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
926
927        let mut config = ReleaseConfig::default();
928        config.artifacts = vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()];
929
930        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
931        let plan = s.plan().unwrap();
932        s.execute(&plan, true).unwrap();
933
934        // No uploads should happen during dry-run
935        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
936        assert!(uploaded.is_empty());
937    }
938
939    #[test]
940    fn execute_no_artifacts_skips_upload() {
941        let s = make_strategy(
942            vec![],
943            vec![raw_commit("feat: something")],
944            ReleaseConfig::default(),
945        );
946        let plan = s.plan().unwrap();
947        s.execute(&plan, false).unwrap();
948
949        let uploaded = s.vcs.as_ref().unwrap().uploaded_assets.lock().unwrap();
950        assert!(uploaded.is_empty());
951    }
952
953    #[test]
954    fn resolve_artifact_globs_basic() {
955        let dir = tempfile::tempdir().unwrap();
956        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
957        std::fs::write(dir.path().join("b.txt"), "b").unwrap();
958        std::fs::create_dir(dir.path().join("subdir")).unwrap();
959
960        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
961        let result = resolve_artifact_globs(&[pattern]).unwrap();
962        assert_eq!(result.len(), 2);
963        assert!(result.iter().any(|f| f.ends_with("a.txt")));
964        assert!(result.iter().any(|f| f.ends_with("b.txt")));
965    }
966
967    #[test]
968    fn resolve_artifact_globs_deduplicates() {
969        let dir = tempfile::tempdir().unwrap();
970        std::fs::write(dir.path().join("file.txt"), "data").unwrap();
971
972        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
973        // Same pattern twice should not produce duplicates
974        let result = resolve_artifact_globs(&[pattern.clone(), pattern]).unwrap();
975        assert_eq!(result.len(), 1);
976    }
977
978    // --- floating tags tests ---
979
980    #[test]
981    fn plan_floating_tag_when_enabled() {
982        let tag = TagInfo {
983            name: "v3.2.0".into(),
984            version: Version::new(3, 2, 0),
985            sha: "d".repeat(40),
986        };
987        let mut config = ReleaseConfig::default();
988        config.floating_tags = true;
989
990        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
991        let plan = s.plan().unwrap();
992        assert_eq!(plan.next_version, Version::new(3, 2, 1));
993        assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
994    }
995
996    #[test]
997    fn plan_no_floating_tag_when_disabled() {
998        let s = make_strategy(
999            vec![],
1000            vec![raw_commit("feat: something")],
1001            ReleaseConfig::default(),
1002        );
1003        let plan = s.plan().unwrap();
1004        assert!(plan.floating_tag_name.is_none());
1005    }
1006
1007    #[test]
1008    fn plan_floating_tag_custom_prefix() {
1009        let tag = TagInfo {
1010            name: "release-2.5.0".into(),
1011            version: Version::new(2, 5, 0),
1012            sha: "e".repeat(40),
1013        };
1014        let mut config = ReleaseConfig::default();
1015        config.floating_tags = true;
1016        config.tag_prefix = "release-".into();
1017
1018        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1019        let plan = s.plan().unwrap();
1020        assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1021    }
1022
1023    #[test]
1024    fn execute_floating_tags_force_create_and_push() {
1025        let mut config = ReleaseConfig::default();
1026        config.floating_tags = true;
1027
1028        let tag = TagInfo {
1029            name: "v1.2.3".into(),
1030            version: Version::new(1, 2, 3),
1031            sha: "f".repeat(40),
1032        };
1033        let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1034        let plan = s.plan().unwrap();
1035        assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1036
1037        s.execute(&plan, false).unwrap();
1038
1039        assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1040        assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1041    }
1042
1043    #[test]
1044    fn execute_no_floating_tags_when_disabled() {
1045        let s = make_strategy(
1046            vec![],
1047            vec![raw_commit("feat: something")],
1048            ReleaseConfig::default(),
1049        );
1050        let plan = s.plan().unwrap();
1051        assert!(plan.floating_tag_name.is_none());
1052
1053        s.execute(&plan, false).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_dry_run_no_side_effects() {
1061        let mut config = ReleaseConfig::default();
1062        config.floating_tags = true;
1063
1064        let tag = TagInfo {
1065            name: "v2.0.0".into(),
1066            version: Version::new(2, 0, 0),
1067            sha: "a".repeat(40),
1068        };
1069        let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1070        let plan = s.plan().unwrap();
1071        assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1072
1073        s.execute(&plan, true).unwrap();
1074
1075        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1076        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1077    }
1078
1079    #[test]
1080    fn execute_floating_tags_idempotent() {
1081        let mut config = ReleaseConfig::default();
1082        config.floating_tags = true;
1083
1084        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1085        let plan = s.plan().unwrap();
1086        assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1087
1088        // Run twice
1089        s.execute(&plan, false).unwrap();
1090        s.execute(&plan, false).unwrap();
1091
1092        // Force ops run every time (correct for floating tags)
1093        assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1094        assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1095    }
1096
1097    // --- force mode tests ---
1098
1099    #[test]
1100    fn force_rerelease_when_tag_at_head() {
1101        let tag = TagInfo {
1102            name: "v1.2.3".into(),
1103            version: Version::new(1, 2, 3),
1104            sha: "a".repeat(40),
1105        };
1106        let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1107        // HEAD == tag SHA, and no new commits
1108        s.git.head = "a".repeat(40);
1109        s.force = true;
1110
1111        let plan = s.plan().unwrap();
1112        assert_eq!(plan.next_version, Version::new(1, 2, 3));
1113        assert_eq!(plan.tag_name, "v1.2.3");
1114        assert!(plan.commits.is_empty());
1115        assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1116    }
1117
1118    #[test]
1119    fn force_fails_when_tag_not_at_head() {
1120        let tag = TagInfo {
1121            name: "v1.2.3".into(),
1122            version: Version::new(1, 2, 3),
1123            sha: "a".repeat(40),
1124        };
1125        let mut s = make_strategy(vec![tag], vec![], ReleaseConfig::default());
1126        // HEAD != tag SHA
1127        s.git.head = "b".repeat(40);
1128        s.force = true;
1129
1130        let err = s.plan().unwrap_err();
1131        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1132    }
1133
1134    // --- build_command tests ---
1135
1136    #[test]
1137    fn execute_runs_build_command_after_version_bump() {
1138        let dir = tempfile::tempdir().unwrap();
1139        let output_file = dir.path().join("sr_test_version");
1140
1141        let mut config = ReleaseConfig::default();
1142        config.build_command = Some(format!(
1143            "echo $SR_VERSION > {}",
1144            output_file.to_str().unwrap()
1145        ));
1146
1147        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1148        let plan = s.plan().unwrap();
1149        s.execute(&plan, false).unwrap();
1150
1151        let contents = std::fs::read_to_string(&output_file).unwrap();
1152        assert_eq!(contents.trim(), "0.1.0");
1153    }
1154
1155    #[test]
1156    fn execute_build_command_failure_aborts_release() {
1157        let mut config = ReleaseConfig::default();
1158        config.build_command = Some("exit 1".into());
1159
1160        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1161        let plan = s.plan().unwrap();
1162        let result = s.execute(&plan, false);
1163
1164        assert!(result.is_err());
1165        assert!(s.git.created_tags.lock().unwrap().is_empty());
1166    }
1167
1168    #[test]
1169    fn execute_dry_run_skips_build_command() {
1170        let dir = tempfile::tempdir().unwrap();
1171        let output_file = dir.path().join("sr_test_should_not_exist");
1172
1173        let mut config = ReleaseConfig::default();
1174        config.build_command = Some(format!("echo test > {}", output_file.to_str().unwrap()));
1175
1176        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1177        let plan = s.plan().unwrap();
1178        s.execute(&plan, true).unwrap();
1179
1180        assert!(!output_file.exists());
1181    }
1182
1183    #[test]
1184    fn force_fails_with_no_tags() {
1185        let mut s = make_strategy(vec![], vec![], ReleaseConfig::default());
1186        s.force = true;
1187
1188        let err = s.plan().unwrap_err();
1189        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1190    }
1191}