1use 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#[derive(Debug, clap::Args)]
15pub struct VersionArgs {
16 pub bump: String,
18 #[arg(long)]
20 pub push: bool,
21}
22
23pub 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, ¤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
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", ¤t).unwrap().to_string(),
320 "1.2.4"
321 );
322 assert_eq!(
323 resolve_next_version("minor", ¤t).unwrap().to_string(),
324 "1.3.0"
325 );
326 assert_eq!(
327 resolve_next_version("major", ¤t).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", ¤t).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}