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}
21
22/// Run `mars version`.
23pub 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, &current)?;
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    // Skip check if this isn't a source package (no agents/, skills/, or SKILL.md)
134    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(&sections[unreleased_index + 1..next_section_index]);
294
295    let mut promoted = String::new();
296    for line in &sections[..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 &sections[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    // Howard Hinnant's civil-from-days algorithm. Converts days since
339    // 1970-01-01 to a proleptic Gregorian date without platform-specific APIs.
340    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", &current).unwrap().to_string(),
427            "1.2.4"
428        );
429        assert_eq!(
430            resolve_next_version("minor", &current).unwrap().to_string(),
431            "1.3.0"
432        );
433        assert_eq!(
434            resolve_next_version("major", &current).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", &current).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}