1use 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#[derive(Debug, clap::Args)]
17pub struct VersionArgs {
18 pub bump: String,
20 #[arg(long)]
22 pub push: bool,
23 #[arg(long)]
25 pub force: bool,
26}
27
28pub 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, ¤t)?;
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 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
172fn 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 };
191
192 match crate::sync::execute(&ctx, &request) {
193 Ok(report)
194 if report
195 .diagnostics
196 .iter()
197 .all(|d| d.level != DiagnosticLevel::Error) =>
198 {
199 Ok(())
200 }
201 Ok(report) if force => {
202 for d in &report.diagnostics {
203 if d.level == DiagnosticLevel::Error {
204 eprintln!("warning (--force): [{}] {}", d.code, d.message);
205 }
206 }
207 Ok(())
208 }
209 Ok(report) => {
210 let mut message = "validate failed:".to_string();
211 for d in &report.diagnostics {
212 if d.level == DiagnosticLevel::Error {
213 message.push_str(&format!("\n - [{}] {}", d.code, d.message));
214 }
215 }
216 Err(ConfigError::Invalid { message }.into())
217 }
218 Err(e) if force => {
219 eprintln!("warning (--force): validate failed: {e}");
220 Ok(())
221 }
222 Err(e) => Err(e),
223 }
224}
225
226fn parse_release_version(value: &str, field_name: &str) -> Result<Version, MarsError> {
227 let version = Version::parse(value).map_err(|_| ConfigError::Invalid {
228 message: format!("{field_name} must be valid semver (X.Y.Z), got `{value}`"),
229 })?;
230
231 if !version.pre.is_empty() || !version.build.is_empty() {
232 return Err(ConfigError::Invalid {
233 message: format!("{field_name} must be plain X.Y.Z (no prerelease/build): `{value}`"),
234 }
235 .into());
236 }
237
238 Ok(version)
239}
240
241fn resolve_next_version(bump: &str, current: &Version) -> Result<Version, MarsError> {
242 match bump {
243 "patch" => Ok(Version {
244 major: current.major,
245 minor: current.minor,
246 patch: current
247 .patch
248 .checked_add(1)
249 .ok_or_else(|| ConfigError::Invalid {
250 message: "patch version overflow".to_string(),
251 })?,
252 pre: Prerelease::EMPTY,
253 build: BuildMetadata::EMPTY,
254 }),
255 "minor" => Ok(Version {
256 major: current.major,
257 minor: current
258 .minor
259 .checked_add(1)
260 .ok_or_else(|| ConfigError::Invalid {
261 message: "minor version overflow".to_string(),
262 })?,
263 patch: 0,
264 pre: Prerelease::EMPTY,
265 build: BuildMetadata::EMPTY,
266 }),
267 "major" => Ok(Version {
268 major: current
269 .major
270 .checked_add(1)
271 .ok_or_else(|| ConfigError::Invalid {
272 message: "major version overflow".to_string(),
273 })?,
274 minor: 0,
275 patch: 0,
276 pre: Prerelease::EMPTY,
277 build: BuildMetadata::EMPTY,
278 }),
279 explicit => parse_release_version(explicit, "requested version"),
280 }
281}
282
283fn ensure_tag_not_exists(project_root: &Path, tag: &str) -> Result<(), MarsError> {
284 let output = crate::platform::process::run_git(
285 &["tag", "--list", tag],
286 project_root,
287 &format!("git tag --list {tag}"),
288 )?;
289
290 let exists = output.lines().any(|line| line.trim() == tag);
291
292 if exists {
293 return Err(ConfigError::Invalid {
294 message: format!("tag `{tag}` already exists"),
295 }
296 .into());
297 }
298
299 Ok(())
300}
301
302fn current_branch(project_root: &Path) -> Result<String, MarsError> {
303 let branch = crate::platform::process::run_git(
304 &["rev-parse", "--abbrev-ref", "HEAD"],
305 project_root,
306 "git rev-parse --abbrev-ref HEAD",
307 )?;
308 if branch.is_empty() || branch == "HEAD" {
309 return Err(ConfigError::Invalid {
310 message: "cannot push from detached HEAD".to_string(),
311 }
312 .into());
313 }
314
315 Ok(branch)
316}
317
318fn update_changelog_if_present(project_root: &Path, next_version: &str) -> Result<(), MarsError> {
319 let changelog_path = project_root.join("CHANGELOG.md");
320 if !changelog_path.is_file() {
321 return Ok(());
322 }
323
324 let content = std::fs::read_to_string(&changelog_path)?;
325 let Some(updated) = promote_unreleased_changelog(&content, next_version, &today_iso_date())
326 else {
327 return Ok(());
328 };
329
330 if updated.unreleased_was_empty {
331 eprintln!("warning: CHANGELOG.md has no entries under [Unreleased]");
332 }
333
334 std::fs::write(changelog_path, updated.content)?;
335 Ok(())
336}
337
338struct ChangelogPromotion {
339 content: String,
340 unreleased_was_empty: bool,
341}
342
343fn promote_unreleased_changelog(
344 content: &str,
345 next_version: &str,
346 date: &str,
347) -> Option<ChangelogPromotion> {
348 let sections = content.split_inclusive('\n').collect::<Vec<_>>();
349
350 let unreleased_index = sections
351 .iter()
352 .position(|line| is_unreleased_header(line.trim_end()))?;
353 let next_section_index = sections
354 .iter()
355 .enumerate()
356 .skip(unreleased_index + 1)
357 .find_map(|(index, line)| {
358 if line.trim_start().starts_with("## [") {
359 Some(index)
360 } else {
361 None
362 }
363 })
364 .unwrap_or(sections.len());
365
366 let unreleased_was_empty =
367 changelog_section_is_empty(§ions[unreleased_index + 1..next_section_index]);
368
369 let mut promoted = String::new();
370 for line in §ions[..unreleased_index] {
371 promoted.push_str(line);
372 }
373 promoted.push_str("## [Unreleased]\n\n");
374 promoted.push_str(&format!("## [{next_version}] - {date}\n"));
375 for line in §ions[unreleased_index + 1..] {
376 promoted.push_str(line);
377 }
378
379 Some(ChangelogPromotion {
380 content: promoted,
381 unreleased_was_empty,
382 })
383}
384
385fn is_unreleased_header(line: &str) -> bool {
386 let trimmed = line.trim();
387 trimmed.starts_with("## [")
388 && trimmed.ends_with(']')
389 && trimmed
390 .trim_start_matches("## [")
391 .trim_end_matches(']')
392 .eq_ignore_ascii_case("unreleased")
393}
394
395fn changelog_section_is_empty(lines: &[&str]) -> bool {
396 lines.iter().all(|line| {
397 let trimmed = line.trim();
398 trimmed.is_empty() || trimmed.starts_with("###")
399 })
400}
401
402fn today_iso_date() -> String {
403 let days_since_epoch = SystemTime::now()
404 .duration_since(UNIX_EPOCH)
405 .unwrap_or_default()
406 .as_secs()
407 / 86_400;
408 civil_date_from_days(days_since_epoch as i64)
409}
410
411fn civil_date_from_days(days_since_unix_epoch: i64) -> String {
412 let z = days_since_unix_epoch + 719_468;
415 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
416 let doe = z - era * 146_097;
417 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
418 let y = yoe + era * 400;
419 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
420 let mp = (5 * doy + 2) / 153;
421 let day = doy - (153 * mp + 2) / 5 + 1;
422 let month = mp + if mp < 10 { 3 } else { -9 };
423 let year = y + if month <= 2 { 1 } else { 0 };
424
425 format!("{year:04}-{month:02}-{day:02}")
426}
427
428#[cfg(test)]
429mod tests {
430 use std::ffi::OsStr;
431 use std::path::Path;
432 use std::process::Command;
433
434 use tempfile::TempDir;
435
436 use super::*;
437
438 fn run_git_test<I, S>(cwd: &Path, args: I) -> String
439 where
440 I: IntoIterator<Item = S>,
441 S: AsRef<OsStr>,
442 {
443 let mut command = Command::new("git");
444 crate::platform::process::remove_git_local_env(&mut command);
445 command.env("GIT_AUTHOR_NAME", "Mars Test");
446 command.env("GIT_AUTHOR_EMAIL", "mars@example.com");
447 command.env("GIT_COMMITTER_NAME", "Mars Test");
448 command.env("GIT_COMMITTER_EMAIL", "mars@example.com");
449 let output = command.current_dir(cwd).args(args).output().unwrap();
450 if !output.status.success() {
451 panic!(
452 "git command failed: {}\nstdout:\n{}\nstderr:\n{}",
453 output.status,
454 String::from_utf8_lossy(&output.stdout),
455 String::from_utf8_lossy(&output.stderr)
456 );
457 }
458 String::from_utf8_lossy(&output.stdout).trim().to_string()
459 }
460
461 fn init_repo_with_mars_toml(mars_toml: &str) -> (TempDir, super::super::MarsContext) {
462 let repo = TempDir::new().unwrap();
463 run_git_test(repo.path(), ["init", "."]);
464 run_git_test(repo.path(), ["config", "user.name", "Mars Test"]);
465 run_git_test(repo.path(), ["config", "user.email", "mars@example.com"]);
466
467 std::fs::create_dir_all(repo.path().join(".agents")).unwrap();
468 std::fs::create_dir_all(repo.path().join("agents")).unwrap();
469 std::fs::write(
470 repo.path().join("agents/test-agent.md"),
471 "---\nname: test-agent\ndescription: test\n---\n# Test",
472 )
473 .unwrap();
474 std::fs::write(repo.path().join("mars.toml"), mars_toml).unwrap();
475 run_git_test(repo.path(), ["add", "."]);
476 run_git_test(repo.path(), ["commit", "-m", "init"]);
477
478 let ctx = super::super::MarsContext::for_test(
479 repo.path().to_path_buf(),
480 repo.path().join(".agents"),
481 );
482 (repo, ctx)
483 }
484
485 #[test]
486 fn parse_release_version_accepts_plain_semver() {
487 let parsed = parse_release_version("1.2.3", "field").unwrap();
488 assert_eq!(parsed.to_string(), "1.2.3");
489 }
490
491 #[test]
492 fn parse_release_version_rejects_prerelease() {
493 let err = parse_release_version("1.2.3-alpha.1", "field").unwrap_err();
494 assert!(err.to_string().contains("plain X.Y.Z"));
495 }
496
497 #[test]
498 fn resolve_next_version_bump_kinds() {
499 let current = Version::parse("1.2.3").unwrap();
500
501 assert_eq!(
502 resolve_next_version("patch", ¤t).unwrap().to_string(),
503 "1.2.4"
504 );
505 assert_eq!(
506 resolve_next_version("minor", ¤t).unwrap().to_string(),
507 "1.3.0"
508 );
509 assert_eq!(
510 resolve_next_version("major", ¤t).unwrap().to_string(),
511 "2.0.0"
512 );
513 }
514
515 #[test]
516 fn resolve_next_version_explicit() {
517 let current = Version::parse("1.2.3").unwrap();
518 assert_eq!(
519 resolve_next_version("4.5.6", ¤t).unwrap().to_string(),
520 "4.5.6"
521 );
522 }
523
524 #[test]
525 fn run_patch_updates_version_commits_and_tags() {
526 let (repo, ctx) = init_repo_with_mars_toml(
527 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
528 );
529
530 let args = VersionArgs {
531 bump: "patch".to_string(),
532 push: false,
533 force: false,
534 };
535
536 let exit = run(&args, &ctx, true).unwrap();
537 assert_eq!(exit, 0);
538
539 let config = crate::config::load(repo.path()).unwrap();
540 assert_eq!(config.package.unwrap().version, "0.1.1");
541
542 let subject = run_git_test(repo.path(), ["log", "-1", "--pretty=%s"]);
543 assert_eq!(subject, "v0.1.1");
544
545 let tag = run_git_test(repo.path(), ["tag", "--list", "v0.1.1"]);
546 assert_eq!(tag, "v0.1.1");
547 }
548
549 #[test]
550 fn run_promotes_unreleased_in_changelog() {
551 let (repo, ctx) = init_repo_with_mars_toml(
552 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
553 );
554 std::fs::write(
555 repo.path().join("CHANGELOG.md"),
556 "# Changelog\n\n## [Unreleased]\n\n### Added\n- New feature X\n\n### Fixed\n- Bug Y\n",
557 )
558 .unwrap();
559 run_git_test(repo.path(), ["add", "CHANGELOG.md"]);
560 run_git_test(repo.path(), ["commit", "-m", "add changelog"]);
561
562 let args = VersionArgs {
563 bump: "patch".to_string(),
564 push: false,
565 force: false,
566 };
567
568 let exit = run(&args, &ctx, true).unwrap();
569 assert_eq!(exit, 0);
570
571 let changelog = std::fs::read_to_string(repo.path().join("CHANGELOG.md")).unwrap();
572 let today = today_iso_date();
573 assert!(changelog.contains("## [Unreleased]\n\n## [0.1.1] - "));
574 assert!(changelog.contains(&format!(
575 "## [0.1.1] - {today}\n\n### Added\n- New feature X"
576 )));
577 assert!(changelog.contains("### Fixed\n- Bug Y"));
578
579 let committed_files =
580 run_git_test(repo.path(), ["show", "--name-only", "--pretty=", "HEAD"]);
581 assert!(committed_files.lines().any(|line| line == "CHANGELOG.md"));
582 }
583
584 #[test]
585 fn run_warns_on_empty_unreleased() {
586 let (repo, ctx) = init_repo_with_mars_toml(
587 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
588 );
589 std::fs::write(
590 repo.path().join("CHANGELOG.md"),
591 "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Fixed\n",
592 )
593 .unwrap();
594 run_git_test(repo.path(), ["add", "CHANGELOG.md"]);
595 run_git_test(repo.path(), ["commit", "-m", "add empty changelog"]);
596
597 let args = VersionArgs {
598 bump: "patch".to_string(),
599 push: false,
600 force: false,
601 };
602
603 let exit = run(&args, &ctx, true).unwrap();
604 assert_eq!(exit, 0);
605
606 let changelog = std::fs::read_to_string(repo.path().join("CHANGELOG.md")).unwrap();
607 assert!(changelog.contains("## [Unreleased]\n\n## [0.1.1] - "));
608 assert!(changelog.contains("## [0.1.1] - "));
609 assert!(
610 promote_unreleased_changelog(
611 "# Changelog\n\n## [Unreleased]\n\n### Added\n\n",
612 "0.1.1",
613 "2026-04-30"
614 )
615 .unwrap()
616 .unreleased_was_empty
617 );
618 }
619
620 #[test]
621 fn run_succeeds_without_changelog() {
622 let (repo, ctx) = init_repo_with_mars_toml(
623 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
624 );
625
626 let args = VersionArgs {
627 bump: "patch".to_string(),
628 push: false,
629 force: false,
630 };
631
632 let exit = run(&args, &ctx, true).unwrap();
633 assert_eq!(exit, 0);
634
635 let config = crate::config::load(repo.path()).unwrap();
636 assert_eq!(config.package.unwrap().version, "0.1.1");
637 assert!(!repo.path().join("CHANGELOG.md").exists());
638 }
639
640 #[test]
641 fn run_changelog_preserves_existing_versions() {
642 let (repo, ctx) = init_repo_with_mars_toml(
643 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
644 );
645 let prior_section = "## [0.1.0] - 2026-04-01\n\n### Added\n- Initial release\n";
646 std::fs::write(
647 repo.path().join("CHANGELOG.md"),
648 format!("# Changelog\n\n## [Unreleased]\n\n### Fixed\n- Bug Y\n\n{prior_section}"),
649 )
650 .unwrap();
651 run_git_test(repo.path(), ["add", "CHANGELOG.md"]);
652 run_git_test(repo.path(), ["commit", "-m", "add changelog"]);
653
654 let args = VersionArgs {
655 bump: "patch".to_string(),
656 push: false,
657 force: false,
658 };
659
660 let exit = run(&args, &ctx, true).unwrap();
661 assert_eq!(exit, 0);
662
663 let changelog = std::fs::read_to_string(repo.path().join("CHANGELOG.md")).unwrap();
664 assert!(changelog.contains("## [0.1.1] - "));
665 assert!(changelog.contains("### Fixed\n- Bug Y"));
666 assert!(changelog.ends_with(prior_section));
667 }
668
669 #[test]
670 fn run_requires_clean_working_tree() {
671 let (repo, ctx) = init_repo_with_mars_toml(
672 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
673 );
674 std::fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
675
676 let args = VersionArgs {
677 bump: "patch".to_string(),
678 push: false,
679 force: false,
680 };
681
682 let err = run(&args, &ctx, true).unwrap_err();
683 assert!(err.to_string().contains("working tree must be clean"));
684
685 let config = crate::config::load(repo.path()).unwrap();
686 assert_eq!(config.package.unwrap().version, "0.1.0");
687 }
688
689 #[test]
690 fn run_requires_package_section() {
691 let (_repo, ctx) =
692 init_repo_with_mars_toml("[dependencies]\nbase = { path = \"../base\" }\n");
693
694 let args = VersionArgs {
695 bump: "patch".to_string(),
696 push: false,
697 force: false,
698 };
699
700 let err = run(&args, &ctx, true).unwrap_err();
701 assert!(err.to_string().contains("must contain [package]"));
702 }
703
704 #[test]
705 fn run_rejects_existing_tag() {
706 let (repo, ctx) = init_repo_with_mars_toml(
707 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
708 );
709 run_git_test(repo.path(), ["tag", "-a", "v0.1.1", "-m", "v0.1.1"]);
710
711 let args = VersionArgs {
712 bump: "patch".to_string(),
713 push: false,
714 force: false,
715 };
716
717 let err = run(&args, &ctx, true).unwrap_err();
718 assert!(err.to_string().contains("tag `v0.1.1` already exists"));
719 }
720
721 #[test]
722 fn run_with_push_pushes_branch_and_tag_to_origin() {
723 let (repo, ctx) = init_repo_with_mars_toml(
724 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
725 );
726
727 let remote = TempDir::new().unwrap();
728 run_git_test(remote.path(), ["init", "--bare", "."]);
729 run_git_test(
730 repo.path(),
731 ["remote", "add", "origin", remote.path().to_str().unwrap()],
732 );
733
734 let args = VersionArgs {
735 bump: "patch".to_string(),
736 push: true,
737 force: false,
738 };
739
740 let exit = run(&args, &ctx, true).unwrap();
741 assert_eq!(exit, 0);
742
743 let branch = run_git_test(repo.path(), ["rev-parse", "--abbrev-ref", "HEAD"]);
744 let remote_branch = run_git_test(repo.path(), ["ls-remote", "--heads", "origin", &branch]);
745 assert!(remote_branch.contains(&format!("refs/heads/{branch}")));
746
747 let remote_tag = run_git_test(repo.path(), ["ls-remote", "--tags", "origin", "v0.1.1"]);
748 assert!(remote_tag.contains("refs/tags/v0.1.1"));
749 }
750
751 #[test]
754 fn run_aborts_when_package_check_fails() {
755 let (repo, ctx) = init_repo_with_mars_toml(
757 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"/nonexistent-dep-xyz-p5\" }\n",
758 );
759
760 let args = VersionArgs {
761 bump: "patch".to_string(),
762 push: false,
763 force: false,
764 };
765
766 let err = run(&args, &ctx, true).unwrap_err();
767 assert!(
768 err.to_string().contains("package check failed"),
769 "expected package check failure: {err}"
770 );
771
772 let config = crate::config::load(repo.path()).unwrap();
773 assert_eq!(
774 config.package.unwrap().version,
775 "0.1.0",
776 "version must not be bumped after check failure"
777 );
778 }
779
780 #[test]
781 fn run_aborts_when_agent_model_policy_is_malformed() {
782 let (repo, ctx) = init_repo_with_mars_toml(
783 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
784 );
785 std::fs::write(
786 repo.path().join("agents/test-agent.md"),
787 "---\nname: test-agent\ndescription: test\nmodel-policies:\n - match:\n alias: gpt55\n model: gpt-5.5\n---\n# Test",
788 )
789 .unwrap();
790 run_git_test(repo.path(), ["add", "agents/test-agent.md"]);
791 run_git_test(repo.path(), ["commit", "-m", "malformed agent policy"]);
792
793 let args = VersionArgs {
794 bump: "patch".to_string(),
795 push: false,
796 force: false,
797 };
798
799 let err = run(&args, &ctx, true).unwrap_err();
800 let message = err.to_string();
801 assert!(
802 message.contains("package check failed") && message.contains("model-policies[1].match"),
803 "expected model-policies package check failure: {message}"
804 );
805
806 let config = crate::config::load(repo.path()).unwrap();
807 assert_eq!(
808 config.package.unwrap().version,
809 "0.1.0",
810 "version must not be bumped after agent profile check failure"
811 );
812 }
813
814 #[test]
817 fn run_force_bypasses_package_check_errors() {
818 let (repo, ctx) = init_repo_with_mars_toml(
820 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"/nonexistent-dep-xyz-p6\" }\n",
821 );
822
823 let args = VersionArgs {
824 bump: "patch".to_string(),
825 push: false,
826 force: true,
827 };
828
829 let exit = run(&args, &ctx, true).unwrap();
830 assert_eq!(exit, 0);
831
832 let config = crate::config::load(repo.path()).unwrap();
833 assert_eq!(
834 config.package.unwrap().version,
835 "0.1.1",
836 "version must be bumped with --force"
837 );
838 }
839}