1use std::fs;
2use std::path::Path;
3
4use crate::cast::cast_version;
5use crate::finders::find_project_root;
6use crate::git::{maybe_run_pre_commit, run_git_actions};
7use crate::schema::{Config, FileKind, OnInvalidVersion};
8use crate::tui::{select_changes, ProposedChange};
9use crate::version::validate_version;
10
11const DEFAULT_CONTEXT_LINES: usize = 3;
12
13fn pretty_path(path: &Path) -> String {
15 let rel_path = if let Some(root) = find_project_root() {
16 path.strip_prefix(&root).unwrap_or(path)
17 } else {
18 path
19 };
20
21 let parent = rel_path.parent().map(|p| p.to_string_lossy()).unwrap_or_default();
22 let filename = rel_path.file_name().map(|f| f.to_string_lossy()).unwrap_or_default();
23
24 if parent.is_empty() {
26 format!("\x1b[1;35m{}\x1b[0m", filename)
27 } else {
28 format!("\x1b[1;34m{}/\x1b[35m{}\x1b[0m", parent, filename)
29 }
30}
31
32pub fn bump_version(config: &Config, target: &str, force: bool) -> Result<(), String> {
33 let current_version = config
34 .current_version
35 .as_ref()
36 .ok_or("No current_version found in config")?;
37
38 let new_version = if is_version_string(target) {
39 target.to_string()
40 } else {
41 compute_new_version(current_version, target)?
42 };
43 let context_lines = config.context_lines.unwrap_or(DEFAULT_CONTEXT_LINES);
44 let project_root = find_project_root().ok_or("Could not find project root")?;
45
46 let default_kind = config.default_kind;
47
48 let mut proposed_changes: Vec<ProposedChange> = Vec::new();
50
51 for file_config in &config.files {
52 let file_path = project_root.join(&file_config.src);
53 if !file_path.exists() {
54 return Err(format!("File not found: {}", pretty_path(&file_path)));
55 }
56
57 let kind = file_config.kind.unwrap_or(default_kind);
58
59 let old_file_version = get_file_version(current_version, kind, config.on_invalid_version, &file_config.src)?;
61 let new_file_version = get_file_version(&new_version, kind, config.on_invalid_version, &file_config.src)?;
62
63 let file_changes = collect_file_changes(&file_path, &old_file_version, &new_file_version, context_lines)?;
64 proposed_changes.extend(file_changes);
65 }
66
67 if proposed_changes.is_empty() {
68 println!("No changes to apply.");
69 return Ok(());
70 }
71
72 let confirmed = select_changes(&mut proposed_changes)
74 .map_err(|e| format!("TUI error: {e}"))?;
75
76 if !confirmed {
77 println!("Cancelled.");
78 return Ok(());
79 }
80
81 let selected: Vec<_> = proposed_changes.iter().filter(|c| c.selected).collect();
83 if selected.is_empty() {
84 println!("No changes selected.");
85 return Ok(());
86 }
87
88 println!("Applying {} change(s)...", selected.len());
89 for change in &selected {
90 apply_change(change)?;
91 }
92
93 config.git.validate()?;
95
96 maybe_run_pre_commit(config.git.run_pre_commit)?;
98
99 let changed_files: Vec<&std::path::Path> = selected
101 .iter()
102 .map(|c| c.path.as_path())
103 .collect::<std::collections::BTreeSet<_>>()
104 .into_iter()
105 .collect();
106
107 run_git_actions(&config.git, current_version, &new_version, force, &changed_files)?;
109
110 Ok(())
111}
112
113fn is_version_string(s: &str) -> bool {
114 !matches!(s, "major" | "minor" | "patch" | "alpha" | "beta" | "rc" | "post" | "dev" | "release")
115}
116
117fn get_file_version(
118 version: &str,
119 kind: FileKind,
120 on_invalid: OnInvalidVersion,
121 src: &Path,
122) -> Result<String, String> {
123 if validate_version(version, kind).is_ok() {
125 return Ok(version.to_string());
126 }
127
128 match on_invalid {
130 OnInvalidVersion::Error => {
131 let err = validate_version(version, kind).unwrap_err();
132 Err(format!(
133 "Invalid version '{}' for file '{}' (kind: {:?}): {}",
134 version,
135 src.display(),
136 kind,
137 err
138 ))
139 }
140 OnInvalidVersion::Cast => {
141 let casted = cast_version(version, kind).map_err(|e| {
142 format!(
143 "Cannot cast version '{}' for file '{}' (kind: {:?}): {}",
144 version,
145 src.display(),
146 kind,
147 e
148 )
149 })?;
150
151 validate_version(&casted, kind).map_err(|e| {
153 format!(
154 "Casted version '{}' is still invalid for file '{}' (kind: {:?}): {}",
155 casted,
156 src.display(),
157 kind,
158 e
159 )
160 })?;
161
162 Ok(casted)
163 }
164 }
165}
166
167fn compute_new_version(current: &str, component: &str) -> Result<String, String> {
168 let parsed = parse_version(current)?;
169
170 match component {
171 "major" => Ok(format!("{}.0.0", parsed.major + 1)),
172 "minor" => Ok(format!("{}.{}.0", parsed.major, parsed.minor + 1)),
173 "patch" => {
174 if parsed.prerelease.is_some() || parsed.post.is_some() || parsed.dev.is_some() {
176 Ok(format!("{}.{}.{}", parsed.major, parsed.minor, parsed.patch))
177 } else {
178 Ok(format!("{}.{}.{}", parsed.major, parsed.minor, parsed.patch + 1))
179 }
180 }
181 "release" => {
182 Ok(format!("{}.{}.{}", parsed.major, parsed.minor, parsed.patch))
184 }
185 "alpha" => {
186 let num = match &parsed.prerelease {
187 Some((kind, n)) if kind == "alpha" => n + 1,
188 _ => 1,
189 };
190 Ok(format!("{}.{}.{}a{}", parsed.major, parsed.minor, parsed.patch, num))
191 }
192 "beta" => {
193 let num = match &parsed.prerelease {
194 Some((kind, n)) if kind == "beta" => n + 1,
195 _ => 1,
196 };
197 Ok(format!("{}.{}.{}b{}", parsed.major, parsed.minor, parsed.patch, num))
198 }
199 "rc" => {
200 let num = match &parsed.prerelease {
201 Some((kind, n)) if kind == "rc" => n + 1,
202 _ => 1,
203 };
204 Ok(format!("{}.{}.{}rc{}", parsed.major, parsed.minor, parsed.patch, num))
205 }
206 "post" => {
207 let num = parsed.post.map(|n| n + 1).unwrap_or(1);
208 let base = format!("{}.{}.{}", parsed.major, parsed.minor, parsed.patch);
209 let pre = match &parsed.prerelease {
210 Some((kind, n)) => format!("{}{}", prerelease_prefix(kind), n),
211 None => String::new(),
212 };
213 Ok(format!("{}{}.post{}", base, pre, num))
214 }
215 "dev" => {
216 let num = parsed.dev.map(|n| n + 1).unwrap_or(1);
217 let base = format!("{}.{}.{}", parsed.major, parsed.minor, parsed.patch);
218 let pre = match &parsed.prerelease {
219 Some((kind, n)) => format!("{}{}", prerelease_prefix(kind), n),
220 None => String::new(),
221 };
222 let post = parsed.post.map(|n| format!(".post{}", n)).unwrap_or_default();
223 Ok(format!("{}{}{}.dev{}", base, pre, post, num))
224 }
225 _ => Err(format!(
226 "Invalid component: {component}. Use major, minor, patch, release, alpha, beta, rc, post, or dev"
227 )),
228 }
229}
230
231fn prerelease_prefix(kind: &str) -> &'static str {
232 match kind {
233 "alpha" => "a",
234 "beta" => "b",
235 "rc" => "rc",
236 _ => "",
237 }
238}
239
240#[derive(Debug, Default)]
241struct ParsedVersion {
242 major: u32,
243 minor: u32,
244 patch: u32,
245 prerelease: Option<(String, u32)>, post: Option<u32>,
247 dev: Option<u32>,
248}
249
250fn parse_version(version: &str) -> Result<ParsedVersion, String> {
251 let version = version.to_lowercase();
252
253 let version = if let Some(pos) = version.find('!') {
255 &version[pos + 1..]
256 } else {
257 version.as_str()
258 };
259
260 let version = if let Some(pos) = version.find('+') {
262 &version[..pos]
263 } else {
264 version
265 };
266
267 let mut parsed = ParsedVersion::default();
268
269 let (version, dev) = if let Some(pos) = version.find(".dev") {
271 let dev_part = &version[pos + 4..];
272 let dev_num: u32 = dev_part.parse().unwrap_or(0);
273 (&version[..pos], Some(dev_num))
274 } else if let Some(pos) = version.find("dev") {
275 let dev_part = &version[pos + 3..];
276 let dev_num: u32 = dev_part.parse().unwrap_or(0);
277 (&version[..pos], Some(dev_num))
278 } else {
279 (version, None)
280 };
281 parsed.dev = dev;
282
283 let (version, post) = if let Some(pos) = version.find(".post") {
285 let post_part = &version[pos + 5..];
286 let post_num: u32 = post_part.parse().unwrap_or(0);
287 (&version[..pos], Some(post_num))
288 } else if let Some(pos) = version.find("post") {
289 let post_part = &version[pos + 4..];
290 let post_num: u32 = post_part.parse().unwrap_or(0);
291 (&version[..pos], Some(post_num))
292 } else {
293 (version, None)
294 };
295 parsed.post = post;
296
297 let prerelease_markers = [
299 ("alpha", "alpha"),
300 ("beta", "beta"),
301 ("preview", "rc"),
302 ("rc", "rc"),
303 ("a", "alpha"),
304 ("b", "beta"),
305 ("c", "rc"),
306 ];
307
308 let mut release = version;
309 for (marker, kind) in prerelease_markers {
310 if let Some(pos) = version.find(marker) {
311 let before = &version[..pos];
312 if before.is_empty() || (!before.ends_with('.') && !before.chars().last().unwrap().is_ascii_digit()) {
314 continue;
315 }
316 let after = &version[pos + marker.len()..];
317 let num: u32 = after
318 .chars()
319 .take_while(|c| c.is_ascii_digit())
320 .collect::<String>()
321 .parse()
322 .unwrap_or(0);
323 parsed.prerelease = Some((kind.to_string(), num));
324 release = before;
325 break;
326 }
327 }
328
329 let release = if let Some(pos) = release.find('-') {
331 let pre_part = &release[pos + 1..];
332 if pre_part.starts_with("alpha") {
333 let num_part = pre_part.strip_prefix("alpha").unwrap_or("").trim_start_matches('.');
334 let num: u32 = num_part.parse().unwrap_or(0);
335 parsed.prerelease = Some(("alpha".to_string(), num));
336 } else if pre_part.starts_with("beta") {
337 let num_part = pre_part.strip_prefix("beta").unwrap_or("").trim_start_matches('.');
338 let num: u32 = num_part.parse().unwrap_or(0);
339 parsed.prerelease = Some(("beta".to_string(), num));
340 } else if pre_part.starts_with("rc") {
341 let num_part = pre_part.strip_prefix("rc").unwrap_or("").trim_start_matches('.');
342 let num: u32 = num_part.parse().unwrap_or(0);
343 parsed.prerelease = Some(("rc".to_string(), num));
344 }
345 &release[..pos]
346 } else {
347 release
348 };
349
350 let parts: Vec<&str> = release.split('.').collect();
352 if parts.is_empty() {
353 return Err(format!("Invalid version format: {version}"));
354 }
355
356 parsed.major = parts[0]
357 .parse()
358 .map_err(|_| format!("Invalid major version: {}", parts[0]))?;
359 parsed.minor = parts.get(1).unwrap_or(&"0")
360 .parse()
361 .map_err(|_| format!("Invalid minor version: {}", parts.get(1).unwrap_or(&"0")))?;
362 parsed.patch = parts.get(2).unwrap_or(&"0")
363 .parse()
364 .map_err(|_| format!("Invalid patch version: {}", parts.get(2).unwrap_or(&"0")))?;
365
366 Ok(parsed)
367}
368
369fn collect_file_changes(
370 path: &Path,
371 old_version: &str,
372 new_version: &str,
373 context_lines: usize,
374) -> Result<Vec<ProposedChange>, String> {
375 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
376 let lines: Vec<&str> = content.lines().collect();
377
378 let occurrences: Vec<usize> = lines
379 .iter()
380 .enumerate()
381 .filter(|(_, line)| line.contains(old_version))
382 .map(|(i, _)| i)
383 .collect();
384
385 if occurrences.is_empty() {
386 return Err(format!(
387 "Version '{}' not found in {}",
388 old_version,
389 pretty_path(path)
390 ));
391 }
392
393 let mut changes = Vec::new();
394
395 for &line_idx in &occurrences {
396 let start = line_idx.saturating_sub(context_lines);
397 let end = (line_idx + context_lines + 1).min(lines.len());
398
399 let context_before: Vec<String> = lines[start..line_idx]
400 .iter()
401 .map(|s| s.to_string())
402 .collect();
403 let context_after: Vec<String> = lines[(line_idx + 1)..end]
404 .iter()
405 .map(|s| s.to_string())
406 .collect();
407
408 let old_line = lines[line_idx].to_string();
409 let new_line = old_line.replace(old_version, new_version);
410
411 changes.push(ProposedChange {
412 path: path.to_path_buf(),
413 line_idx,
414 old_line,
415 new_line,
416 context_before,
417 context_after,
418 selected: true,
419 });
420 }
421
422 Ok(changes)
423}
424
425fn apply_change(change: &ProposedChange) -> Result<(), String> {
426 let original = fs::read_to_string(&change.path)
427 .map_err(|e| format!("Failed to read {}: {e}", pretty_path(&change.path)))?;
428 let lines: Vec<&str> = original.lines().collect();
429
430 let new_content: Vec<String> = lines
431 .iter()
432 .enumerate()
433 .map(|(i, line)| {
434 if i == change.line_idx {
435 change.new_line.clone()
436 } else {
437 (*line).to_string()
438 }
439 })
440 .collect();
441
442 let new_content = new_content.join("\n");
443
444 let new_content = if original.ends_with('\n') {
446 new_content + "\n"
447 } else {
448 new_content
449 };
450
451 fs::write(&change.path, &new_content)
452 .map_err(|e| format!("Failed to write {}: {e}", pretty_path(&change.path)))?;
453
454 println!(" Updated {}:{}", pretty_path(&change.path), change.line_idx + 1);
455 Ok(())
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn test_bump_major() {
464 assert_eq!(compute_new_version("1.2.3", "major").unwrap(), "2.0.0");
465 assert_eq!(compute_new_version("0.1.0", "major").unwrap(), "1.0.0");
466 assert_eq!(compute_new_version("1.2.3a1", "major").unwrap(), "2.0.0");
467 }
468
469 #[test]
470 fn test_bump_minor() {
471 assert_eq!(compute_new_version("1.2.3", "minor").unwrap(), "1.3.0");
472 assert_eq!(compute_new_version("0.1.0", "minor").unwrap(), "0.2.0");
473 assert_eq!(compute_new_version("1.2.3a1", "minor").unwrap(), "1.3.0");
474 }
475
476 #[test]
477 fn test_bump_patch() {
478 assert_eq!(compute_new_version("1.2.3", "patch").unwrap(), "1.2.4");
479 assert_eq!(compute_new_version("0.1.0", "patch").unwrap(), "0.1.1");
480 assert_eq!(compute_new_version("1.2.3a1", "patch").unwrap(), "1.2.3");
482 assert_eq!(compute_new_version("1.2.3.post1", "patch").unwrap(), "1.2.3");
483 }
484
485 #[test]
486 fn test_bump_release() {
487 assert_eq!(compute_new_version("1.2.3a1", "release").unwrap(), "1.2.3");
488 assert_eq!(compute_new_version("1.2.3b2", "release").unwrap(), "1.2.3");
489 assert_eq!(compute_new_version("1.2.3rc1", "release").unwrap(), "1.2.3");
490 assert_eq!(compute_new_version("1.2.3.post1", "release").unwrap(), "1.2.3");
491 assert_eq!(compute_new_version("1.2.3.dev1", "release").unwrap(), "1.2.3");
492 assert_eq!(compute_new_version("1.2.3", "release").unwrap(), "1.2.3");
493 }
494
495 #[test]
496 fn test_bump_alpha() {
497 assert_eq!(compute_new_version("1.2.3", "alpha").unwrap(), "1.2.3a1");
498 assert_eq!(compute_new_version("1.2.3a1", "alpha").unwrap(), "1.2.3a2");
499 assert_eq!(compute_new_version("1.2.3a5", "alpha").unwrap(), "1.2.3a6");
500 assert_eq!(compute_new_version("1.2.3b1", "alpha").unwrap(), "1.2.3a1");
502 }
503
504 #[test]
505 fn test_bump_beta() {
506 assert_eq!(compute_new_version("1.2.3", "beta").unwrap(), "1.2.3b1");
507 assert_eq!(compute_new_version("1.2.3b1", "beta").unwrap(), "1.2.3b2");
508 assert_eq!(compute_new_version("1.2.3a1", "beta").unwrap(), "1.2.3b1");
509 }
510
511 #[test]
512 fn test_bump_rc() {
513 assert_eq!(compute_new_version("1.2.3", "rc").unwrap(), "1.2.3rc1");
514 assert_eq!(compute_new_version("1.2.3rc1", "rc").unwrap(), "1.2.3rc2");
515 assert_eq!(compute_new_version("1.2.3b1", "rc").unwrap(), "1.2.3rc1");
516 }
517
518 #[test]
519 fn test_bump_post() {
520 assert_eq!(compute_new_version("1.2.3", "post").unwrap(), "1.2.3.post1");
521 assert_eq!(compute_new_version("1.2.3.post1", "post").unwrap(), "1.2.3.post2");
522 assert_eq!(compute_new_version("1.2.3a1", "post").unwrap(), "1.2.3a1.post1");
523 }
524
525 #[test]
526 fn test_bump_dev() {
527 assert_eq!(compute_new_version("1.2.3", "dev").unwrap(), "1.2.3.dev1");
528 assert_eq!(compute_new_version("1.2.3.dev1", "dev").unwrap(), "1.2.3.dev2");
529 assert_eq!(compute_new_version("1.2.3a1", "dev").unwrap(), "1.2.3a1.dev1");
530 assert_eq!(compute_new_version("1.2.3.post1", "dev").unwrap(), "1.2.3.post1.dev1");
531 }
532
533 #[test]
534 fn test_bump_js_style_prerelease() {
535 assert_eq!(compute_new_version("1.2.3-alpha.1", "alpha").unwrap(), "1.2.3a2");
537 assert_eq!(compute_new_version("1.2.3-beta.1", "beta").unwrap(), "1.2.3b2");
538 assert_eq!(compute_new_version("1.2.3-rc.1", "rc").unwrap(), "1.2.3rc2");
539 }
540
541 #[test]
542 fn test_parse_version() {
543 let p = parse_version("1.2.3").unwrap();
544 assert_eq!((p.major, p.minor, p.patch), (1, 2, 3));
545 assert!(p.prerelease.is_none());
546
547 let p = parse_version("1.2.3a1").unwrap();
548 assert_eq!((p.major, p.minor, p.patch), (1, 2, 3));
549 assert_eq!(p.prerelease, Some(("alpha".to_string(), 1)));
550
551 let p = parse_version("1.2.3.post1").unwrap();
552 assert_eq!(p.post, Some(1));
553
554 let p = parse_version("1.2.3.dev1").unwrap();
555 assert_eq!(p.dev, Some(1));
556 }
557}