1use std::path::Path;
4
5use semver::{BuildMetadata, Prerelease, Version};
6
7use crate::error::{ConfigError, MarsError};
8
9use super::{check, output};
10
11#[derive(Debug, clap::Args)]
13pub struct VersionArgs {
14 pub bump: String,
16 #[arg(long)]
18 pub push: bool,
19}
20
21pub 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, ¤t)?;
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 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", ¤t).unwrap().to_string(),
308 "1.2.4"
309 );
310 assert_eq!(
311 resolve_next_version("minor", ¤t).unwrap().to_string(),
312 "1.3.0"
313 );
314 assert_eq!(
315 resolve_next_version("major", ¤t).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", ¤t).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}