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 meridian_managed: false,
181 };
182 let request = SyncRequest {
183 resolution: ResolutionMode::Normal,
184 mutation: None,
185 options: SyncOptions {
186 dry_run: true,
187 ..SyncOptions::default()
188 },
189 recovery: Default::default(),
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(§ions[unreleased_index + 1..next_section_index]);
369
370 let mut promoted = String::new();
371 for line in §ions[..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 §ions[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 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(repo.path().to_path_buf());
480 (repo, ctx)
481 }
482
483 #[test]
484 fn parse_release_version_accepts_plain_semver() {
485 let parsed = parse_release_version("1.2.3", "field").unwrap();
486 assert_eq!(parsed.to_string(), "1.2.3");
487 }
488
489 #[test]
490 fn parse_release_version_rejects_prerelease() {
491 let err = parse_release_version("1.2.3-alpha.1", "field").unwrap_err();
492 assert!(err.to_string().contains("plain X.Y.Z"));
493 }
494
495 #[test]
496 fn resolve_next_version_bump_kinds() {
497 let current = Version::parse("1.2.3").unwrap();
498
499 assert_eq!(
500 resolve_next_version("patch", ¤t).unwrap().to_string(),
501 "1.2.4"
502 );
503 assert_eq!(
504 resolve_next_version("minor", ¤t).unwrap().to_string(),
505 "1.3.0"
506 );
507 assert_eq!(
508 resolve_next_version("major", ¤t).unwrap().to_string(),
509 "2.0.0"
510 );
511 }
512
513 #[test]
514 fn resolve_next_version_explicit() {
515 let current = Version::parse("1.2.3").unwrap();
516 assert_eq!(
517 resolve_next_version("4.5.6", ¤t).unwrap().to_string(),
518 "4.5.6"
519 );
520 }
521
522 #[test]
523 fn run_patch_updates_version_commits_and_tags() {
524 let (repo, ctx) = init_repo_with_mars_toml(
525 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
526 );
527
528 let args = VersionArgs {
529 bump: "patch".to_string(),
530 push: false,
531 force: false,
532 };
533
534 let exit = run(&args, &ctx, true).unwrap();
535 assert_eq!(exit, 0);
536
537 let config = crate::config::load(repo.path()).unwrap();
538 assert_eq!(config.package.unwrap().version, "0.1.1");
539
540 let subject = run_git_test(repo.path(), ["log", "-1", "--pretty=%s"]);
541 assert_eq!(subject, "v0.1.1");
542
543 let tag = run_git_test(repo.path(), ["tag", "--list", "v0.1.1"]);
544 assert_eq!(tag, "v0.1.1");
545 }
546
547 #[test]
548 fn run_promotes_unreleased_in_changelog() {
549 let (repo, ctx) = init_repo_with_mars_toml(
550 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
551 );
552 std::fs::write(
553 repo.path().join("CHANGELOG.md"),
554 "# Changelog\n\n## [Unreleased]\n\n### Added\n- New feature X\n\n### Fixed\n- Bug Y\n",
555 )
556 .unwrap();
557 run_git_test(repo.path(), ["add", "CHANGELOG.md"]);
558 run_git_test(repo.path(), ["commit", "-m", "add changelog"]);
559
560 let args = VersionArgs {
561 bump: "patch".to_string(),
562 push: false,
563 force: false,
564 };
565
566 let exit = run(&args, &ctx, true).unwrap();
567 assert_eq!(exit, 0);
568
569 let changelog = std::fs::read_to_string(repo.path().join("CHANGELOG.md")).unwrap();
570 let today = today_iso_date();
571 assert!(changelog.contains("## [Unreleased]\n\n## [0.1.1] - "));
572 assert!(changelog.contains(&format!(
573 "## [0.1.1] - {today}\n\n### Added\n- New feature X"
574 )));
575 assert!(changelog.contains("### Fixed\n- Bug Y"));
576
577 let committed_files =
578 run_git_test(repo.path(), ["show", "--name-only", "--pretty=", "HEAD"]);
579 assert!(committed_files.lines().any(|line| line == "CHANGELOG.md"));
580 }
581
582 #[test]
583 fn run_warns_on_empty_unreleased() {
584 let (repo, ctx) = init_repo_with_mars_toml(
585 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
586 );
587 std::fs::write(
588 repo.path().join("CHANGELOG.md"),
589 "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Fixed\n",
590 )
591 .unwrap();
592 run_git_test(repo.path(), ["add", "CHANGELOG.md"]);
593 run_git_test(repo.path(), ["commit", "-m", "add empty 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("## [Unreleased]\n\n## [0.1.1] - "));
606 assert!(changelog.contains("## [0.1.1] - "));
607 assert!(
608 promote_unreleased_changelog(
609 "# Changelog\n\n## [Unreleased]\n\n### Added\n\n",
610 "0.1.1",
611 "2026-04-30"
612 )
613 .unwrap()
614 .unreleased_was_empty
615 );
616 }
617
618 #[test]
619 fn run_succeeds_without_changelog() {
620 let (repo, ctx) = init_repo_with_mars_toml(
621 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
622 );
623
624 let args = VersionArgs {
625 bump: "patch".to_string(),
626 push: false,
627 force: false,
628 };
629
630 let exit = run(&args, &ctx, true).unwrap();
631 assert_eq!(exit, 0);
632
633 let config = crate::config::load(repo.path()).unwrap();
634 assert_eq!(config.package.unwrap().version, "0.1.1");
635 assert!(!repo.path().join("CHANGELOG.md").exists());
636 }
637
638 #[test]
639 fn run_changelog_preserves_existing_versions() {
640 let (repo, ctx) = init_repo_with_mars_toml(
641 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
642 );
643 let prior_section = "## [0.1.0] - 2026-04-01\n\n### Added\n- Initial release\n";
644 std::fs::write(
645 repo.path().join("CHANGELOG.md"),
646 format!("# Changelog\n\n## [Unreleased]\n\n### Fixed\n- Bug Y\n\n{prior_section}"),
647 )
648 .unwrap();
649 run_git_test(repo.path(), ["add", "CHANGELOG.md"]);
650 run_git_test(repo.path(), ["commit", "-m", "add changelog"]);
651
652 let args = VersionArgs {
653 bump: "patch".to_string(),
654 push: false,
655 force: false,
656 };
657
658 let exit = run(&args, &ctx, true).unwrap();
659 assert_eq!(exit, 0);
660
661 let changelog = std::fs::read_to_string(repo.path().join("CHANGELOG.md")).unwrap();
662 assert!(changelog.contains("## [0.1.1] - "));
663 assert!(changelog.contains("### Fixed\n- Bug Y"));
664 assert!(changelog.ends_with(prior_section));
665 }
666
667 #[test]
668 fn run_requires_clean_working_tree() {
669 let (repo, ctx) = init_repo_with_mars_toml(
670 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
671 );
672 std::fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
673
674 let args = VersionArgs {
675 bump: "patch".to_string(),
676 push: false,
677 force: false,
678 };
679
680 let err = run(&args, &ctx, true).unwrap_err();
681 assert!(err.to_string().contains("working tree must be clean"));
682
683 let config = crate::config::load(repo.path()).unwrap();
684 assert_eq!(config.package.unwrap().version, "0.1.0");
685 }
686
687 #[test]
688 fn run_requires_package_section() {
689 let (_repo, ctx) =
690 init_repo_with_mars_toml("[dependencies]\nbase = { path = \"../base\" }\n");
691
692 let args = VersionArgs {
693 bump: "patch".to_string(),
694 push: false,
695 force: false,
696 };
697
698 let err = run(&args, &ctx, true).unwrap_err();
699 assert!(err.to_string().contains("must contain [package]"));
700 }
701
702 #[test]
703 fn run_rejects_existing_tag() {
704 let (repo, ctx) = init_repo_with_mars_toml(
705 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
706 );
707 run_git_test(repo.path(), ["tag", "-a", "v0.1.1", "-m", "v0.1.1"]);
708
709 let args = VersionArgs {
710 bump: "patch".to_string(),
711 push: false,
712 force: false,
713 };
714
715 let err = run(&args, &ctx, true).unwrap_err();
716 assert!(err.to_string().contains("tag `v0.1.1` already exists"));
717 }
718
719 #[test]
720 fn run_with_push_pushes_branch_and_tag_to_origin() {
721 let (repo, ctx) = init_repo_with_mars_toml(
722 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
723 );
724
725 let remote = TempDir::new().unwrap();
726 run_git_test(remote.path(), ["init", "--bare", "."]);
727 run_git_test(
728 repo.path(),
729 ["remote", "add", "origin", remote.path().to_str().unwrap()],
730 );
731
732 let args = VersionArgs {
733 bump: "patch".to_string(),
734 push: true,
735 force: false,
736 };
737
738 let exit = run(&args, &ctx, true).unwrap();
739 assert_eq!(exit, 0);
740
741 let branch = run_git_test(repo.path(), ["rev-parse", "--abbrev-ref", "HEAD"]);
742 let remote_branch = run_git_test(repo.path(), ["ls-remote", "--heads", "origin", &branch]);
743 assert!(remote_branch.contains(&format!("refs/heads/{branch}")));
744
745 let remote_tag = run_git_test(repo.path(), ["ls-remote", "--tags", "origin", "v0.1.1"]);
746 assert!(remote_tag.contains("refs/tags/v0.1.1"));
747 }
748
749 #[test]
752 fn run_aborts_when_package_check_fails() {
753 let (repo, ctx) = init_repo_with_mars_toml(
755 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"/nonexistent-dep-xyz-p5\" }\n",
756 );
757
758 let args = VersionArgs {
759 bump: "patch".to_string(),
760 push: false,
761 force: false,
762 };
763
764 let err = run(&args, &ctx, true).unwrap_err();
765 assert!(
766 err.to_string().contains("package check failed"),
767 "expected package check failure: {err}"
768 );
769
770 let config = crate::config::load(repo.path()).unwrap();
771 assert_eq!(
772 config.package.unwrap().version,
773 "0.1.0",
774 "version must not be bumped after check failure"
775 );
776 }
777
778 #[test]
779 fn run_aborts_when_agent_model_policy_is_malformed() {
780 let (repo, ctx) = init_repo_with_mars_toml(
781 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
782 );
783 std::fs::write(
784 repo.path().join("agents/test-agent.md"),
785 "---\nname: test-agent\ndescription: test\nmodel-policies:\n - match:\n alias: gpt55\n model: gpt-5.5\n---\n# Test",
786 )
787 .unwrap();
788 run_git_test(repo.path(), ["add", "agents/test-agent.md"]);
789 run_git_test(repo.path(), ["commit", "-m", "malformed agent policy"]);
790
791 let args = VersionArgs {
792 bump: "patch".to_string(),
793 push: false,
794 force: false,
795 };
796
797 let err = run(&args, &ctx, true).unwrap_err();
798 let message = err.to_string();
799 assert!(
800 message.contains("package check failed") && message.contains("model-policies[1].match"),
801 "expected model-policies package check failure: {message}"
802 );
803
804 let config = crate::config::load(repo.path()).unwrap();
805 assert_eq!(
806 config.package.unwrap().version,
807 "0.1.0",
808 "version must not be bumped after agent profile check failure"
809 );
810 }
811
812 #[test]
815 fn run_force_bypasses_package_check_errors() {
816 let (repo, ctx) = init_repo_with_mars_toml(
818 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\ndep = { path = \"/nonexistent-dep-xyz-p6\" }\n",
819 );
820
821 let args = VersionArgs {
822 bump: "patch".to_string(),
823 push: false,
824 force: true,
825 };
826
827 let exit = run(&args, &ctx, true).unwrap();
828 assert_eq!(exit, 0);
829
830 let config = crate::config::load(repo.path()).unwrap();
831 assert_eq!(
832 config.package.unwrap().version,
833 "0.1.1",
834 "version must be bumped with --force"
835 );
836 }
837}