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