Skip to main content

sr_core/
release.rs

1use semver::Version;
2use serde::Serialize;
3
4use crate::changelog::{ChangelogEntry, ChangelogFormatter};
5use crate::commit::{CommitParser, ConventionalCommit, DefaultCommitClassifier};
6use crate::config::{Config, PackageConfig};
7use crate::error::ReleaseError;
8use crate::git::GitRepository;
9use crate::stages::{StageContext, default_pipeline};
10use crate::version::{BumpLevel, apply_bump, apply_prerelease_bump, determine_bump};
11
12/// The computed plan for a release, before execution.
13#[derive(Debug, Serialize)]
14pub struct ReleasePlan {
15    pub current_version: Option<Version>,
16    pub next_version: Version,
17    pub bump: BumpLevel,
18    pub commits: Vec<ConventionalCommit>,
19    pub tag_name: String,
20    pub floating_tag_name: Option<String>,
21    pub prerelease: bool,
22}
23
24/// Orchestrates the release flow.
25pub trait ReleaseStrategy: Send + Sync {
26    /// Plan the release without executing it.
27    fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
28
29    /// Execute the release.
30    fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
31}
32
33/// Abstraction over a remote VCS provider (e.g. GitHub, GitLab).
34pub trait VcsProvider: Send + Sync {
35    /// Create a release on the remote VCS.
36    fn create_release(
37        &self,
38        tag: &str,
39        name: &str,
40        body: &str,
41        prerelease: bool,
42        draft: bool,
43    ) -> Result<String, ReleaseError>;
44
45    /// Generate a compare URL between two refs.
46    fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
47
48    /// Check if a release already exists for the given tag.
49    fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
50
51    /// Delete a release by tag.
52    fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
53
54    /// Return the base URL of the repository (e.g. `https://github.com/owner/repo`).
55    fn repo_url(&self) -> Option<String> {
56        None
57    }
58
59    /// Update an existing release (name and body) using PATCH semantics,
60    /// preserving any previously uploaded assets.
61    fn update_release(
62        &self,
63        _tag: &str,
64        _name: &str,
65        _body: &str,
66        _prerelease: bool,
67        _draft: bool,
68    ) -> Result<String, ReleaseError> {
69        Err(ReleaseError::Vcs(
70            "update_release not implemented for this provider".into(),
71        ))
72    }
73
74    /// Upload asset files to an existing release identified by tag.
75    fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
76        Ok(())
77    }
78
79    /// List the basenames of assets currently attached to the release for `tag`.
80    /// Returns `Ok(vec![])` for providers that don't support asset listing.
81    /// Used by the idempotent upload path to skip assets already present.
82    fn list_assets(&self, _tag: &str) -> Result<Vec<String>, ReleaseError> {
83        Ok(Vec::new())
84    }
85
86    /// Fetch the content of a named asset on the release for `tag`.
87    /// Returns `Ok(None)` if the asset doesn't exist or the provider doesn't
88    /// support it. Used by the reconciler to read `sr-manifest.json`.
89    fn fetch_asset(&self, _tag: &str, _name: &str) -> Result<Option<Vec<u8>>, ReleaseError> {
90        Ok(None)
91    }
92
93    /// Verify that a release exists and is in the expected state after creation.
94    fn verify_release(&self, _tag: &str) -> Result<(), ReleaseError> {
95        Ok(())
96    }
97}
98
99/// A no-op VcsProvider that silently succeeds. Used when no remote VCS
100/// (e.g. GitHub) is configured.
101pub struct NoopVcsProvider;
102
103impl VcsProvider for NoopVcsProvider {
104    fn create_release(
105        &self,
106        _tag: &str,
107        _name: &str,
108        _body: &str,
109        _prerelease: bool,
110        _draft: bool,
111    ) -> Result<String, ReleaseError> {
112        Ok(String::new())
113    }
114
115    fn compare_url(&self, _base: &str, _head: &str) -> Result<String, ReleaseError> {
116        Ok(String::new())
117    }
118
119    fn release_exists(&self, _tag: &str) -> Result<bool, ReleaseError> {
120        Ok(false)
121    }
122
123    fn delete_release(&self, _tag: &str) -> Result<(), ReleaseError> {
124        Ok(())
125    }
126}
127
128/// Concrete release strategy implementing the trunk-based release flow.
129pub struct TrunkReleaseStrategy<G, V, C, F> {
130    pub git: G,
131    pub vcs: V,
132    pub parser: C,
133    pub formatter: F,
134    pub config: Config,
135    /// When true, re-release the current tag if HEAD is at the latest tag.
136    pub force: bool,
137    /// Pre-release identifier resolved from the active channel (None = stable).
138    pub prerelease_id: Option<String>,
139    /// Whether the GitHub release should be created as a draft.
140    pub draft: bool,
141}
142
143impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
144where
145    G: GitRepository,
146    V: VcsProvider,
147    C: CommitParser,
148    F: ChangelogFormatter,
149{
150    fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
151        let today = today_string();
152        let compare_url = match &plan.current_version {
153            Some(v) => {
154                let base = format!("{}{v}", self.config.git.tag_prefix);
155                self.vcs
156                    .compare_url(&base, &plan.tag_name)
157                    .ok()
158                    .filter(|s| !s.is_empty())
159            }
160            None => None,
161        };
162        let entry = ChangelogEntry {
163            version: plan.next_version.to_string(),
164            date: today,
165            commits: plan.commits.clone(),
166            compare_url,
167            repo_url: self.vcs.repo_url(),
168        };
169        self.formatter.format(&[entry])
170    }
171
172    /// Render the release name from the configured template, or fall back to the tag name.
173    fn release_name(&self, plan: &ReleasePlan) -> String {
174        if let Some(ref template_str) = self.config.vcs.github.release_name_template {
175            let mut env = minijinja::Environment::new();
176            if env.add_template("release_name", template_str).is_ok()
177                && let Ok(tmpl) = env.get_template("release_name")
178                && let Ok(rendered) = tmpl.render(minijinja::context! {
179                    version => plan.next_version.to_string(),
180                    tag_name => &plan.tag_name,
181                    tag_prefix => &self.config.git.tag_prefix,
182                })
183            {
184                return rendered;
185            }
186            eprintln!("warning: invalid release_name_template, falling back to tag name");
187        }
188        plan.tag_name.clone()
189    }
190
191    /// Return the active package for a single-package release (the root package or the only one).
192    /// Returns the root package (".") if present, otherwise the first package.
193    fn active_package(&self) -> Option<&PackageConfig> {
194        self.config
195            .packages
196            .iter()
197            .find(|p| p.path == ".")
198            .or_else(|| self.config.packages.first())
199    }
200}
201
202impl<G, V, C, F> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F>
203where
204    G: GitRepository,
205    V: VcsProvider,
206    C: CommitParser,
207    F: ChangelogFormatter,
208{
209    fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
210        let is_prerelease = self.prerelease_id.is_some();
211
212        // For stable releases, find the latest stable tag (skip pre-release tags).
213        // For pre-releases, find the latest tag of any kind to determine commits since.
214        let all_tags = self.git.all_tags(&self.config.git.tag_prefix)?;
215        let latest_stable = all_tags.iter().rev().find(|t| t.version.pre.is_empty());
216        let latest_any = all_tags.last();
217
218        // Reconciliation check: don't cut a new release on top of an incomplete
219        // one. `--force` bypasses (it's explicit user intent to re-run the
220        // pipeline, which itself heals). Unknown status (legacy release, no
221        // manifest) passes silently — we can't distinguish "old release" from
222        // "sr died before upload" remotely.
223        if !self.force
224            && let Some(latest) = all_tags.last()
225        {
226            match crate::manifest::check_release_status(&self.vcs, &latest.name)? {
227                crate::manifest::ReleaseStatus::Incomplete {
228                    missing_artifacts, ..
229                } => {
230                    return Err(ReleaseError::Vcs(format!(
231                        "previous release {tag} is incomplete: {} declared asset(s) missing ({}). \
232                         Heal it first — `git checkout {tag} && sr release --force` — \
233                         then re-run sr release.",
234                        missing_artifacts.len(),
235                        missing_artifacts.join(", "),
236                        tag = latest.name,
237                    )));
238                }
239                crate::manifest::ReleaseStatus::Complete(_)
240                | crate::manifest::ReleaseStatus::Unknown => {}
241            }
242        }
243
244        // Use the latest tag (any kind) for commit range, but the latest stable for base version
245        let tag_info = if is_prerelease {
246            latest_any
247        } else {
248            latest_stable.or(latest_any)
249        };
250
251        let (current_version, from_sha) = match tag_info {
252            Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
253            None => (None, None),
254        };
255
256        let default_pkg = PackageConfig::default();
257        let pkg = self.active_package().unwrap_or(&default_pkg);
258        let path_filter = if pkg.path != "." {
259            Some(pkg.path.as_str())
260        } else {
261            None
262        };
263
264        let raw_commits = if let Some(path) = path_filter {
265            self.git.commits_since_in_path(from_sha, path)?
266        } else {
267            self.git.commits_since(from_sha)?
268        };
269
270        if raw_commits.is_empty() {
271            // Force mode: re-release if HEAD is exactly at the latest tag
272            if self.force
273                && let Some(info) = tag_info
274            {
275                let head = self.git.head_sha()?;
276                if head == info.sha {
277                    let floating_tag_name = if self.config.git.floating_tag {
278                        Some(format!(
279                            "{}{}",
280                            self.config.git.tag_prefix, info.version.major
281                        ))
282                    } else {
283                        None
284                    };
285                    return Ok(ReleasePlan {
286                        current_version: Some(info.version.clone()),
287                        next_version: info.version.clone(),
288                        bump: BumpLevel::Patch,
289                        commits: vec![],
290                        tag_name: info.name.clone(),
291                        floating_tag_name,
292                        prerelease: is_prerelease,
293                    });
294                }
295            }
296            let (tag, sha) = match tag_info {
297                Some(info) => (info.name.clone(), info.sha.clone()),
298                None => ("(none)".into(), "(none)".into()),
299            };
300            return Err(ReleaseError::NoCommits { tag, sha });
301        }
302
303        let skip_patterns = &self.config.git.skip_patterns;
304        let conventional_commits: Vec<ConventionalCommit> = raw_commits
305            .iter()
306            .filter(|c| !c.message.starts_with("chore(release):"))
307            .filter(|c| !skip_patterns.iter().any(|p| c.message.contains(p.as_str())))
308            .filter_map(|c| self.parser.parse(c).ok())
309            .collect();
310
311        let classifier = DefaultCommitClassifier::new(self.config.commit.types.into_commit_types());
312        let tag_for_err = tag_info
313            .map(|i| i.name.clone())
314            .unwrap_or_else(|| "(none)".into());
315        let commit_count = conventional_commits.len();
316        let bump = match determine_bump(&conventional_commits, &classifier) {
317            Some(b) => b,
318            None if self.force => BumpLevel::Patch,
319            None => {
320                return Err(ReleaseError::NoBump {
321                    tag: tag_for_err,
322                    commit_count,
323                });
324            }
325        };
326
327        // For pre-releases, base the version on the latest *stable* tag
328        let base_version = if is_prerelease {
329            latest_stable
330                .map(|t| t.version.clone())
331                .or(current_version.clone())
332                .unwrap_or(Version::new(0, 0, 0))
333        } else {
334            current_version.clone().unwrap_or(Version::new(0, 0, 0))
335        };
336
337        // v0 protection: downshift Major → Minor when version is 0.x.y
338        // to prevent accidentally bumping to v1. Disable with git.v0_protection: false.
339        let bump =
340            if base_version.major == 0 && bump == BumpLevel::Major && self.config.git.v0_protection
341            {
342                eprintln!(
343                    "v0 protection: breaking change detected at v{base_version}, \
344                     downshifting major → minor (set git.v0_protection: false to bump to v1)"
345                );
346                BumpLevel::Minor
347            } else {
348                bump
349            };
350
351        let next_version = if let Some(ref prerelease_id) = self.prerelease_id {
352            let existing_versions: Vec<Version> =
353                all_tags.iter().map(|t| t.version.clone()).collect();
354            apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
355        } else {
356            apply_bump(&base_version, bump)
357        };
358
359        let tag_name = format!("{}{next_version}", self.config.git.tag_prefix);
360
361        // Don't update floating tags for pre-releases
362        let floating_tag_name = if self.config.git.floating_tag && !is_prerelease {
363            Some(format!(
364                "{}{}",
365                self.config.git.tag_prefix, next_version.major
366            ))
367        } else {
368            None
369        };
370
371        Ok(ReleasePlan {
372            current_version,
373            next_version,
374            bump,
375            commits: conventional_commits,
376            tag_name,
377            floating_tag_name,
378            prerelease: is_prerelease,
379        })
380    }
381
382    fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
383        let version_str = plan.next_version.to_string();
384        let changelog_body = self.format_changelog(plan)?;
385        let release_name = self.release_name(plan);
386
387        let env = release_env(&version_str, &plan.tag_name);
388        let env_refs: Vec<(&str, &str)> =
389            env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
390
391        let default_pkg = PackageConfig::default();
392        let active_package = self.active_package().unwrap_or(&default_pkg);
393
394        let mut ctx = StageContext {
395            plan,
396            config: &self.config,
397            git: &self.git,
398            vcs: &self.vcs,
399            active_package,
400            changelog_body: &changelog_body,
401            release_name: &release_name,
402            version_str: &version_str,
403            hooks_env: &env_refs,
404            dry_run,
405            sign_tags: self.config.git.sign_tags,
406            draft: self.draft,
407            bumped_files: Vec::new(),
408        };
409
410        for stage in default_pipeline() {
411            if !stage.is_complete(&ctx)? {
412                stage.run(&mut ctx)?;
413            }
414        }
415
416        if dry_run {
417            eprintln!("[dry-run] Changelog:\n{changelog_body}");
418        } else {
419            eprintln!("Released {}", plan.tag_name);
420        }
421        Ok(())
422    }
423}
424
425/// Build release env vars as owned strings.
426fn release_env(version: &str, tag: &str) -> Vec<(String, String)> {
427    vec![
428        ("SR_VERSION".into(), version.into()),
429        ("SR_TAG".into(), tag.into()),
430    ]
431}
432
433/// Resolve glob patterns into a deduplicated, sorted list of file paths.
434pub(crate) fn resolve_globs(patterns: &[String]) -> Result<Vec<String>, String> {
435    let mut files = std::collections::BTreeSet::new();
436    for pattern in patterns {
437        let paths =
438            glob::glob(pattern).map_err(|e| format!("invalid glob pattern '{pattern}': {e}"))?;
439        for entry in paths {
440            match entry {
441                Ok(path) if path.is_file() => {
442                    files.insert(path.to_string_lossy().into_owned());
443                }
444                Ok(_) => {}
445                Err(e) => {
446                    return Err(format!("glob error for pattern '{pattern}': {e}"));
447                }
448            }
449        }
450    }
451    Ok(files.into_iter().collect())
452}
453
454pub fn today_string() -> String {
455    // Portable date calculation from UNIX epoch (no external deps or subprocess).
456    // Uses Howard Hinnant's civil_from_days algorithm.
457    let secs = std::time::SystemTime::now()
458        .duration_since(std::time::UNIX_EPOCH)
459        .unwrap_or_default()
460        .as_secs() as i64;
461
462    let z = secs / 86400 + 719468;
463    let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
464    let doe = (z - era * 146097) as u32;
465    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
466    let y = yoe as i64 + era * 400;
467    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
468    let mp = (5 * doy + 2) / 153;
469    let d = doy - (153 * mp + 2) / 5 + 1;
470    let m = if mp < 10 { mp + 3 } else { mp - 9 };
471    let y = if m <= 2 { y + 1 } else { y };
472
473    format!("{y:04}-{m:02}-{d:02}")
474}
475
476#[cfg(test)]
477mod tests {
478    use std::sync::Mutex;
479
480    use super::*;
481    use crate::changelog::DefaultChangelogFormatter;
482    use crate::commit::{Commit, TypedCommitParser};
483    use crate::config::{
484        ChangelogConfig, Config, GitConfig, HooksConfig, PackageConfig, default_changelog_groups,
485    };
486    use crate::git::{GitRepository, TagInfo};
487
488    // --- Fakes ---
489
490    struct FakeGit {
491        tags: Vec<TagInfo>,
492        commits: Vec<Commit>,
493        /// Commits returned when path filtering is active (None = fall back to `commits`).
494        path_commits: Option<Vec<Commit>>,
495        head: String,
496        created_tags: Mutex<Vec<String>>,
497        pushed_tags: Mutex<Vec<String>>,
498        committed: Mutex<Vec<(Vec<String>, String)>>,
499        push_count: Mutex<u32>,
500        force_created_tags: Mutex<Vec<String>>,
501        force_pushed_tags: Mutex<Vec<String>>,
502    }
503
504    impl FakeGit {
505        fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
506            let head = tags
507                .last()
508                .map(|t| t.sha.clone())
509                .unwrap_or_else(|| "0".repeat(40));
510            Self {
511                tags,
512                commits,
513                path_commits: None,
514                head,
515                created_tags: Mutex::new(Vec::new()),
516                pushed_tags: Mutex::new(Vec::new()),
517                committed: Mutex::new(Vec::new()),
518                push_count: Mutex::new(0),
519                force_created_tags: Mutex::new(Vec::new()),
520                force_pushed_tags: Mutex::new(Vec::new()),
521            }
522        }
523    }
524
525    impl GitRepository for FakeGit {
526        fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
527            Ok(self.tags.last().cloned())
528        }
529
530        fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
531            Ok(self.commits.clone())
532        }
533
534        fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
535            self.created_tags.lock().unwrap().push(name.to_string());
536            Ok(())
537        }
538
539        fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
540            self.pushed_tags.lock().unwrap().push(name.to_string());
541            Ok(())
542        }
543
544        fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
545            self.committed.lock().unwrap().push((
546                paths.iter().map(|s| s.to_string()).collect(),
547                message.to_string(),
548            ));
549            Ok(true)
550        }
551
552        fn push(&self) -> Result<(), ReleaseError> {
553            *self.push_count.lock().unwrap() += 1;
554            Ok(())
555        }
556
557        fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
558            Ok(self
559                .created_tags
560                .lock()
561                .unwrap()
562                .contains(&name.to_string()))
563        }
564
565        fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
566            Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
567        }
568
569        fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
570            Ok(self.tags.clone())
571        }
572
573        fn commits_between(
574            &self,
575            _from: Option<&str>,
576            _to: &str,
577        ) -> Result<Vec<Commit>, ReleaseError> {
578            Ok(self.commits.clone())
579        }
580
581        fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
582            Ok("2026-01-01".into())
583        }
584
585        fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
586            self.force_created_tags
587                .lock()
588                .unwrap()
589                .push(name.to_string());
590            Ok(())
591        }
592
593        fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
594            self.force_pushed_tags
595                .lock()
596                .unwrap()
597                .push(name.to_string());
598            Ok(())
599        }
600
601        fn head_sha(&self) -> Result<String, ReleaseError> {
602            Ok(self.head.clone())
603        }
604
605        fn commits_since_in_path(
606            &self,
607            _from: Option<&str>,
608            _path: &str,
609        ) -> Result<Vec<Commit>, ReleaseError> {
610            Ok(self
611                .path_commits
612                .clone()
613                .unwrap_or_else(|| self.commits.clone()))
614        }
615    }
616
617    struct FakeVcs {
618        releases: Mutex<Vec<(String, String)>>,
619        deleted_releases: Mutex<Vec<String>>,
620        uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
621        /// (tag, basename) → bytes. Populated by upload_assets when the file
622        /// on disk is readable; consumed by fetch_asset and list_assets to
623        /// simulate GitHub's release-assets view.
624        stored_assets: Mutex<Vec<(String, String, Vec<u8>)>>,
625    }
626
627    impl FakeVcs {
628        fn new() -> Self {
629            Self {
630                releases: Mutex::new(Vec::new()),
631                deleted_releases: Mutex::new(Vec::new()),
632                uploaded_assets: Mutex::new(Vec::new()),
633                stored_assets: Mutex::new(Vec::new()),
634            }
635        }
636
637        /// Pre-seed an asset on a release — for reconciliation tests where the
638        /// starting state already has a manifest.
639        fn seed_asset(&self, tag: &str, name: &str, content: Vec<u8>) {
640            self.stored_assets
641                .lock()
642                .unwrap()
643                .push((tag.to_string(), name.to_string(), content));
644        }
645    }
646
647    impl VcsProvider for FakeVcs {
648        fn create_release(
649            &self,
650            tag: &str,
651            _name: &str,
652            body: &str,
653            _prerelease: bool,
654            _draft: bool,
655        ) -> Result<String, ReleaseError> {
656            self.releases
657                .lock()
658                .unwrap()
659                .push((tag.to_string(), body.to_string()));
660            Ok(format!("https://github.com/test/release/{tag}"))
661        }
662
663        fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
664            Ok(format!("https://github.com/test/compare/{base}...{head}"))
665        }
666
667        fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
668            Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
669        }
670
671        fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
672            self.deleted_releases.lock().unwrap().push(tag.to_string());
673            self.releases.lock().unwrap().retain(|(t, _)| t != tag);
674            Ok(())
675        }
676
677        fn update_release(
678            &self,
679            tag: &str,
680            _name: &str,
681            body: &str,
682            _prerelease: bool,
683            _draft: bool,
684        ) -> Result<String, ReleaseError> {
685            let mut releases = self.releases.lock().unwrap();
686            if let Some(entry) = releases.iter_mut().find(|(t, _)| t == tag) {
687                entry.1 = body.to_string();
688            }
689            Ok(format!("https://github.com/test/release/{tag}"))
690        }
691
692        fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
693            self.uploaded_assets.lock().unwrap().push((
694                tag.to_string(),
695                files.iter().map(|s| s.to_string()).collect(),
696            ));
697            // Mirror into stored_assets so list/fetch see what was uploaded.
698            for path in files {
699                let basename = std::path::Path::new(path)
700                    .file_name()
701                    .and_then(|n| n.to_str())
702                    .unwrap_or(path)
703                    .to_string();
704                let content = std::fs::read(path).unwrap_or_default();
705                self.stored_assets
706                    .lock()
707                    .unwrap()
708                    .push((tag.to_string(), basename, content));
709            }
710            Ok(())
711        }
712
713        fn list_assets(&self, tag: &str) -> Result<Vec<String>, ReleaseError> {
714            Ok(self
715                .stored_assets
716                .lock()
717                .unwrap()
718                .iter()
719                .filter(|(t, _, _)| t == tag)
720                .map(|(_, n, _)| n.clone())
721                .collect())
722        }
723
724        fn fetch_asset(&self, tag: &str, name: &str) -> Result<Option<Vec<u8>>, ReleaseError> {
725            Ok(self
726                .stored_assets
727                .lock()
728                .unwrap()
729                .iter()
730                .find(|(t, n, _)| t == tag && n == name)
731                .map(|(_, _, b)| b.clone()))
732        }
733
734        fn repo_url(&self) -> Option<String> {
735            Some("https://github.com/test/repo".into())
736        }
737    }
738
739    // --- Helpers ---
740
741    type TestStrategy =
742        TrunkReleaseStrategy<FakeGit, FakeVcs, TypedCommitParser, DefaultChangelogFormatter>;
743
744    /// Build a Config with changelog file disabled and a dummy version file,
745    /// so tests don't pollute the real CHANGELOG.md or auto-detect and bump
746    /// the actual Cargo.toml of whichever crate is running the tests.
747    fn test_config() -> Config {
748        Config {
749            changelog: ChangelogConfig {
750                file: None,
751                ..Default::default()
752            },
753            packages: vec![PackageConfig {
754                path: ".".into(),
755                version_files: vec!["__sr_test_dummy_no_bump__".into()],
756                ..Default::default()
757            }],
758            ..Default::default()
759        }
760    }
761
762    /// Build a Config with custom git settings (still isolated from real files).
763    fn config_with_git(git: GitConfig) -> Config {
764        Config {
765            git,
766            changelog: ChangelogConfig {
767                file: None,
768                ..Default::default()
769            },
770            packages: vec![PackageConfig {
771                path: ".".into(),
772                version_files: vec!["__sr_test_dummy_no_bump__".into()],
773                ..Default::default()
774            }],
775            ..Default::default()
776        }
777    }
778
779    fn make_strategy(tags: Vec<TagInfo>, commits: Vec<Commit>, config: Config) -> TestStrategy {
780        TrunkReleaseStrategy {
781            git: FakeGit::new(tags, commits),
782            vcs: FakeVcs::new(),
783            parser: TypedCommitParser::default(),
784            formatter: DefaultChangelogFormatter::new(None, default_changelog_groups()),
785            config,
786            force: false,
787            prerelease_id: None,
788            draft: false,
789        }
790    }
791
792    fn raw_commit(msg: &str) -> Commit {
793        Commit {
794            sha: "a".repeat(40),
795            message: msg.into(),
796        }
797    }
798
799    // --- plan() tests ---
800
801    #[test]
802    fn plan_no_commits_returns_error() {
803        let s = make_strategy(vec![], vec![], Config::default());
804        let err = s.plan().unwrap_err();
805        assert!(matches!(err, ReleaseError::NoCommits { .. }));
806    }
807
808    #[test]
809    fn plan_no_releasable_returns_error() {
810        let s = make_strategy(
811            vec![],
812            vec![raw_commit("chore: tidy up")],
813            Config::default(),
814        );
815        let err = s.plan().unwrap_err();
816        assert!(matches!(err, ReleaseError::NoBump { .. }));
817    }
818
819    #[test]
820    fn force_releases_patch_when_no_releasable_commits() {
821        let tag = TagInfo {
822            name: "v1.2.3".into(),
823            version: Version::new(1, 2, 3),
824            sha: "d".repeat(40),
825        };
826        let mut s = make_strategy(
827            vec![tag],
828            vec![raw_commit("chore: rename package")],
829            Config::default(),
830        );
831        s.force = true;
832        let plan = s.plan().unwrap();
833        assert_eq!(plan.next_version, Version::new(1, 2, 4));
834        assert_eq!(plan.bump, BumpLevel::Patch);
835    }
836
837    #[test]
838    fn plan_first_release() {
839        let s = make_strategy(
840            vec![],
841            vec![raw_commit("feat: initial feature")],
842            Config::default(),
843        );
844        let plan = s.plan().unwrap();
845        assert_eq!(plan.next_version, Version::new(0, 1, 0));
846        assert_eq!(plan.tag_name, "v0.1.0");
847        assert!(plan.current_version.is_none());
848    }
849
850    #[test]
851    fn plan_skips_commits_matching_skip_patterns() {
852        let s = make_strategy(
853            vec![],
854            vec![
855                raw_commit("feat: real feature"),
856                raw_commit("feat: noisy experiment [skip release]"),
857                raw_commit("fix: swallowed fix [skip sr]"),
858            ],
859            test_config(),
860        );
861        let plan = s.plan().unwrap();
862        assert_eq!(plan.commits.len(), 1);
863        assert_eq!(plan.commits[0].description, "real feature");
864    }
865
866    #[test]
867    fn plan_custom_skip_patterns_override_defaults() {
868        let git = GitConfig {
869            skip_patterns: vec!["DO-NOT-RELEASE".into()],
870            ..Default::default()
871        };
872        let s = make_strategy(
873            vec![],
874            vec![
875                raw_commit("feat: shipped"),
876                raw_commit("feat: DO-NOT-RELEASE internal"),
877                // default patterns no longer active → this commit counts
878                raw_commit("feat: still here [skip release]"),
879            ],
880            config_with_git(git),
881        );
882        let plan = s.plan().unwrap();
883        assert_eq!(plan.commits.len(), 2);
884    }
885
886    #[test]
887    fn plan_increments_existing() {
888        let tag = TagInfo {
889            name: "v1.2.3".into(),
890            version: Version::new(1, 2, 3),
891            sha: "b".repeat(40),
892        };
893        let s = make_strategy(
894            vec![tag],
895            vec![raw_commit("fix: patch bug")],
896            Config::default(),
897        );
898        let plan = s.plan().unwrap();
899        assert_eq!(plan.next_version, Version::new(1, 2, 4));
900    }
901
902    #[test]
903    fn plan_breaking_bump() {
904        let tag = TagInfo {
905            name: "v1.2.3".into(),
906            version: Version::new(1, 2, 3),
907            sha: "c".repeat(40),
908        };
909        let s = make_strategy(
910            vec![tag],
911            vec![raw_commit("feat!: breaking change")],
912            Config::default(),
913        );
914        let plan = s.plan().unwrap();
915        assert_eq!(plan.next_version, Version::new(2, 0, 0));
916    }
917
918    #[test]
919    fn plan_v0_breaking_downshifts_to_minor() {
920        let tag = TagInfo {
921            name: "v0.5.0".into(),
922            version: Version::new(0, 5, 0),
923            sha: "c".repeat(40),
924        };
925        let s = make_strategy(
926            vec![tag],
927            vec![raw_commit("feat!: breaking change")],
928            Config::default(),
929        );
930        let plan = s.plan().unwrap();
931        // v0 protection: Major → Minor, so 0.5.0 → 0.6.0 (not 1.0.0)
932        assert_eq!(plan.next_version, Version::new(0, 6, 0));
933        assert_eq!(plan.bump, BumpLevel::Minor);
934    }
935
936    #[test]
937    fn plan_v0_breaking_with_protection_disabled_bumps_major() {
938        let tag = TagInfo {
939            name: "v0.5.0".into(),
940            version: Version::new(0, 5, 0),
941            sha: "c".repeat(40),
942        };
943        let mut config = Config::default();
944        config.git.v0_protection = false;
945        let s = make_strategy(
946            vec![tag],
947            vec![raw_commit("feat!: breaking change")],
948            config,
949        );
950        let plan = s.plan().unwrap();
951        // v0_protection: false allows bumping to v1
952        assert_eq!(plan.next_version, Version::new(1, 0, 0));
953        assert_eq!(plan.bump, BumpLevel::Major);
954    }
955
956    #[test]
957    fn plan_v0_feat_stays_minor() {
958        let tag = TagInfo {
959            name: "v0.5.0".into(),
960            version: Version::new(0, 5, 0),
961            sha: "c".repeat(40),
962        };
963        let s = make_strategy(
964            vec![tag],
965            vec![raw_commit("feat: new feature")],
966            Config::default(),
967        );
968        let plan = s.plan().unwrap();
969        // Non-breaking feat in v0 stays as minor bump
970        assert_eq!(plan.next_version, Version::new(0, 6, 0));
971        assert_eq!(plan.bump, BumpLevel::Minor);
972    }
973
974    #[test]
975    fn plan_v0_fix_stays_patch() {
976        let tag = TagInfo {
977            name: "v0.5.0".into(),
978            version: Version::new(0, 5, 0),
979            sha: "c".repeat(40),
980        };
981        let s = make_strategy(
982            vec![tag],
983            vec![raw_commit("fix: bug fix")],
984            Config::default(),
985        );
986        let plan = s.plan().unwrap();
987        // Fix in v0 stays as patch
988        assert_eq!(plan.next_version, Version::new(0, 5, 1));
989        assert_eq!(plan.bump, BumpLevel::Patch);
990    }
991
992    // --- execute() tests ---
993
994    #[test]
995    fn execute_dry_run_no_side_effects() {
996        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
997        let plan = s.plan().unwrap();
998        s.execute(&plan, true).unwrap();
999
1000        assert!(s.git.created_tags.lock().unwrap().is_empty());
1001        assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1002    }
1003
1004    #[test]
1005    fn execute_creates_and_pushes_tag() {
1006        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1007        let plan = s.plan().unwrap();
1008        s.execute(&plan, false).unwrap();
1009
1010        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1011        assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1012    }
1013
1014    #[test]
1015    fn execute_calls_vcs_create_release() {
1016        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1017        let plan = s.plan().unwrap();
1018        s.execute(&plan, false).unwrap();
1019
1020        let releases = s.vcs.releases.lock().unwrap();
1021        assert_eq!(releases.len(), 1);
1022        assert_eq!(releases[0].0, "v0.1.0");
1023        assert!(!releases[0].1.is_empty());
1024    }
1025
1026    #[test]
1027    fn execute_commits_changelog_before_tag() {
1028        let dir = tempfile::tempdir().unwrap();
1029        let changelog_path = dir.path().join("CHANGELOG.md");
1030
1031        // Use the temp dir as the package path so auto-detection finds no version files.
1032        let config = Config {
1033            changelog: ChangelogConfig {
1034                file: Some(changelog_path.to_str().unwrap().to_string()),
1035                ..Default::default()
1036            },
1037            packages: vec![PackageConfig {
1038                path: dir.path().to_str().unwrap().to_string(),
1039                ..Default::default()
1040            }],
1041            ..Default::default()
1042        };
1043
1044        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1045        let plan = s.plan().unwrap();
1046        s.execute(&plan, false).unwrap();
1047
1048        // Verify changelog was committed
1049        let committed = s.git.committed.lock().unwrap();
1050        assert_eq!(committed.len(), 1);
1051        assert_eq!(
1052            committed[0].0,
1053            vec![changelog_path.to_str().unwrap().to_string()]
1054        );
1055        assert!(committed[0].1.contains("chore(release): v0.1.0"));
1056
1057        // Verify tag was created after commit
1058        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1059    }
1060
1061    #[test]
1062    fn execute_skips_existing_tag() {
1063        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1064        let plan = s.plan().unwrap();
1065
1066        // Pre-populate the tag to simulate it already existing
1067        s.git
1068            .created_tags
1069            .lock()
1070            .unwrap()
1071            .push("v0.1.0".to_string());
1072
1073        s.execute(&plan, false).unwrap();
1074
1075        // Tag should not be created again (still only the one we pre-populated)
1076        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1077    }
1078
1079    #[test]
1080    fn execute_skips_existing_release() {
1081        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1082        let plan = s.plan().unwrap();
1083
1084        // Pre-populate a release to simulate it already existing
1085        s.vcs
1086            .releases
1087            .lock()
1088            .unwrap()
1089            .push(("v0.1.0".to_string(), "old notes".to_string()));
1090
1091        s.execute(&plan, false).unwrap();
1092
1093        // Should have updated in place without deleting
1094        let deleted = s.vcs.deleted_releases.lock().unwrap();
1095        assert!(deleted.is_empty(), "update should not delete");
1096
1097        let releases = s.vcs.releases.lock().unwrap();
1098        assert_eq!(releases.len(), 1);
1099        assert_eq!(releases[0].0, "v0.1.0");
1100        assert_ne!(releases[0].1, "old notes");
1101    }
1102
1103    #[test]
1104    fn execute_idempotent_rerun() {
1105        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1106        let plan = s.plan().unwrap();
1107
1108        // First run
1109        s.execute(&plan, false).unwrap();
1110
1111        // Second run should also succeed (idempotent)
1112        s.execute(&plan, false).unwrap();
1113
1114        // Tag should only have been created once (second run skips because tag_exists)
1115        assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1116
1117        // Tag push should only happen once (second run skips because remote_tag_exists)
1118        assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1119
1120        // Push (commit) should happen twice (always safe)
1121        assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1122
1123        // Release should be updated in place on second run (no delete)
1124        let deleted = s.vcs.deleted_releases.lock().unwrap();
1125        assert!(deleted.is_empty(), "update should not delete");
1126
1127        let releases = s.vcs.releases.lock().unwrap();
1128        assert_eq!(releases.len(), 1);
1129        assert_eq!(releases[0].0, "v0.1.0");
1130    }
1131
1132    #[test]
1133    fn execute_bumps_version_files() {
1134        let dir = tempfile::tempdir().unwrap();
1135        let cargo_path = dir.path().join("Cargo.toml");
1136        std::fs::write(
1137            &cargo_path,
1138            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1139        )
1140        .unwrap();
1141
1142        let config = Config {
1143            changelog: ChangelogConfig {
1144                file: None,
1145                ..Default::default()
1146            },
1147            packages: vec![PackageConfig {
1148                path: ".".into(),
1149                version_files: vec![cargo_path.to_str().unwrap().to_string()],
1150                ..Default::default()
1151            }],
1152            ..Default::default()
1153        };
1154
1155        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1156        let plan = s.plan().unwrap();
1157        s.execute(&plan, false).unwrap();
1158
1159        // Verify the file was bumped
1160        let contents = std::fs::read_to_string(&cargo_path).unwrap();
1161        assert!(contents.contains("version = \"0.1.0\""));
1162
1163        // Verify it was staged alongside the commit
1164        let committed = s.git.committed.lock().unwrap();
1165        assert_eq!(committed.len(), 1);
1166        assert!(
1167            committed[0]
1168                .0
1169                .contains(&cargo_path.to_str().unwrap().to_string())
1170        );
1171    }
1172
1173    #[test]
1174    fn execute_stages_changelog_and_version_files_together() {
1175        let dir = tempfile::tempdir().unwrap();
1176        let cargo_path = dir.path().join("Cargo.toml");
1177        std::fs::write(
1178            &cargo_path,
1179            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1180        )
1181        .unwrap();
1182
1183        let changelog_path = dir.path().join("CHANGELOG.md");
1184
1185        let config = Config {
1186            changelog: ChangelogConfig {
1187                file: Some(changelog_path.to_str().unwrap().to_string()),
1188                ..Default::default()
1189            },
1190            packages: vec![PackageConfig {
1191                path: ".".into(),
1192                version_files: vec![cargo_path.to_str().unwrap().to_string()],
1193                ..Default::default()
1194            }],
1195            ..Default::default()
1196        };
1197
1198        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1199        let plan = s.plan().unwrap();
1200        s.execute(&plan, false).unwrap();
1201
1202        // Both changelog and version file should be staged in a single commit
1203        let committed = s.git.committed.lock().unwrap();
1204        assert_eq!(committed.len(), 1);
1205        assert!(
1206            committed[0]
1207                .0
1208                .contains(&changelog_path.to_str().unwrap().to_string())
1209        );
1210        assert!(
1211            committed[0]
1212                .0
1213                .contains(&cargo_path.to_str().unwrap().to_string())
1214        );
1215    }
1216
1217    // --- artifact upload tests ---
1218
1219    #[test]
1220    fn execute_uploads_artifacts() {
1221        let dir = tempfile::tempdir().unwrap();
1222        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1223        std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1224
1225        let config = Config {
1226            changelog: ChangelogConfig {
1227                file: None,
1228                ..Default::default()
1229            },
1230            packages: vec![PackageConfig {
1231                path: ".".into(),
1232                version_files: vec!["__sr_test_dummy_no_bump__".into()],
1233                artifacts: vec![
1234                    dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1235                    dir.path().join("*.zip").to_str().unwrap().to_string(),
1236                ],
1237                ..Default::default()
1238            }],
1239            ..Default::default()
1240        };
1241
1242        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1243        let plan = s.plan().unwrap();
1244        s.execute(&plan, false).unwrap();
1245
1246        let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1247        // UploadArtifacts call + UploadManifest call
1248        assert_eq!(uploaded.len(), 2);
1249        let artifact_call = uploaded
1250            .iter()
1251            .find(|(_tag, files)| files.iter().any(|f| f.ends_with("app.tar.gz")))
1252            .expect("expected an upload call containing user artifacts");
1253        assert_eq!(artifact_call.0, "v0.1.0");
1254        assert_eq!(artifact_call.1.len(), 2);
1255        assert!(artifact_call.1.iter().any(|f| f.ends_with("app.tar.gz")));
1256        assert!(artifact_call.1.iter().any(|f| f.ends_with("app.zip")));
1257    }
1258
1259    #[test]
1260    fn execute_dry_run_shows_artifacts() {
1261        let dir = tempfile::tempdir().unwrap();
1262        std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1263
1264        let config = Config {
1265            changelog: ChangelogConfig {
1266                file: None,
1267                ..Default::default()
1268            },
1269            packages: vec![PackageConfig {
1270                path: ".".into(),
1271                version_files: vec!["__sr_test_dummy_no_bump__".into()],
1272                artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1273                ..Default::default()
1274            }],
1275            ..Default::default()
1276        };
1277
1278        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1279        let plan = s.plan().unwrap();
1280        s.execute(&plan, true).unwrap();
1281
1282        // No uploads should happen during dry-run
1283        let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1284        assert!(uploaded.is_empty());
1285    }
1286
1287    #[test]
1288    fn execute_no_artifacts_skips_upload() {
1289        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1290        let plan = s.plan().unwrap();
1291        s.execute(&plan, false).unwrap();
1292
1293        // No user-declared artifacts → no user-artifact upload call. The
1294        // manifest stage still uploads sr-manifest.json.
1295        let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1296        let user_uploads: Vec<_> = uploaded
1297            .iter()
1298            .filter(|(_tag, files)| {
1299                !files
1300                    .iter()
1301                    .all(|f| f.ends_with(crate::manifest::MANIFEST_ASSET_NAME))
1302            })
1303            .collect();
1304        assert!(
1305            user_uploads.is_empty(),
1306            "unexpected non-manifest uploads: {user_uploads:?}"
1307        );
1308    }
1309
1310    #[test]
1311    fn resolve_globs_basic() {
1312        let dir = tempfile::tempdir().unwrap();
1313        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1314        std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1315        std::fs::create_dir(dir.path().join("subdir")).unwrap();
1316
1317        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1318        let result = resolve_globs(&[pattern]).unwrap();
1319        assert_eq!(result.len(), 2);
1320        assert!(result.iter().any(|f: &String| f.ends_with("a.txt")));
1321        assert!(result.iter().any(|f: &String| f.ends_with("b.txt")));
1322    }
1323
1324    #[test]
1325    fn resolve_globs_deduplicates() {
1326        let dir = tempfile::tempdir().unwrap();
1327        std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1328
1329        let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1330        // Same pattern twice should not produce duplicates
1331        let result = resolve_globs(&[pattern.clone(), pattern]).unwrap();
1332        assert_eq!(result.len(), 1);
1333    }
1334
1335    // --- floating tags tests ---
1336
1337    #[test]
1338    fn plan_floating_tag_when_enabled() {
1339        let tag = TagInfo {
1340            name: "v3.2.0".into(),
1341            version: Version::new(3, 2, 0),
1342            sha: "d".repeat(40),
1343        };
1344        let config = config_with_git(GitConfig {
1345            floating_tag: true,
1346            ..Default::default()
1347        });
1348
1349        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1350        let plan = s.plan().unwrap();
1351        assert_eq!(plan.next_version, Version::new(3, 2, 1));
1352        assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1353    }
1354
1355    #[test]
1356    fn plan_no_floating_tag_when_disabled() {
1357        let s = make_strategy(
1358            vec![],
1359            vec![raw_commit("feat: something")],
1360            config_with_git(GitConfig {
1361                floating_tag: false,
1362                ..Default::default()
1363            }),
1364        );
1365        let plan = s.plan().unwrap();
1366        assert!(plan.floating_tag_name.is_none());
1367    }
1368
1369    #[test]
1370    fn plan_floating_tag_custom_prefix() {
1371        let tag = TagInfo {
1372            name: "release-2.5.0".into(),
1373            version: Version::new(2, 5, 0),
1374            sha: "e".repeat(40),
1375        };
1376        let config = config_with_git(GitConfig {
1377            floating_tag: true,
1378            tag_prefix: "release-".into(),
1379            ..Default::default()
1380        });
1381
1382        let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1383        let plan = s.plan().unwrap();
1384        assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1385    }
1386
1387    #[test]
1388    fn execute_floating_tags_force_create_and_push() {
1389        let config = config_with_git(GitConfig {
1390            floating_tag: true,
1391            ..Default::default()
1392        });
1393
1394        let tag = TagInfo {
1395            name: "v1.2.3".into(),
1396            version: Version::new(1, 2, 3),
1397            sha: "f".repeat(40),
1398        };
1399        let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1400        let plan = s.plan().unwrap();
1401        assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1402
1403        s.execute(&plan, false).unwrap();
1404
1405        assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1406        assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1407    }
1408
1409    #[test]
1410    fn execute_no_floating_tags_when_disabled() {
1411        let s = make_strategy(
1412            vec![],
1413            vec![raw_commit("feat: something")],
1414            config_with_git(GitConfig {
1415                floating_tag: false,
1416                ..Default::default()
1417            }),
1418        );
1419        let plan = s.plan().unwrap();
1420        assert!(plan.floating_tag_name.is_none());
1421
1422        s.execute(&plan, false).unwrap();
1423
1424        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1425        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1426    }
1427
1428    #[test]
1429    fn execute_floating_tags_dry_run_no_side_effects() {
1430        let config = config_with_git(GitConfig {
1431            floating_tag: true,
1432            ..Default::default()
1433        });
1434
1435        let tag = TagInfo {
1436            name: "v2.0.0".into(),
1437            version: Version::new(2, 0, 0),
1438            sha: "a".repeat(40),
1439        };
1440        let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1441        let plan = s.plan().unwrap();
1442        assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1443
1444        s.execute(&plan, true).unwrap();
1445
1446        assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1447        assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1448    }
1449
1450    #[test]
1451    fn execute_floating_tags_idempotent() {
1452        let config = config_with_git(GitConfig {
1453            floating_tag: true,
1454            ..Default::default()
1455        });
1456
1457        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1458        let plan = s.plan().unwrap();
1459        assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1460
1461        // Run twice
1462        s.execute(&plan, false).unwrap();
1463        s.execute(&plan, false).unwrap();
1464
1465        // Force ops run every time (correct for floating tags)
1466        assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1467        assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1468    }
1469
1470    // --- force mode tests ---
1471
1472    #[test]
1473    fn force_rerelease_when_tag_at_head() {
1474        let tag = TagInfo {
1475            name: "v1.2.3".into(),
1476            version: Version::new(1, 2, 3),
1477            sha: "a".repeat(40),
1478        };
1479        let mut s = make_strategy(vec![tag], vec![], Config::default());
1480        // HEAD == tag SHA, and no new commits
1481        s.git.head = "a".repeat(40);
1482        s.force = true;
1483
1484        let plan = s.plan().unwrap();
1485        assert_eq!(plan.next_version, Version::new(1, 2, 3));
1486        assert_eq!(plan.tag_name, "v1.2.3");
1487        assert!(plan.commits.is_empty());
1488        assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1489    }
1490
1491    #[test]
1492    fn force_fails_when_tag_not_at_head() {
1493        let tag = TagInfo {
1494            name: "v1.2.3".into(),
1495            version: Version::new(1, 2, 3),
1496            sha: "a".repeat(40),
1497        };
1498        let mut s = make_strategy(vec![tag], vec![], Config::default());
1499        // HEAD != tag SHA
1500        s.git.head = "b".repeat(40);
1501        s.force = true;
1502
1503        let err = s.plan().unwrap_err();
1504        assert!(matches!(err, ReleaseError::NoCommits { .. }));
1505    }
1506
1507    // --- build hooks + artifact validation tests ---
1508
1509    /// Build hooks receive SR_VERSION set to the bumped version.
1510    #[test]
1511    fn execute_runs_build_hook_with_version_env() {
1512        let dir = tempfile::tempdir().unwrap();
1513        let marker = dir.path().join("saw_version.txt");
1514        let cmd = format!("echo \"$SR_VERSION\" > {}", marker.display());
1515
1516        let config = Config {
1517            changelog: ChangelogConfig {
1518                file: None,
1519                ..Default::default()
1520            },
1521            packages: vec![PackageConfig {
1522                path: ".".into(),
1523                version_files: vec!["__sr_test_dummy_no_bump__".into()],
1524                hooks: Some(HooksConfig {
1525                    build: vec![cmd],
1526                    ..Default::default()
1527                }),
1528                ..Default::default()
1529            }],
1530            ..Default::default()
1531        };
1532
1533        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1534        let plan = s.plan().unwrap();
1535        s.execute(&plan, false).unwrap();
1536
1537        let content = std::fs::read_to_string(&marker).unwrap();
1538        assert_eq!(content.trim(), "0.1.0");
1539    }
1540
1541    /// Build hooks run AFTER version bump — the manifest on disk contains the
1542    /// new version when the build executes.
1543    #[test]
1544    fn execute_build_sees_bumped_version_on_disk() {
1545        let dir = tempfile::tempdir().unwrap();
1546        let cargo_path = dir.path().join("Cargo.toml");
1547        std::fs::write(
1548            &cargo_path,
1549            "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1550        )
1551        .unwrap();
1552
1553        let marker = dir.path().join("observed_version.txt");
1554        // Build hook reads whatever version is currently in Cargo.toml.
1555        let cmd = format!(
1556            "grep '^version' {} > {}",
1557            cargo_path.display(),
1558            marker.display()
1559        );
1560
1561        let config = Config {
1562            changelog: ChangelogConfig {
1563                file: None,
1564                ..Default::default()
1565            },
1566            packages: vec![PackageConfig {
1567                path: ".".into(),
1568                version_files: vec![cargo_path.to_str().unwrap().to_string()],
1569                hooks: Some(HooksConfig {
1570                    build: vec![cmd],
1571                    ..Default::default()
1572                }),
1573                ..Default::default()
1574            }],
1575            ..Default::default()
1576        };
1577
1578        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1579        let plan = s.plan().unwrap();
1580        s.execute(&plan, false).unwrap();
1581
1582        let content = std::fs::read_to_string(&marker).unwrap();
1583        assert!(
1584            content.contains("0.1.0"),
1585            "build should see bumped version on disk, got: {content}"
1586        );
1587    }
1588
1589    /// A failing build aborts before tag/commit/release — preserves the invariant
1590    /// that a tag on remote implies a successful build.
1591    #[test]
1592    fn execute_build_failure_leaves_no_tag_or_commit() {
1593        let config = Config {
1594            changelog: ChangelogConfig {
1595                file: None,
1596                ..Default::default()
1597            },
1598            packages: vec![PackageConfig {
1599                path: ".".into(),
1600                version_files: vec!["__sr_test_dummy_no_bump__".into()],
1601                hooks: Some(HooksConfig {
1602                    build: vec!["false".into()],
1603                    ..Default::default()
1604                }),
1605                ..Default::default()
1606            }],
1607            ..Default::default()
1608        };
1609
1610        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1611        let plan = s.plan().unwrap();
1612        let err = s.execute(&plan, false).unwrap_err();
1613        assert!(matches!(err, ReleaseError::Hook(_)), "got {err:?}");
1614
1615        assert!(s.git.created_tags.lock().unwrap().is_empty());
1616        assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1617        assert!(s.git.committed.lock().unwrap().is_empty());
1618        assert!(s.vcs.releases.lock().unwrap().is_empty());
1619    }
1620
1621    /// When `hooks.build` is set, every declared artifact glob must match ≥1 file
1622    /// or the pipeline aborts before tagging.
1623    #[test]
1624    fn execute_validation_fails_when_declared_artifact_missing() {
1625        let config = Config {
1626            changelog: ChangelogConfig {
1627                file: None,
1628                ..Default::default()
1629            },
1630            packages: vec![PackageConfig {
1631                path: ".".into(),
1632                version_files: vec!["__sr_test_dummy_no_bump__".into()],
1633                artifacts: vec!["/definitely/not/here/*.tar.gz".into()],
1634                hooks: Some(HooksConfig {
1635                    build: vec!["true".into()],
1636                    ..Default::default()
1637                }),
1638                ..Default::default()
1639            }],
1640            ..Default::default()
1641        };
1642
1643        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1644        let plan = s.plan().unwrap();
1645        let err = s.execute(&plan, false).unwrap_err();
1646
1647        match err {
1648            ReleaseError::Vcs(ref msg) => {
1649                assert!(
1650                    msg.contains("matched no files"),
1651                    "expected validation error, got: {msg}"
1652                );
1653            }
1654            other => panic!("expected Vcs error, got {other:?}"),
1655        }
1656
1657        assert!(s.git.created_tags.lock().unwrap().is_empty());
1658        assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1659        assert!(s.vcs.releases.lock().unwrap().is_empty());
1660    }
1661
1662    /// Validation passes when every declared glob resolves to ≥1 file.
1663    #[test]
1664    fn execute_validation_passes_when_all_artifacts_present() {
1665        let dir = tempfile::tempdir().unwrap();
1666        std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
1667
1668        let config = Config {
1669            changelog: ChangelogConfig {
1670                file: None,
1671                ..Default::default()
1672            },
1673            packages: vec![PackageConfig {
1674                path: ".".into(),
1675                version_files: vec!["__sr_test_dummy_no_bump__".into()],
1676                artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1677                hooks: Some(HooksConfig {
1678                    build: vec!["true".into()],
1679                    ..Default::default()
1680                }),
1681                ..Default::default()
1682            }],
1683            ..Default::default()
1684        };
1685
1686        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1687        let plan = s.plan().unwrap();
1688        s.execute(&plan, false).unwrap();
1689
1690        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1691    }
1692
1693    /// No `hooks.build` means no contract — missing declared artifacts do NOT
1694    /// fail the pipeline. Preserves today's behavior for users building
1695    /// outside sr.
1696    #[test]
1697    fn execute_validation_skipped_without_build_hooks() {
1698        let config = Config {
1699            changelog: ChangelogConfig {
1700                file: None,
1701                ..Default::default()
1702            },
1703            packages: vec![PackageConfig {
1704                path: ".".into(),
1705                version_files: vec!["__sr_test_dummy_no_bump__".into()],
1706                artifacts: vec!["/still/not/here/*.tar.gz".into()],
1707                // No hooks.build — today's behavior.
1708                ..Default::default()
1709            }],
1710            ..Default::default()
1711        };
1712
1713        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1714        let plan = s.plan().unwrap();
1715        s.execute(&plan, false).unwrap();
1716
1717        assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1718    }
1719
1720    // --- manifest + reconciliation tests ---
1721
1722    /// sr-manifest.json is uploaded on every successful release.
1723    #[test]
1724    fn execute_uploads_manifest_as_final_asset() {
1725        let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1726        let plan = s.plan().unwrap();
1727        s.execute(&plan, false).unwrap();
1728
1729        let assets = s.vcs.list_assets("v0.1.0").unwrap();
1730        assert!(
1731            assets.contains(&crate::manifest::MANIFEST_ASSET_NAME.to_string()),
1732            "manifest should be uploaded; got {assets:?}"
1733        );
1734    }
1735
1736    /// Manifest records the tag, the commit sha at HEAD, and (when declared)
1737    /// the resolved artifact basenames.
1738    #[test]
1739    fn execute_manifest_contains_tag_and_artifacts() {
1740        let dir = tempfile::tempdir().unwrap();
1741        std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
1742
1743        let config = Config {
1744            changelog: ChangelogConfig {
1745                file: None,
1746                ..Default::default()
1747            },
1748            packages: vec![PackageConfig {
1749                path: ".".into(),
1750                version_files: vec!["__sr_test_dummy_no_bump__".into()],
1751                artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1752                ..Default::default()
1753            }],
1754            ..Default::default()
1755        };
1756
1757        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1758        let plan = s.plan().unwrap();
1759        s.execute(&plan, false).unwrap();
1760
1761        let manifest_bytes = s
1762            .vcs
1763            .fetch_asset("v0.1.0", crate::manifest::MANIFEST_ASSET_NAME)
1764            .unwrap()
1765            .expect("manifest should be present");
1766        let manifest: crate::manifest::Manifest = serde_json::from_slice(&manifest_bytes).unwrap();
1767
1768        assert_eq!(manifest.tag, "v0.1.0");
1769        assert!(manifest.artifacts.iter().any(|a| a == "app.tar.gz"));
1770        assert!(!manifest.commit_sha.is_empty());
1771        assert!(!manifest.sr_version.is_empty());
1772    }
1773
1774    /// Second run against a release that already has all declared artifacts
1775    /// uploaded is a no-op for upload — no duplicate asset errors.
1776    #[test]
1777    fn execute_skips_already_uploaded_artifacts() {
1778        let dir = tempfile::tempdir().unwrap();
1779        std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
1780
1781        let config = Config {
1782            changelog: ChangelogConfig {
1783                file: None,
1784                ..Default::default()
1785            },
1786            packages: vec![PackageConfig {
1787                path: ".".into(),
1788                version_files: vec!["__sr_test_dummy_no_bump__".into()],
1789                artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1790                ..Default::default()
1791            }],
1792            ..Default::default()
1793        };
1794
1795        let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1796        let plan = s.plan().unwrap();
1797
1798        // First run uploads the artifact + manifest.
1799        s.execute(&plan, false).unwrap();
1800        let uploads_after_first = s.vcs.uploaded_assets.lock().unwrap().len();
1801
1802        // Second run should skip both — no new uploads.
1803        s.execute(&plan, false).unwrap();
1804        let uploads_after_second = s.vcs.uploaded_assets.lock().unwrap().len();
1805
1806        assert_eq!(
1807            uploads_after_first, uploads_after_second,
1808            "idempotent re-run should not re-upload existing assets"
1809        );
1810    }
1811
1812    /// plan() refuses to cut a new version when the latest remote tag has a
1813    /// manifest declaring artifacts that aren't present on the release.
1814    #[test]
1815    fn plan_blocks_when_previous_release_incomplete() {
1816        let prev_tag = TagInfo {
1817            name: "v1.0.0".into(),
1818            version: Version::new(1, 0, 0),
1819            sha: "a".repeat(40),
1820        };
1821        let s = make_strategy(
1822            vec![prev_tag],
1823            vec![raw_commit("feat: new thing")],
1824            test_config(),
1825        );
1826
1827        // Seed an incomplete manifest on the remote: declares an asset that
1828        // isn't in list_assets.
1829        let incomplete = crate::manifest::Manifest {
1830            sr_version: "7.1.0".into(),
1831            tag: "v1.0.0".into(),
1832            commit_sha: "a".repeat(40),
1833            artifacts: vec!["missing-binary.tar.gz".into()],
1834            completed_at: "2026-04-18T00:00:00Z".into(),
1835        };
1836        s.vcs.seed_asset(
1837            "v1.0.0",
1838            crate::manifest::MANIFEST_ASSET_NAME,
1839            serde_json::to_vec(&incomplete).unwrap(),
1840        );
1841
1842        let err = s.plan().unwrap_err();
1843        match err {
1844            ReleaseError::Vcs(ref msg) => {
1845                assert!(
1846                    msg.contains("incomplete") && msg.contains("missing-binary.tar.gz"),
1847                    "unexpected error: {msg}"
1848                );
1849            }
1850            other => panic!("expected Vcs error, got {other:?}"),
1851        }
1852    }
1853
1854    /// Complete manifest on the previous release → plan proceeds.
1855    #[test]
1856    fn plan_passes_when_previous_release_complete() {
1857        let prev_tag = TagInfo {
1858            name: "v1.0.0".into(),
1859            version: Version::new(1, 0, 0),
1860            sha: "a".repeat(40),
1861        };
1862        let s = make_strategy(
1863            vec![prev_tag],
1864            vec![raw_commit("feat: next thing")],
1865            test_config(),
1866        );
1867
1868        let complete = crate::manifest::Manifest {
1869            sr_version: "7.1.0".into(),
1870            tag: "v1.0.0".into(),
1871            commit_sha: "a".repeat(40),
1872            artifacts: vec!["ok.tar.gz".into()],
1873            completed_at: "2026-04-18T00:00:00Z".into(),
1874        };
1875        s.vcs.seed_asset(
1876            "v1.0.0",
1877            crate::manifest::MANIFEST_ASSET_NAME,
1878            serde_json::to_vec(&complete).unwrap(),
1879        );
1880        s.vcs.seed_asset("v1.0.0", "ok.tar.gz", b"bin".to_vec());
1881
1882        let plan = s.plan().unwrap();
1883        assert_eq!(plan.next_version, Version::new(1, 1, 0));
1884    }
1885
1886    /// No manifest on the previous release (legacy/pre-sr) → plan proceeds
1887    /// without blocking. We can't distinguish legacy from aborted remotely.
1888    #[test]
1889    fn plan_passes_when_previous_release_has_no_manifest() {
1890        let prev_tag = TagInfo {
1891            name: "v1.0.0".into(),
1892            version: Version::new(1, 0, 0),
1893            sha: "a".repeat(40),
1894        };
1895        let s = make_strategy(
1896            vec![prev_tag],
1897            vec![raw_commit("feat: legacy compat")],
1898            test_config(),
1899        );
1900        // Intentionally no seed — fetch_asset returns None → Unknown status.
1901
1902        let plan = s.plan().unwrap();
1903        assert_eq!(plan.next_version, Version::new(1, 1, 0));
1904    }
1905
1906    /// --force bypasses the reconciliation check so a broken tag can be healed
1907    /// by re-running the pipeline against it.
1908    #[test]
1909    fn plan_with_force_bypasses_reconciliation_block() {
1910        let prev_tag = TagInfo {
1911            name: "v1.0.0".into(),
1912            version: Version::new(1, 0, 0),
1913            sha: "a".repeat(40),
1914        };
1915        let mut s = make_strategy(vec![prev_tag], vec![], test_config());
1916        s.git.head = "a".repeat(40); // HEAD at tag → force-rerelease path
1917        s.force = true;
1918
1919        let incomplete = crate::manifest::Manifest {
1920            sr_version: "7.1.0".into(),
1921            tag: "v1.0.0".into(),
1922            commit_sha: "a".repeat(40),
1923            artifacts: vec!["missing.tar.gz".into()],
1924            completed_at: "2026-04-18T00:00:00Z".into(),
1925        };
1926        s.vcs.seed_asset(
1927            "v1.0.0",
1928            crate::manifest::MANIFEST_ASSET_NAME,
1929            serde_json::to_vec(&incomplete).unwrap(),
1930        );
1931
1932        // Should NOT error — --force bypasses the reconciliation check.
1933        let plan = s.plan().unwrap();
1934        assert_eq!(plan.next_version, Version::new(1, 0, 0));
1935    }
1936}