Skip to main content

mars_agents/cli/
version.rs

1//! `mars version <bump|X.Y.Z> [--push]` — bump package version, commit, and tag.
2
3use std::path::Path;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use semver::{BuildMetadata, Prerelease, Version};
7
8use crate::diagnostic::DiagnosticLevel;
9use crate::error::{ConfigError, MarsError};
10use crate::sync::{ResolutionMode, SyncOptions, SyncRequest};
11use crate::types::MarsContext;
12
13use super::{check, output};
14
15/// Arguments for `mars version`.
16#[derive(Debug, clap::Args)]
17pub struct VersionArgs {
18    /// Version bump: patch, minor, major, or explicit X.Y.Z
19    pub bump: String,
20    /// Push branch and tag to origin after versioning
21    #[arg(long)]
22    pub push: bool,
23    /// Force version even if package check fails (bypass publish gate)
24    #[arg(long)]
25    pub force: bool,
26}
27
28/// Run `mars version`.
29pub fn run(args: &VersionArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
30    require_clean_working_tree(&ctx.project_root)?;
31    require_package_check(&ctx.project_root, args.force)?;
32
33    let mut config = crate::config::load(&ctx.project_root)?;
34    let package = config
35        .package
36        .as_mut()
37        .ok_or_else(|| ConfigError::Invalid {
38            message: "mars.toml must contain [package] with name and version".to_string(),
39        })?;
40
41    if package.name.trim().is_empty() {
42        return Err(ConfigError::Invalid {
43            message: "[package].name must not be empty".to_string(),
44        }
45        .into());
46    }
47
48    require_validate_pass(&ctx.project_root, args.force)?;
49
50    let current = parse_release_version(&package.version, "[package].version")?;
51    let next = resolve_next_version(&args.bump, &current)?;
52
53    if next == current {
54        return Err(ConfigError::Invalid {
55            message: format!(
56                "new version `{}` matches current version `{}`",
57                next, package.version
58            ),
59        }
60        .into());
61    }
62
63    let next_version = next.to_string();
64    let tag = format!("v{next_version}");
65
66    ensure_tag_not_exists(&ctx.project_root, &tag)?;
67
68    package.version = next_version.clone();
69    crate::config::save(&ctx.project_root, &config)?;
70    update_changelog_if_present(&ctx.project_root, &next_version)?;
71
72    crate::platform::process::run_git(
73        &["add", "mars.toml"],
74        &ctx.project_root,
75        "git add mars.toml",
76    )?;
77    if ctx.project_root.join("CHANGELOG.md").is_file() {
78        crate::platform::process::run_git(
79            &["add", "CHANGELOG.md"],
80            &ctx.project_root,
81            "git add CHANGELOG.md",
82        )?;
83    }
84    crate::platform::process::run_git(
85        &["commit", "-m", &tag],
86        &ctx.project_root,
87        &format!("git commit -m {tag}"),
88    )?;
89    crate::platform::process::run_git(
90        &["tag", "-a", &tag, "-m", &tag],
91        &ctx.project_root,
92        &format!("git tag -a {tag} -m {tag}"),
93    )?;
94
95    if args.push {
96        let branch = current_branch(&ctx.project_root)?;
97        crate::platform::process::run_git(
98            &["push", "origin", &branch],
99            &ctx.project_root,
100            &format!("git push origin {branch}"),
101        )?;
102        crate::platform::process::run_git(
103            &["push", "origin", &tag],
104            &ctx.project_root,
105            &format!("git push origin {tag}"),
106        )?;
107    }
108
109    if json {
110        output::print_json(&serde_json::json!({
111            "ok": true,
112            "version": next_version,
113            "tag": tag,
114            "pushed": args.push,
115        }));
116    } else {
117        println!("{tag}");
118    }
119
120    Ok(0)
121}
122
123fn require_clean_working_tree(project_root: &Path) -> Result<(), MarsError> {
124    let output = crate::platform::process::run_git(
125        &["status", "--porcelain"],
126        project_root,
127        "git status --porcelain",
128    )?;
129
130    if !output.is_empty() {
131        return Err(ConfigError::Invalid {
132            message: "working tree must be clean before running `mars version`".to_string(),
133        }
134        .into());
135    }
136
137    Ok(())
138}
139
140fn require_package_check(project_root: &Path, force: bool) -> Result<(), MarsError> {
141    // Skip check if this isn't a source package (no agents/, skills/, or SKILL.md)
142    let has_agents = project_root.join("agents").is_dir();
143    let has_skills = project_root.join("skills").is_dir();
144    let has_root_skill = project_root.join("SKILL.md").is_file();
145    if !has_agents && !has_skills && !has_root_skill {
146        return Ok(());
147    }
148
149    match check::check_dir(project_root) {
150        Ok(report) if report.errors.is_empty() => Ok(()),
151        Ok(report) if force => {
152            for error in &report.errors {
153                eprintln!("warning (--force): {error}");
154            }
155            Ok(())
156        }
157        Ok(report) => {
158            let mut message = "package check failed:".to_string();
159            for error in &report.errors {
160                message.push_str(&format!("\n  - {error}"));
161            }
162            Err(ConfigError::Invalid { message }.into())
163        }
164        Err(e) if force => {
165            eprintln!("warning (--force): check failed: {e}");
166            Ok(())
167        }
168        Err(e) => Err(e),
169    }
170}
171
172/// Run the full sync pipeline in dry-run mode and fail on errors.
173///
174/// This catches integration issues that `mars check` cannot see:
175/// dependency resolution failures, cross-package skill graph inconsistencies,
176/// and target generation errors.
177fn require_validate_pass(project_root: &Path, force: bool) -> Result<(), MarsError> {
178    let ctx = MarsContext {
179        project_root: project_root.to_path_buf(),
180        managed_root: project_root.join(".mars"),
181        meridian_managed: false,
182    };
183    let request = SyncRequest {
184        resolution: ResolutionMode::Normal,
185        mutation: None,
186        options: SyncOptions {
187            dry_run: true,
188            ..SyncOptions::default()
189        },
190        lossiness_mode: crate::diagnostic::LossinessMode::Hidden,
191    };
192
193    match crate::sync::execute(&ctx, &request) {
194        Ok(report)
195            if report
196                .diagnostics
197                .iter()
198                .all(|d| d.level != DiagnosticLevel::Error) =>
199        {
200            Ok(())
201        }
202        Ok(report) if force => {
203            for d in &report.diagnostics {
204                if d.level == DiagnosticLevel::Error {
205                    eprintln!("warning (--force): [{}] {}", d.code, d.message);
206                }
207            }
208            Ok(())
209        }
210        Ok(report) => {
211            let mut message = "validate failed:".to_string();
212            for d in &report.diagnostics {
213                if d.level == DiagnosticLevel::Error {
214                    message.push_str(&format!("\n  - [{}] {}", d.code, d.message));
215                }
216            }
217            Err(ConfigError::Invalid { message }.into())
218        }
219        Err(e) if force => {
220            eprintln!("warning (--force): validate failed: {e}");
221            Ok(())
222        }
223        Err(e) => Err(e),
224    }
225}
226
227fn parse_release_version(value: &str, field_name: &str) -> Result<Version, MarsError> {
228    let version = Version::parse(value).map_err(|_| ConfigError::Invalid {
229        message: format!("{field_name} must be valid semver (X.Y.Z), got `{value}`"),
230    })?;
231
232    if !version.pre.is_empty() || !version.build.is_empty() {
233        return Err(ConfigError::Invalid {
234            message: format!("{field_name} must be plain X.Y.Z (no prerelease/build): `{value}`"),
235        }
236        .into());
237    }
238
239    Ok(version)
240}
241
242fn resolve_next_version(bump: &str, current: &Version) -> Result<Version, MarsError> {
243    match bump {
244        "patch" => Ok(Version {
245            major: current.major,
246            minor: current.minor,
247            patch: current
248                .patch
249                .checked_add(1)
250                .ok_or_else(|| ConfigError::Invalid {
251                    message: "patch version overflow".to_string(),
252                })?,
253            pre: Prerelease::EMPTY,
254            build: BuildMetadata::EMPTY,
255        }),
256        "minor" => Ok(Version {
257            major: current.major,
258            minor: current
259                .minor
260                .checked_add(1)
261                .ok_or_else(|| ConfigError::Invalid {
262                    message: "minor version overflow".to_string(),
263                })?,
264            patch: 0,
265            pre: Prerelease::EMPTY,
266            build: BuildMetadata::EMPTY,
267        }),
268        "major" => Ok(Version {
269            major: current
270                .major
271                .checked_add(1)
272                .ok_or_else(|| ConfigError::Invalid {
273                    message: "major version overflow".to_string(),
274                })?,
275            minor: 0,
276            patch: 0,
277            pre: Prerelease::EMPTY,
278            build: BuildMetadata::EMPTY,
279        }),
280        explicit => parse_release_version(explicit, "requested version"),
281    }
282}
283
284fn ensure_tag_not_exists(project_root: &Path, tag: &str) -> Result<(), MarsError> {
285    let output = crate::platform::process::run_git(
286        &["tag", "--list", tag],
287        project_root,
288        &format!("git tag --list {tag}"),
289    )?;
290
291    let exists = output.lines().any(|line| line.trim() == tag);
292
293    if exists {
294        return Err(ConfigError::Invalid {
295            message: format!("tag `{tag}` already exists"),
296        }
297        .into());
298    }
299
300    Ok(())
301}
302
303fn current_branch(project_root: &Path) -> Result<String, MarsError> {
304    let branch = crate::platform::process::run_git(
305        &["rev-parse", "--abbrev-ref", "HEAD"],
306        project_root,
307        "git rev-parse --abbrev-ref HEAD",
308    )?;
309    if branch.is_empty() || branch == "HEAD" {
310        return Err(ConfigError::Invalid {
311            message: "cannot push from detached HEAD".to_string(),
312        }
313        .into());
314    }
315
316    Ok(branch)
317}
318
319fn update_changelog_if_present(project_root: &Path, next_version: &str) -> Result<(), MarsError> {
320    let changelog_path = project_root.join("CHANGELOG.md");
321    if !changelog_path.is_file() {
322        return Ok(());
323    }
324
325    let content = std::fs::read_to_string(&changelog_path)?;
326    let Some(updated) = promote_unreleased_changelog(&content, next_version, &today_iso_date())
327    else {
328        return Ok(());
329    };
330
331    if updated.unreleased_was_empty {
332        eprintln!("warning: CHANGELOG.md has no entries under [Unreleased]");
333    }
334
335    std::fs::write(changelog_path, updated.content)?;
336    Ok(())
337}
338
339struct ChangelogPromotion {
340    content: String,
341    unreleased_was_empty: bool,
342}
343
344fn promote_unreleased_changelog(
345    content: &str,
346    next_version: &str,
347    date: &str,
348) -> Option<ChangelogPromotion> {
349    let sections = content.split_inclusive('\n').collect::<Vec<_>>();
350
351    let unreleased_index = sections
352        .iter()
353        .position(|line| is_unreleased_header(line.trim_end()))?;
354    let next_section_index = sections
355        .iter()
356        .enumerate()
357        .skip(unreleased_index + 1)
358        .find_map(|(index, line)| {
359            if line.trim_start().starts_with("## [") {
360                Some(index)
361            } else {
362                None
363            }
364        })
365        .unwrap_or(sections.len());
366
367    let unreleased_was_empty =
368        changelog_section_is_empty(&sections[unreleased_index + 1..next_section_index]);
369
370    let mut promoted = String::new();
371    for line in &sections[..unreleased_index] {
372        promoted.push_str(line);
373    }
374    promoted.push_str("## [Unreleased]\n\n");
375    promoted.push_str(&format!("## [{next_version}] - {date}\n"));
376    for line in &sections[unreleased_index + 1..] {
377        promoted.push_str(line);
378    }
379
380    Some(ChangelogPromotion {
381        content: promoted,
382        unreleased_was_empty,
383    })
384}
385
386fn is_unreleased_header(line: &str) -> bool {
387    let trimmed = line.trim();
388    trimmed.starts_with("## [")
389        && trimmed.ends_with(']')
390        && trimmed
391            .trim_start_matches("## [")
392            .trim_end_matches(']')
393            .eq_ignore_ascii_case("unreleased")
394}
395
396fn changelog_section_is_empty(lines: &[&str]) -> bool {
397    lines.iter().all(|line| {
398        let trimmed = line.trim();
399        trimmed.is_empty() || trimmed.starts_with("###")
400    })
401}
402
403fn today_iso_date() -> String {
404    let days_since_epoch = SystemTime::now()
405        .duration_since(UNIX_EPOCH)
406        .unwrap_or_default()
407        .as_secs()
408        / 86_400;
409    civil_date_from_days(days_since_epoch as i64)
410}
411
412fn civil_date_from_days(days_since_unix_epoch: i64) -> String {
413    // Howard Hinnant's civil-from-days algorithm. Converts days since
414    // 1970-01-01 to a proleptic Gregorian date without platform-specific APIs.
415    let z = days_since_unix_epoch + 719_468;
416    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
417    let doe = z - era * 146_097;
418    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
419    let y = yoe + era * 400;
420    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
421    let mp = (5 * doy + 2) / 153;
422    let day = doy - (153 * mp + 2) / 5 + 1;
423    let month = mp + if mp < 10 { 3 } else { -9 };
424    let year = y + if month <= 2 { 1 } else { 0 };
425
426    format!("{year:04}-{month:02}-{day:02}")
427}
428
429#[cfg(test)]
430mod tests {
431    use std::ffi::OsStr;
432    use std::path::Path;
433    use std::process::Command;
434
435    use tempfile::TempDir;
436
437    use super::*;
438
439    fn run_git_test<I, S>(cwd: &Path, args: I) -> String
440    where
441        I: IntoIterator<Item = S>,
442        S: AsRef<OsStr>,
443    {
444        let mut command = Command::new("git");
445        crate::platform::process::remove_git_local_env(&mut command);
446        command.env("GIT_AUTHOR_NAME", "Mars Test");
447        command.env("GIT_AUTHOR_EMAIL", "mars@example.com");
448        command.env("GIT_COMMITTER_NAME", "Mars Test");
449        command.env("GIT_COMMITTER_EMAIL", "mars@example.com");
450        let output = command.current_dir(cwd).args(args).output().unwrap();
451        if !output.status.success() {
452            panic!(
453                "git command failed: {}\nstdout:\n{}\nstderr:\n{}",
454                output.status,
455                String::from_utf8_lossy(&output.stdout),
456                String::from_utf8_lossy(&output.stderr)
457            );
458        }
459        String::from_utf8_lossy(&output.stdout).trim().to_string()
460    }
461
462    fn init_repo_with_mars_toml(mars_toml: &str) -> (TempDir, super::super::MarsContext) {
463        let repo = TempDir::new().unwrap();
464        run_git_test(repo.path(), ["init", "."]);
465        run_git_test(repo.path(), ["config", "user.name", "Mars Test"]);
466        run_git_test(repo.path(), ["config", "user.email", "mars@example.com"]);
467
468        std::fs::create_dir_all(repo.path().join(".agents")).unwrap();
469        std::fs::create_dir_all(repo.path().join("agents")).unwrap();
470        std::fs::write(
471            repo.path().join("agents/test-agent.md"),
472            "---\nname: test-agent\ndescription: test\n---\n# Test",
473        )
474        .unwrap();
475        std::fs::write(repo.path().join("mars.toml"), mars_toml).unwrap();
476        run_git_test(repo.path(), ["add", "."]);
477        run_git_test(repo.path(), ["commit", "-m", "init"]);
478
479        let ctx = super::super::MarsContext::for_test(
480            repo.path().to_path_buf(),
481            repo.path().join(".agents"),
482        );
483        (repo, ctx)
484    }
485
486    #[test]
487    fn parse_release_version_accepts_plain_semver() {
488        let parsed = parse_release_version("1.2.3", "field").unwrap();
489        assert_eq!(parsed.to_string(), "1.2.3");
490    }
491
492    #[test]
493    fn parse_release_version_rejects_prerelease() {
494        let err = parse_release_version("1.2.3-alpha.1", "field").unwrap_err();
495        assert!(err.to_string().contains("plain X.Y.Z"));
496    }
497
498    #[test]
499    fn resolve_next_version_bump_kinds() {
500        let current = Version::parse("1.2.3").unwrap();
501
502        assert_eq!(
503            resolve_next_version("patch", &current).unwrap().to_string(),
504            "1.2.4"
505        );
506        assert_eq!(
507            resolve_next_version("minor", &current).unwrap().to_string(),
508            "1.3.0"
509        );
510        assert_eq!(
511            resolve_next_version("major", &current).unwrap().to_string(),
512            "2.0.0"
513        );
514    }
515
516    #[test]
517    fn resolve_next_version_explicit() {
518        let current = Version::parse("1.2.3").unwrap();
519        assert_eq!(
520            resolve_next_version("4.5.6", &current).unwrap().to_string(),
521            "4.5.6"
522        );
523    }
524
525    #[test]
526    fn run_patch_updates_version_commits_and_tags() {
527        let (repo, ctx) = init_repo_with_mars_toml(
528            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
529        );
530
531        let args = VersionArgs {
532            bump: "patch".to_string(),
533            push: false,
534            force: false,
535        };
536
537        let exit = run(&args, &ctx, true).unwrap();
538        assert_eq!(exit, 0);
539
540        let config = crate::config::load(repo.path()).unwrap();
541        assert_eq!(config.package.unwrap().version, "0.1.1");
542
543        let subject = run_git_test(repo.path(), ["log", "-1", "--pretty=%s"]);
544        assert_eq!(subject, "v0.1.1");
545
546        let tag = run_git_test(repo.path(), ["tag", "--list", "v0.1.1"]);
547        assert_eq!(tag, "v0.1.1");
548    }
549
550    #[test]
551    fn run_promotes_unreleased_in_changelog() {
552        let (repo, ctx) = init_repo_with_mars_toml(
553            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
554        );
555        std::fs::write(
556            repo.path().join("CHANGELOG.md"),
557            "# Changelog\n\n## [Unreleased]\n\n### Added\n- New feature X\n\n### Fixed\n- Bug Y\n",
558        )
559        .unwrap();
560        run_git_test(repo.path(), ["add", "CHANGELOG.md"]);
561        run_git_test(repo.path(), ["commit", "-m", "add changelog"]);
562
563        let args = VersionArgs {
564            bump: "patch".to_string(),
565            push: false,
566            force: false,
567        };
568
569        let exit = run(&args, &ctx, true).unwrap();
570        assert_eq!(exit, 0);
571
572        let changelog = std::fs::read_to_string(repo.path().join("CHANGELOG.md")).unwrap();
573        let today = today_iso_date();
574        assert!(changelog.contains("## [Unreleased]\n\n## [0.1.1] - "));
575        assert!(changelog.contains(&format!(
576            "## [0.1.1] - {today}\n\n### Added\n- New feature X"
577        )));
578        assert!(changelog.contains("### Fixed\n- Bug Y"));
579
580        let committed_files =
581            run_git_test(repo.path(), ["show", "--name-only", "--pretty=", "HEAD"]);
582        assert!(committed_files.lines().any(|line| line == "CHANGELOG.md"));
583    }
584
585    #[test]
586    fn run_warns_on_empty_unreleased() {
587        let (repo, ctx) = init_repo_with_mars_toml(
588            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
589        );
590        std::fs::write(
591            repo.path().join("CHANGELOG.md"),
592            "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Fixed\n",
593        )
594        .unwrap();
595        run_git_test(repo.path(), ["add", "CHANGELOG.md"]);
596        run_git_test(repo.path(), ["commit", "-m", "add empty changelog"]);
597
598        let args = VersionArgs {
599            bump: "patch".to_string(),
600            push: false,
601            force: false,
602        };
603
604        let exit = run(&args, &ctx, true).unwrap();
605        assert_eq!(exit, 0);
606
607        let changelog = std::fs::read_to_string(repo.path().join("CHANGELOG.md")).unwrap();
608        assert!(changelog.contains("## [Unreleased]\n\n## [0.1.1] - "));
609        assert!(changelog.contains("## [0.1.1] - "));
610        assert!(
611            promote_unreleased_changelog(
612                "# Changelog\n\n## [Unreleased]\n\n### Added\n\n",
613                "0.1.1",
614                "2026-04-30"
615            )
616            .unwrap()
617            .unreleased_was_empty
618        );
619    }
620
621    #[test]
622    fn run_succeeds_without_changelog() {
623        let (repo, ctx) = init_repo_with_mars_toml(
624            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
625        );
626
627        let args = VersionArgs {
628            bump: "patch".to_string(),
629            push: false,
630            force: false,
631        };
632
633        let exit = run(&args, &ctx, true).unwrap();
634        assert_eq!(exit, 0);
635
636        let config = crate::config::load(repo.path()).unwrap();
637        assert_eq!(config.package.unwrap().version, "0.1.1");
638        assert!(!repo.path().join("CHANGELOG.md").exists());
639    }
640
641    #[test]
642    fn run_changelog_preserves_existing_versions() {
643        let (repo, ctx) = init_repo_with_mars_toml(
644            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
645        );
646        let prior_section = "## [0.1.0] - 2026-04-01\n\n### Added\n- Initial release\n";
647        std::fs::write(
648            repo.path().join("CHANGELOG.md"),
649            format!("# Changelog\n\n## [Unreleased]\n\n### Fixed\n- Bug Y\n\n{prior_section}"),
650        )
651        .unwrap();
652        run_git_test(repo.path(), ["add", "CHANGELOG.md"]);
653        run_git_test(repo.path(), ["commit", "-m", "add changelog"]);
654
655        let args = VersionArgs {
656            bump: "patch".to_string(),
657            push: false,
658            force: false,
659        };
660
661        let exit = run(&args, &ctx, true).unwrap();
662        assert_eq!(exit, 0);
663
664        let changelog = std::fs::read_to_string(repo.path().join("CHANGELOG.md")).unwrap();
665        assert!(changelog.contains("## [0.1.1] - "));
666        assert!(changelog.contains("### Fixed\n- Bug Y"));
667        assert!(changelog.ends_with(prior_section));
668    }
669
670    #[test]
671    fn run_requires_clean_working_tree() {
672        let (repo, ctx) = init_repo_with_mars_toml(
673            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
674        );
675        std::fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
676
677        let args = VersionArgs {
678            bump: "patch".to_string(),
679            push: false,
680            force: false,
681        };
682
683        let err = run(&args, &ctx, true).unwrap_err();
684        assert!(err.to_string().contains("working tree must be clean"));
685
686        let config = crate::config::load(repo.path()).unwrap();
687        assert_eq!(config.package.unwrap().version, "0.1.0");
688    }
689
690    #[test]
691    fn run_requires_package_section() {
692        let (_repo, ctx) =
693            init_repo_with_mars_toml("[dependencies]\nbase = { path = \"../base\" }\n");
694
695        let args = VersionArgs {
696            bump: "patch".to_string(),
697            push: false,
698            force: false,
699        };
700
701        let err = run(&args, &ctx, true).unwrap_err();
702        assert!(err.to_string().contains("must contain [package]"));
703    }
704
705    #[test]
706    fn run_rejects_existing_tag() {
707        let (repo, ctx) = init_repo_with_mars_toml(
708            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
709        );
710        run_git_test(repo.path(), ["tag", "-a", "v0.1.1", "-m", "v0.1.1"]);
711
712        let args = VersionArgs {
713            bump: "patch".to_string(),
714            push: false,
715            force: false,
716        };
717
718        let err = run(&args, &ctx, true).unwrap_err();
719        assert!(err.to_string().contains("tag `v0.1.1` already exists"));
720    }
721
722    #[test]
723    fn run_with_push_pushes_branch_and_tag_to_origin() {
724        let (repo, ctx) = init_repo_with_mars_toml(
725            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
726        );
727
728        let remote = TempDir::new().unwrap();
729        run_git_test(remote.path(), ["init", "--bare", "."]);
730        run_git_test(
731            repo.path(),
732            ["remote", "add", "origin", remote.path().to_str().unwrap()],
733        );
734
735        let args = VersionArgs {
736            bump: "patch".to_string(),
737            push: true,
738            force: false,
739        };
740
741        let exit = run(&args, &ctx, true).unwrap();
742        assert_eq!(exit, 0);
743
744        let branch = run_git_test(repo.path(), ["rev-parse", "--abbrev-ref", "HEAD"]);
745        let remote_branch = run_git_test(repo.path(), ["ls-remote", "--heads", "origin", &branch]);
746        assert!(remote_branch.contains(&format!("refs/heads/{branch}")));
747
748        let remote_tag = run_git_test(repo.path(), ["ls-remote", "--tags", "origin", "v0.1.1"]);
749        assert!(remote_tag.contains("refs/tags/v0.1.1"));
750    }
751
752    // ── P5: check errors abort version ───────────────────────────────────────────
753
754    #[test]
755    fn run_aborts_when_package_check_fails() {
756        // P5: an unresolvable dependency causes check to fail, version is not bumped.
757        let (repo, ctx) = init_repo_with_mars_toml(
758            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"/nonexistent-dep-xyz-p5\" }\n",
759        );
760
761        let args = VersionArgs {
762            bump: "patch".to_string(),
763            push: false,
764            force: false,
765        };
766
767        let err = run(&args, &ctx, true).unwrap_err();
768        assert!(
769            err.to_string().contains("package check failed"),
770            "expected package check failure: {err}"
771        );
772
773        let config = crate::config::load(repo.path()).unwrap();
774        assert_eq!(
775            config.package.unwrap().version,
776            "0.1.0",
777            "version must not be bumped after check failure"
778        );
779    }
780
781    #[test]
782    fn run_aborts_when_agent_model_policy_is_malformed() {
783        let (repo, ctx) = init_repo_with_mars_toml(
784            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
785        );
786        std::fs::write(
787            repo.path().join("agents/test-agent.md"),
788            "---\nname: test-agent\ndescription: test\nmodel-policies:\n  - match:\n      alias: gpt55\n      model: gpt-5.5\n---\n# Test",
789        )
790        .unwrap();
791        run_git_test(repo.path(), ["add", "agents/test-agent.md"]);
792        run_git_test(repo.path(), ["commit", "-m", "malformed agent policy"]);
793
794        let args = VersionArgs {
795            bump: "patch".to_string(),
796            push: false,
797            force: false,
798        };
799
800        let err = run(&args, &ctx, true).unwrap_err();
801        let message = err.to_string();
802        assert!(
803            message.contains("package check failed") && message.contains("model-policies[1].match"),
804            "expected model-policies package check failure: {message}"
805        );
806
807        let config = crate::config::load(repo.path()).unwrap();
808        assert_eq!(
809            config.package.unwrap().version,
810            "0.1.0",
811            "version must not be bumped after agent profile check failure"
812        );
813    }
814
815    // ── P6: --force bypasses check errors ────────────────────────────────────────
816
817    #[test]
818    fn run_force_bypasses_package_check_errors() {
819        // P6: --force proceeds despite check errors, emits warnings to stderr.
820        let (repo, ctx) = init_repo_with_mars_toml(
821            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"/nonexistent-dep-xyz-p6\" }\n",
822        );
823
824        let args = VersionArgs {
825            bump: "patch".to_string(),
826            push: false,
827            force: true,
828        };
829
830        let exit = run(&args, &ctx, true).unwrap();
831        assert_eq!(exit, 0);
832
833        let config = crate::config::load(repo.path()).unwrap();
834        assert_eq!(
835            config.package.unwrap().version,
836            "0.1.1",
837            "version must be bumped with --force"
838        );
839    }
840}