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