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