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