Skip to main content

_bver/
bump.rs

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
13/// Format a path relative to project root, bold, with folder and filename in different colors
14fn 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    // Bold + blue for folder, bold + magenta for filename
25    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    // Collect all proposed changes
49    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        // Get the versions to use for this file (possibly casted)
60        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    // Show TUI to select changes
73    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    // Apply selected changes
82    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    // Validate git config before running any git operations
94    config.git.validate()?;
95
96    // Run pre-commit hooks if configured
97    maybe_run_pre_commit(config.git.run_pre_commit)?;
98
99    // Collect unique changed file paths
100    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 if configured
108    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    // First, check if the version is already valid for this kind
124    if validate_version(version, kind).is_ok() {
125        return Ok(version.to_string());
126    }
127
128    // Version is invalid for this kind
129    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 the casted version
152            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 we have a prerelease, just drop it (1.0.0a1 -> 1.0.0)
175            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            // Drop all prerelease/post/dev suffixes
183            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)>, // (kind, number) e.g., ("alpha", 1)
246    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    // Remove epoch if present
254    let version = if let Some(pos) = version.find('!') {
255        &version[pos + 1..]
256    } else {
257        version.as_str()
258    };
259
260    // Remove local version if present
261    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    // Find dev suffix
270    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    // Find post suffix
284    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    // Find prerelease suffix (alpha, beta, rc, a, b, c)
298    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            // Make sure it's at a valid position (after a digit or dot)
313            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    // Also handle JS-style prerelease (1.0.0-alpha.1)
330    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    // Parse major.minor.patch
351    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    // Preserve trailing newline if original had one
445    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        // With prerelease, patch just drops the prerelease
481        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        // Switching from beta/rc to alpha resets to 1
501        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        // JS style: 1.0.0-alpha.1
536        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}