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