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