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;
4
5use semver::{BuildMetadata, Prerelease, Version};
6
7use crate::error::{ConfigError, MarsError};
8
9use super::{check, output};
10
11/// Arguments for `mars version`.
12#[derive(Debug, clap::Args)]
13pub struct VersionArgs {
14    /// Version bump: patch, minor, major, or explicit X.Y.Z
15    pub bump: String,
16    /// Push branch and tag to origin after versioning
17    #[arg(long)]
18    pub push: bool,
19}
20
21/// Run `mars version`.
22pub fn run(args: &VersionArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
23    require_clean_working_tree(&ctx.project_root)?;
24    require_package_check(&ctx.project_root)?;
25
26    let mut config = crate::config::load(&ctx.project_root)?;
27    let package = config
28        .package
29        .as_mut()
30        .ok_or_else(|| ConfigError::Invalid {
31            message: "mars.toml must contain [package] with name and version".to_string(),
32        })?;
33
34    if package.name.trim().is_empty() {
35        return Err(ConfigError::Invalid {
36            message: "[package].name must not be empty".to_string(),
37        }
38        .into());
39    }
40
41    let current = parse_release_version(&package.version, "[package].version")?;
42    let next = resolve_next_version(&args.bump, &current)?;
43
44    if next == current {
45        return Err(ConfigError::Invalid {
46            message: format!(
47                "new version `{}` matches current version `{}`",
48                next, package.version
49            ),
50        }
51        .into());
52    }
53
54    let next_version = next.to_string();
55    let tag = format!("v{next_version}");
56
57    ensure_tag_not_exists(&ctx.project_root, &tag)?;
58
59    package.version = next_version.clone();
60    crate::config::save(&ctx.project_root, &config)?;
61
62    crate::platform::process::run_git(
63        &["add", "mars.toml"],
64        &ctx.project_root,
65        "git add mars.toml",
66    )?;
67    crate::platform::process::run_git(
68        &["commit", "-m", &tag],
69        &ctx.project_root,
70        &format!("git commit -m {tag}"),
71    )?;
72    crate::platform::process::run_git(
73        &["tag", "-a", &tag, "-m", &tag],
74        &ctx.project_root,
75        &format!("git tag -a {tag} -m {tag}"),
76    )?;
77
78    if args.push {
79        let branch = current_branch(&ctx.project_root)?;
80        crate::platform::process::run_git(
81            &["push", "origin", &branch],
82            &ctx.project_root,
83            &format!("git push origin {branch}"),
84        )?;
85        crate::platform::process::run_git(
86            &["push", "origin", &tag],
87            &ctx.project_root,
88            &format!("git push origin {tag}"),
89        )?;
90    }
91
92    if json {
93        output::print_json(&serde_json::json!({
94            "ok": true,
95            "version": next_version,
96            "tag": tag,
97            "pushed": args.push,
98        }));
99    } else {
100        println!("{tag}");
101    }
102
103    Ok(0)
104}
105
106fn require_clean_working_tree(project_root: &Path) -> Result<(), MarsError> {
107    let output = crate::platform::process::run_git(
108        &["status", "--porcelain"],
109        project_root,
110        "git status --porcelain",
111    )?;
112
113    if !output.is_empty() {
114        return Err(ConfigError::Invalid {
115            message: "working tree must be clean before running `mars version`".to_string(),
116        }
117        .into());
118    }
119
120    Ok(())
121}
122
123fn require_package_check(project_root: &Path) -> Result<(), MarsError> {
124    // Skip check if this isn't a source package (no agents/, skills/, or SKILL.md)
125    let has_agents = project_root.join("agents").is_dir();
126    let has_skills = project_root.join("skills").is_dir();
127    let has_root_skill = project_root.join("SKILL.md").is_file();
128    if !has_agents && !has_skills && !has_root_skill {
129        return Ok(());
130    }
131
132    let report = check::check_dir(project_root)?;
133    if !report.errors.is_empty() {
134        let mut message = "package check failed:".to_string();
135        for error in &report.errors {
136            message.push_str(&format!("\n  - {error}"));
137        }
138        return Err(ConfigError::Invalid { message }.into());
139    }
140    Ok(())
141}
142
143fn parse_release_version(value: &str, field_name: &str) -> Result<Version, MarsError> {
144    let version = Version::parse(value).map_err(|_| ConfigError::Invalid {
145        message: format!("{field_name} must be valid semver (X.Y.Z), got `{value}`"),
146    })?;
147
148    if !version.pre.is_empty() || !version.build.is_empty() {
149        return Err(ConfigError::Invalid {
150            message: format!("{field_name} must be plain X.Y.Z (no prerelease/build): `{value}`"),
151        }
152        .into());
153    }
154
155    Ok(version)
156}
157
158fn resolve_next_version(bump: &str, current: &Version) -> Result<Version, MarsError> {
159    match bump {
160        "patch" => Ok(Version {
161            major: current.major,
162            minor: current.minor,
163            patch: current
164                .patch
165                .checked_add(1)
166                .ok_or_else(|| ConfigError::Invalid {
167                    message: "patch version overflow".to_string(),
168                })?,
169            pre: Prerelease::EMPTY,
170            build: BuildMetadata::EMPTY,
171        }),
172        "minor" => Ok(Version {
173            major: current.major,
174            minor: current
175                .minor
176                .checked_add(1)
177                .ok_or_else(|| ConfigError::Invalid {
178                    message: "minor version overflow".to_string(),
179                })?,
180            patch: 0,
181            pre: Prerelease::EMPTY,
182            build: BuildMetadata::EMPTY,
183        }),
184        "major" => Ok(Version {
185            major: current
186                .major
187                .checked_add(1)
188                .ok_or_else(|| ConfigError::Invalid {
189                    message: "major version overflow".to_string(),
190                })?,
191            minor: 0,
192            patch: 0,
193            pre: Prerelease::EMPTY,
194            build: BuildMetadata::EMPTY,
195        }),
196        explicit => parse_release_version(explicit, "requested version"),
197    }
198}
199
200fn ensure_tag_not_exists(project_root: &Path, tag: &str) -> Result<(), MarsError> {
201    let output = crate::platform::process::run_git(
202        &["tag", "--list", tag],
203        project_root,
204        &format!("git tag --list {tag}"),
205    )?;
206
207    let exists = output.lines().any(|line| line.trim() == tag);
208
209    if exists {
210        return Err(ConfigError::Invalid {
211            message: format!("tag `{tag}` already exists"),
212        }
213        .into());
214    }
215
216    Ok(())
217}
218
219fn current_branch(project_root: &Path) -> Result<String, MarsError> {
220    let branch = crate::platform::process::run_git(
221        &["rev-parse", "--abbrev-ref", "HEAD"],
222        project_root,
223        "git rev-parse --abbrev-ref HEAD",
224    )?;
225    if branch.is_empty() || branch == "HEAD" {
226        return Err(ConfigError::Invalid {
227            message: "cannot push from detached HEAD".to_string(),
228        }
229        .into());
230    }
231
232    Ok(branch)
233}
234
235#[cfg(test)]
236mod tests {
237    use std::ffi::OsStr;
238    use std::path::Path;
239    use std::process::Command;
240
241    use tempfile::TempDir;
242
243    use super::*;
244
245    fn run_git_test<I, S>(cwd: &Path, args: I) -> String
246    where
247        I: IntoIterator<Item = S>,
248        S: AsRef<OsStr>,
249    {
250        let output = Command::new("git")
251            .current_dir(cwd)
252            .args(args)
253            .output()
254            .unwrap();
255        if !output.status.success() {
256            panic!(
257                "git command failed: {}\nstdout:\n{}\nstderr:\n{}",
258                output.status,
259                String::from_utf8_lossy(&output.stdout),
260                String::from_utf8_lossy(&output.stderr)
261            );
262        }
263        String::from_utf8_lossy(&output.stdout).trim().to_string()
264    }
265
266    fn init_repo_with_mars_toml(mars_toml: &str) -> (TempDir, super::super::MarsContext) {
267        let repo = TempDir::new().unwrap();
268        run_git_test(repo.path(), ["init", "."]);
269        run_git_test(repo.path(), ["config", "user.name", "Mars Test"]);
270        run_git_test(repo.path(), ["config", "user.email", "mars@example.com"]);
271
272        std::fs::create_dir_all(repo.path().join(".agents")).unwrap();
273        std::fs::create_dir_all(repo.path().join("agents")).unwrap();
274        std::fs::write(
275            repo.path().join("agents/test-agent.md"),
276            "---\nname: test-agent\ndescription: test\n---\n# Test",
277        )
278        .unwrap();
279        std::fs::write(repo.path().join("mars.toml"), mars_toml).unwrap();
280        run_git_test(repo.path(), ["add", "."]);
281        run_git_test(repo.path(), ["commit", "-m", "init"]);
282
283        let ctx = super::super::MarsContext::for_test(
284            repo.path().to_path_buf(),
285            repo.path().join(".agents"),
286        );
287        (repo, ctx)
288    }
289
290    #[test]
291    fn parse_release_version_accepts_plain_semver() {
292        let parsed = parse_release_version("1.2.3", "field").unwrap();
293        assert_eq!(parsed.to_string(), "1.2.3");
294    }
295
296    #[test]
297    fn parse_release_version_rejects_prerelease() {
298        let err = parse_release_version("1.2.3-alpha.1", "field").unwrap_err();
299        assert!(err.to_string().contains("plain X.Y.Z"));
300    }
301
302    #[test]
303    fn resolve_next_version_bump_kinds() {
304        let current = Version::parse("1.2.3").unwrap();
305
306        assert_eq!(
307            resolve_next_version("patch", &current).unwrap().to_string(),
308            "1.2.4"
309        );
310        assert_eq!(
311            resolve_next_version("minor", &current).unwrap().to_string(),
312            "1.3.0"
313        );
314        assert_eq!(
315            resolve_next_version("major", &current).unwrap().to_string(),
316            "2.0.0"
317        );
318    }
319
320    #[test]
321    fn resolve_next_version_explicit() {
322        let current = Version::parse("1.2.3").unwrap();
323        assert_eq!(
324            resolve_next_version("4.5.6", &current).unwrap().to_string(),
325            "4.5.6"
326        );
327    }
328
329    #[test]
330    fn run_patch_updates_version_commits_and_tags() {
331        let (repo, ctx) = init_repo_with_mars_toml(
332            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
333        );
334
335        let args = VersionArgs {
336            bump: "patch".to_string(),
337            push: false,
338        };
339
340        let exit = run(&args, &ctx, true).unwrap();
341        assert_eq!(exit, 0);
342
343        let config = crate::config::load(repo.path()).unwrap();
344        assert_eq!(config.package.unwrap().version, "0.1.1");
345
346        let subject = run_git_test(repo.path(), ["log", "-1", "--pretty=%s"]);
347        assert_eq!(subject, "v0.1.1");
348
349        let tag = run_git_test(repo.path(), ["tag", "--list", "v0.1.1"]);
350        assert_eq!(tag, "v0.1.1");
351    }
352
353    #[test]
354    fn run_requires_clean_working_tree() {
355        let (repo, ctx) = init_repo_with_mars_toml(
356            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
357        );
358        std::fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap();
359
360        let args = VersionArgs {
361            bump: "patch".to_string(),
362            push: false,
363        };
364
365        let err = run(&args, &ctx, true).unwrap_err();
366        assert!(err.to_string().contains("working tree must be clean"));
367
368        let config = crate::config::load(repo.path()).unwrap();
369        assert_eq!(config.package.unwrap().version, "0.1.0");
370    }
371
372    #[test]
373    fn run_requires_package_section() {
374        let (_repo, ctx) =
375            init_repo_with_mars_toml("[dependencies]\nbase = { path = \"../base\" }\n");
376
377        let args = VersionArgs {
378            bump: "patch".to_string(),
379            push: false,
380        };
381
382        let err = run(&args, &ctx, true).unwrap_err();
383        assert!(err.to_string().contains("must contain [package]"));
384    }
385
386    #[test]
387    fn run_rejects_existing_tag() {
388        let (repo, ctx) = init_repo_with_mars_toml(
389            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
390        );
391        run_git_test(repo.path(), ["tag", "-a", "v0.1.1", "-m", "v0.1.1"]);
392
393        let args = VersionArgs {
394            bump: "patch".to_string(),
395            push: false,
396        };
397
398        let err = run(&args, &ctx, true).unwrap_err();
399        assert!(err.to_string().contains("tag `v0.1.1` already exists"));
400    }
401
402    #[test]
403    fn run_with_push_pushes_branch_and_tag_to_origin() {
404        let (repo, ctx) = init_repo_with_mars_toml(
405            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n\n[dependencies]\n",
406        );
407
408        let remote = TempDir::new().unwrap();
409        run_git_test(remote.path(), ["init", "--bare", "."]);
410        run_git_test(
411            repo.path(),
412            ["remote", "add", "origin", remote.path().to_str().unwrap()],
413        );
414
415        let args = VersionArgs {
416            bump: "patch".to_string(),
417            push: true,
418        };
419
420        let exit = run(&args, &ctx, true).unwrap();
421        assert_eq!(exit, 0);
422
423        let branch = run_git_test(repo.path(), ["rev-parse", "--abbrev-ref", "HEAD"]);
424        let remote_branch = run_git_test(repo.path(), ["ls-remote", "--heads", "origin", &branch]);
425        assert!(remote_branch.contains(&format!("refs/heads/{branch}")));
426
427        let remote_tag = run_git_test(repo.path(), ["ls-remote", "--tags", "origin", "v0.1.1"]);
428        assert!(remote_tag.contains("refs/tags/v0.1.1"));
429    }
430}