Skip to main content

sr_core/
release.rs

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