Skip to main content

qtcloud_devops_cli/release/
util.rs

1use std::path::Path;
2use std::process::Command;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
5pub enum Registry {
6    PyPI,
7    PubDev,
8    Crates,
9}
10
11pub fn validate_version(version: &str) -> bool {
12    let re = regex::Regex::new(
13        r"^(v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?|[a-zA-Z0-9_.-]+/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$",
14    ).unwrap();
15    re.is_match(version)
16}
17
18pub fn normalize_version(version: &str) -> String {
19    let s = version.strip_prefix('v').unwrap_or(version);
20    s.split("/v").last().unwrap_or(s).to_string()
21}
22
23pub fn precheck_version_changelog(version: &str, changelog_path: &Path) -> Vec<String> {
24    let mut errors = Vec::new();
25    if !validate_version(version) {
26        errors.push(format!("版本号格式错误: {}", version));
27    }
28    if changelog_path.exists() {
29        let content = std::fs::read_to_string(changelog_path).unwrap_or_default();
30        let ver = normalize_version(version);
31        let marker = format!("## [{}]", ver);
32        if !content.contains(&marker) {
33            errors.push(format!("CHANGELOG.md 未找到 {} 版本记录", ver));
34        }
35    } else {
36        errors.push(format!("CHANGELOG.md 不存在: {}", changelog_path.display()));
37    }
38    errors
39}
40
41pub fn extract_notes(version: &str, changelog_path: &Path) -> Option<String> {
42    let content = std::fs::read_to_string(changelog_path).ok()?;
43    let ver = normalize_version(version);
44    let start_marker = format!("## [{}]", ver);
45    let mut capture = false;
46    let mut notes: Vec<&str> = Vec::new();
47    for line in content.lines() {
48        if line.trim().starts_with(&start_marker) { capture = true; continue; }
49        if capture { if line.starts_with("## [") { break; } notes.push(line); }
50    }
51    let text = notes.join("\n").trim().to_string();
52    if text.is_empty() { None } else { Some(text) }
53}
54
55pub fn confirm_release(version: &str, yes: bool) -> bool {
56    if yes { return true; }
57    use std::io::Write;
58    println!("\n发布版本: {}", version);
59    print!("确认发布? (y/N): ");
60    std::io::stdout().flush().ok();
61    let mut input = String::new();
62    std::io::stdin().read_line(&mut input).ok();
63    input.trim().to_lowercase() == "y" || input.trim().to_lowercase() == "yes"
64}
65
66fn git_args(args: &[&str], repo_path: &Path) -> Command {
67    let mut cmd = Command::new("git");
68    cmd.arg("-C"); cmd.arg(repo_path); cmd.args(args);
69    cmd
70}
71
72pub fn create_tag(version: &str, repo_path: &Path) -> bool {
73    match git_args(&["tag", version], repo_path).output() {
74        Ok(out) if out.status.success() => true,
75        Ok(out) => {
76            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
77            if msg.contains("already exists") || msg.contains("已存在") { return true; }
78            eprintln!("创建标签失败: {}", msg); false
79        }
80        Err(e) => { eprintln!("创建标签失败: {}", e); false }
81    }
82}
83
84pub fn push_tag(version: &str, repo_path: &Path) -> bool {
85    match git_args(&["push", "origin", version], repo_path).output() {
86        Ok(out) if out.status.success() => true,
87        Ok(out) => {
88            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
89            if msg.contains("does not appear") || msg.contains("repository '' does not exist") { return true; }
90            eprintln!("推送标签失败: {}", msg); false
91        }
92        Err(e) => { eprintln!("推送标签失败: {}", e); false }
93    }
94}
95
96pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
97    let result = git_args(&["remote", "get-url", "origin"], repo_path).output().ok()?;
98    if !result.status.success() { return None; }
99    parse_github_repo(&String::from_utf8_lossy(&result.stdout).trim())
100}
101
102pub fn parse_github_repo(url: &str) -> Option<String> {
103    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
104    let caps = re.captures(url)?;
105    Some(caps.get(1)?.as_str().to_string())
106}
107
108pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
109    let out = Command::new("gh")
110        .args(["release", "create", version, "--title", version, "--notes", notes, "--repo", repo])
111        .output();
112    match out {
113        Ok(out) if out.status.success() => true,
114        Ok(out) => {
115            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
116            if msg.contains("already exists") || msg.contains("已存在") { return true; }
117            eprintln!("创建 Release 失败: {}", msg); false
118        }
119        Err(e) => { eprintln!("创建 Release 失败: {}", e); false }
120    }
121}
122
123pub fn rollback_tag(version: &str, repo_path: &Path) {
124    git_args(&["tag", "-d", version], repo_path).output().ok();
125    git_args(&["push", "origin", "--delete", version], repo_path).output().ok();
126    println!("↻ 标签 {} 已回滚", version);
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::path::Path;
133
134    fn git_init(path: &Path) {
135        std::process::Command::new("git").args(["init", "-b", "main"]).current_dir(path).output().unwrap();
136        std::process::Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
137        std::process::Command::new("git").args(["config", "user.name", "Test"]).current_dir(path).output().unwrap();
138    }
139
140    fn git_commit(path: &Path, msg: &str) {
141        std::fs::write(path.join("file"), msg).unwrap();
142        std::process::Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
143        std::process::Command::new("git").args(["commit", "-m", msg]).current_dir(path).output().unwrap();
144    }
145
146    #[test] fn test_validate_version_v_prefix() { assert!(validate_version("v1.2.3")); }
147    #[test] fn test_validate_version_with_suffix() { assert!(validate_version("v1.2.3-alpha.1")); assert!(validate_version("v1.2.3-rc1")); }
148    #[test] fn test_validate_version_pkg() { assert!(validate_version("pkg/v1.2.3")); assert!(validate_version("cli/v0.1.0")); }
149    #[test] fn test_validate_version_invalid() { assert!(!validate_version("1.2.3")); assert!(!validate_version("v1.2")); assert!(!validate_version("abc")); }
150    #[test] fn test_parse_github_repo_https() { assert_eq!(parse_github_repo("https://github.com/owner/repo.git"), Some("owner/repo".into())); }
151    #[test] fn test_parse_github_repo_ssh() { assert_eq!(parse_github_repo("git@github.com:owner/repo.git"), Some("owner/repo".into())); }
152    #[test] fn test_parse_github_repo_not_github() { assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None); }
153    #[test] fn test_extract_notes_found() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap(); assert!(extract_notes("v1.0.0", &d.path().join("C.md")).is_some()); }
154    #[test] fn test_extract_notes_not_found() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap(); assert!(extract_notes("v2.0.0", &d.path().join("C.md")).is_none()); }
155    #[test] fn test_confirm_release_yes_flag() { assert!(confirm_release("v1.0.0", true)); }
156    #[test] fn test_precheck_changelog_no_errors() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap(); assert!(precheck_version_changelog("v1.0.0", &d.path().join("C.md")).is_empty()); }
157    #[test] fn test_precheck_changelog_missing_entry() { let d = tempfile::tempdir().unwrap(); std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap(); assert!(precheck_version_changelog("v2.0.0", &d.path().join("C.md")).iter().any(|e| e.contains("未找到"))); }
158    #[test] fn test_precheck_changelog_file_not_found() { let d = tempfile::tempdir().unwrap(); assert!(precheck_version_changelog("v1.0.0", &d.path().join("N.md")).iter().any(|e| e.contains("不存在"))); }
159    #[test] fn test_precheck_changelog_version_invalid() { let d = tempfile::tempdir().unwrap(); assert!(precheck_version_changelog("bad", &d.path().join("C.md")).iter().any(|e| e.contains("格式错误"))); }
160    #[test] fn test_registry_debug() { assert_eq!(format!("{:?}", Registry::PyPI), "PyPI"); }
161    #[test] fn test_registry_clone_eq() { assert_eq!(Registry::PyPI, Registry::PyPI); }
162    #[test] fn test_normalize_version_v_prefix() { assert_eq!(normalize_version("v1.2.3"), "1.2.3"); }
163    #[test] fn test_normalize_version_pkg() { assert_eq!(normalize_version("pkg/v1.2.3"), "1.2.3"); }
164    #[test] fn test_normalize_version_no_prefix() { assert_eq!(normalize_version("1.2.3"), "1.2.3"); }
165    #[test] fn test_normalize_version_scoped() { assert_eq!(normalize_version("cli/v0.3.2"), "0.3.2"); }
166    #[test] fn test_validate_version_formal() { assert!(validate_version("v1.0.0")); }
167    #[test] fn test_validate_version_prerelease() { assert!(validate_version("v1.0.0-rc.1")); }
168    #[test] fn test_validate_version_no_v() { assert!(!validate_version("1.0.0")); }
169    #[test] fn test_validate_version_empty() { assert!(!validate_version("")); }
170    #[test] fn test_validate_version_scope_only() { assert!(!validate_version("cli/")); }
171    #[test] fn test_get_remote_repo_no_git_repo() { assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None); }
172    #[test] fn test_create_release_no_gh() { assert!(!create_release("v0.0.0-test", "", "no/repo")); }
173    #[test] fn test_create_tag_in_non_git_dir() { assert!(!create_tag("v0.0.0-test", tempfile::tempdir().unwrap().path())); }
174    #[test] fn test_create_tag_idempotent() { let d = tempfile::tempdir().unwrap(); git_init(d.path()); git_commit(d.path(), "init"); assert!(create_tag("v0.0.0-test", d.path())); assert!(create_tag("v0.0.0-test", d.path())); }
175    #[test] fn test_push_tag_in_non_git_dir() { assert!(!push_tag("v0.0.0-test", tempfile::tempdir().unwrap().path())); }
176    #[test] fn test_push_tag_fails_with_non_existent_remote() { let d = tempfile::tempdir().unwrap(); git_init(d.path()); git_commit(d.path(), "init"); assert!(create_tag("v0.0.0-test-remote", d.path())); std::process::Command::new("git").args(["remote", "add", "origin", "https://nonexistent.invalid/repo.git"]).current_dir(d.path()).output().unwrap(); assert!(!push_tag("v0.0.0-test-remote", d.path())); }
177    #[test] fn test_get_remote_repo_in_git_without_remote() { let d = tempfile::tempdir().unwrap(); std::process::Command::new("git").args(["init", "-b", "main"]).current_dir(d.path()).output().unwrap(); assert_eq!(get_remote_repo(d.path()), None); }
178    #[test] fn test_rollback_tag_removes_tag() { let d = tempfile::tempdir().unwrap(); std::process::Command::new("git").args(["init", "-b", "main"]).current_dir(d.path()).output().unwrap(); std::fs::write(d.path().join("f"), "").unwrap(); std::process::Command::new("git").args(["add", "."]).current_dir(d.path()).output().unwrap(); std::process::Command::new("git").args(["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "x"]).current_dir(d.path()).output().unwrap(); assert!(create_tag("v0.0.0-test-rollback", d.path())); rollback_tag("v0.0.0-test-rollback", d.path()); let o = std::process::Command::new("git").args(["tag", "-l"]).current_dir(d.path()).output().unwrap(); assert!(!String::from_utf8_lossy(&o.stdout).contains("v0.0.0-test-rollback")); }
179}
180