1use 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#[derive(Debug, clap::Args)]
14pub struct VersionArgs {
15 pub bump: String,
17 #[arg(long)]
19 pub push: bool,
20 #[arg(long)]
22 pub force: bool,
23}
24
25pub 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, ¤t)?;
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 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(§ions[unreleased_index + 1..next_section_index]);
309
310 let mut promoted = String::new();
311 for line in §ions[..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 §ions[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 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", ¤t).unwrap().to_string(),
444 "1.2.4"
445 );
446 assert_eq!(
447 resolve_next_version("minor", ¤t).unwrap().to_string(),
448 "1.3.0"
449 );
450 assert_eq!(
451 resolve_next_version("major", ¤t).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", ¤t).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 #[test]
695 fn run_aborts_when_package_check_fails() {
696 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 #[test]
758 fn run_force_bypasses_package_check_errors() {
759 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}