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        let v_marker = format!("## [v{}]", ver);
33        if !content.contains(&marker) && !content.contains(&v_marker) {
34            errors.push(format!("CHANGELOG.md 未找到 {} 版本记录", ver));
35        }
36    } else {
37        errors.push(format!("CHANGELOG.md 不存在: {}", changelog_path.display()));
38    }
39    errors
40}
41
42pub fn extract_notes(version: &str, changelog_path: &Path) -> Option<String> {
43    let content = std::fs::read_to_string(changelog_path).ok()?;
44    let ver = normalize_version(version);
45    let start_marker = format!("## [{}]", ver);
46    let start_marker_v = format!("## [v{}]", ver);
47    let mut capture = false;
48    let mut notes: Vec<&str> = Vec::new();
49    for line in content.lines() {
50        if line.trim().starts_with(&start_marker) || line.trim().starts_with(&start_marker_v) {
51            capture = true;
52            continue;
53        }
54        if capture {
55            if line.starts_with("## [") {
56                // 同版本重复头部(LLM 混入)跳过,不同版本停止
57                if line.contains(&ver) || line.contains(&format!("v{}", ver)) {
58                    continue;
59                }
60                break;
61            }
62            notes.push(line);
63        }
64    }
65    let text = notes
66        .iter()
67        .filter(|l| !l.trim().starts_with("## ["))
68        .cloned()
69        .collect::<Vec<_>>()
70        .join("\n")
71        .trim()
72        .to_string();
73    if text.is_empty() {
74        None
75    } else {
76        Some(text)
77    }
78}
79
80pub fn confirm_release(version: &str, yes: bool) -> bool {
81    if yes {
82        return true;
83    }
84    use std::io::Write;
85    println!("\n发布版本: {}", version);
86    print!("确认发布? (y/N): ");
87    std::io::stdout().flush().ok();
88    let mut input = String::new();
89    std::io::stdin().read_line(&mut input).ok();
90    input.trim().to_lowercase() == "y" || input.trim().to_lowercase() == "yes"
91}
92
93fn git_args(args: &[&str], repo_path: &Path) -> Command {
94    let mut cmd = Command::new("git");
95    cmd.arg("-C");
96    cmd.arg(repo_path);
97    cmd.args(args);
98    cmd
99}
100
101pub fn create_tag(version: &str, repo_path: &Path) -> bool {
102    match git_args(&["tag", version], repo_path).output() {
103        Ok(out) if out.status.success() => true,
104        Ok(out) => {
105            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
106            if msg.contains("already exists") || msg.contains("已存在") {
107                return true;
108            }
109            eprintln!("创建标签失败: {}", msg);
110            false
111        }
112        Err(e) => {
113            eprintln!("创建标签失败: {}", e);
114            false
115        }
116    }
117}
118
119pub fn push_tag(version: &str, repo_path: &Path) -> bool {
120    match git_args(&["push", "origin", version], repo_path).output() {
121        Ok(out) if out.status.success() => true,
122        Ok(out) => {
123            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
124            if msg.contains("does not appear") || msg.contains("repository '' does not exist") {
125                return true;
126            }
127            eprintln!("推送标签失败: {}", msg);
128            false
129        }
130        Err(e) => {
131            eprintln!("推送标签失败: {}", e);
132            false
133        }
134    }
135}
136
137pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
138    let result = git_args(&["remote", "get-url", "origin"], repo_path)
139        .output()
140        .ok()?;
141    if !result.status.success() {
142        return None;
143    }
144    parse_github_repo(&String::from_utf8_lossy(&result.stdout).trim())
145}
146
147pub fn parse_github_repo(url: &str) -> Option<String> {
148    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
149    let caps = re.captures(url)?;
150    Some(caps.get(1)?.as_str().to_string())
151}
152
153pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
154    let out = Command::new("gh")
155        .args([
156            "release", "create", version, "--title", version, "--notes", notes, "--repo", repo,
157        ])
158        .output();
159    match out {
160        Ok(out) if out.status.success() => true,
161        Ok(out) => {
162            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
163            if msg.contains("already exists") || msg.contains("已存在") {
164                return true;
165            }
166            eprintln!("创建 Release 失败: {}", msg);
167            false
168        }
169        Err(e) => {
170            eprintln!("创建 Release 失败: {}", e);
171            false
172        }
173    }
174}
175
176pub fn rollback_tag(version: &str, repo_path: &Path) {
177    git_args(&["tag", "-d", version], repo_path).output().ok();
178    git_args(&["push", "origin", "--delete", version], repo_path)
179        .output()
180        .ok();
181    println!("↻ 标签 {} 已回滚", version);
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::path::Path;
188
189    fn git_init(path: &Path) {
190        std::process::Command::new("git")
191            .args(["init", "-b", "main"])
192            .current_dir(path)
193            .output()
194            .unwrap();
195        std::process::Command::new("git")
196            .args(["config", "user.email", "test@test.com"])
197            .current_dir(path)
198            .output()
199            .unwrap();
200        std::process::Command::new("git")
201            .args(["config", "user.name", "Test"])
202            .current_dir(path)
203            .output()
204            .unwrap();
205    }
206
207    fn git_commit(path: &Path, msg: &str) {
208        std::fs::write(path.join("file"), msg).unwrap();
209        std::process::Command::new("git")
210            .args(["add", "."])
211            .current_dir(path)
212            .output()
213            .unwrap();
214        std::process::Command::new("git")
215            .args(["commit", "-m", msg])
216            .current_dir(path)
217            .output()
218            .unwrap();
219    }
220
221    #[test]
222    fn test_validate_version_v_prefix() {
223        assert!(validate_version("v1.2.3"));
224    }
225    #[test]
226    fn test_validate_version_with_suffix() {
227        assert!(validate_version("v1.2.3-alpha.1"));
228        assert!(validate_version("v1.2.3-rc1"));
229    }
230    #[test]
231    fn test_validate_version_pkg() {
232        assert!(validate_version("pkg/v1.2.3"));
233        assert!(validate_version("cli/v0.1.0"));
234    }
235    #[test]
236    fn test_validate_version_invalid() {
237        assert!(!validate_version("1.2.3"));
238        assert!(!validate_version("v1.2"));
239        assert!(!validate_version("abc"));
240    }
241    #[test]
242    fn test_parse_github_repo_https() {
243        assert_eq!(
244            parse_github_repo("https://github.com/owner/repo.git"),
245            Some("owner/repo".into())
246        );
247    }
248    #[test]
249    fn test_parse_github_repo_ssh() {
250        assert_eq!(
251            parse_github_repo("git@github.com:owner/repo.git"),
252            Some("owner/repo".into())
253        );
254    }
255    #[test]
256    fn test_parse_github_repo_not_github() {
257        assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None);
258    }
259    #[test]
260    fn test_extract_notes_found() {
261        let d = tempfile::tempdir().unwrap();
262        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
263        assert!(extract_notes("v1.0.0", &d.path().join("C.md")).is_some());
264    }
265    #[test]
266    fn test_extract_notes_not_found() {
267        let d = tempfile::tempdir().unwrap();
268        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
269        assert!(extract_notes("v2.0.0", &d.path().join("C.md")).is_none());
270    }
271    #[test]
272    fn test_extract_notes_filters_header_lines() {
273        let d = tempfile::tempdir().unwrap();
274        // 模拟 LLM 产物中混入版本头部行
275        std::fs::write(
276            d.path().join("C.md"),
277            "## [1.0.0] - 2026-06-26\n\n\
278             ## [v1.0.0] - 2023-08-31\n\n\
279             ### Added\n- feature\n",
280        )
281        .unwrap();
282        let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap_or_default();
283        assert!(!notes.contains("## ["), "提取内容应过滤 ## [ 行: {}", notes);
284        assert!(notes.contains("### Added"));
285        assert!(notes.contains("- feature"));
286    }
287
288    #[test]
289    fn test_confirm_release_yes_flag() {
290        assert!(confirm_release("v1.0.0", true));
291    }
292    #[test]
293    fn test_precheck_changelog_no_errors() {
294        let d = tempfile::tempdir().unwrap();
295        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
296        assert!(precheck_version_changelog("v1.0.0", &d.path().join("C.md")).is_empty());
297    }
298    #[test]
299    fn test_precheck_changelog_missing_entry() {
300        let d = tempfile::tempdir().unwrap();
301        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
302        assert!(precheck_version_changelog("v2.0.0", &d.path().join("C.md"))
303            .iter()
304            .any(|e| e.contains("未找到")));
305    }
306    #[test]
307    fn test_precheck_changelog_file_not_found() {
308        let d = tempfile::tempdir().unwrap();
309        assert!(precheck_version_changelog("v1.0.0", &d.path().join("N.md"))
310            .iter()
311            .any(|e| e.contains("不存在")));
312    }
313    #[test]
314    fn test_precheck_changelog_version_invalid() {
315        let d = tempfile::tempdir().unwrap();
316        assert!(precheck_version_changelog("bad", &d.path().join("C.md"))
317            .iter()
318            .any(|e| e.contains("格式错误")));
319    }
320    #[test]
321    fn test_registry_debug() {
322        assert_eq!(format!("{:?}", Registry::PyPI), "PyPI");
323    }
324    #[test]
325    fn test_registry_clone_eq() {
326        assert_eq!(Registry::PyPI, Registry::PyPI);
327    }
328    #[test]
329    fn test_normalize_version_v_prefix() {
330        assert_eq!(normalize_version("v1.2.3"), "1.2.3");
331    }
332    #[test]
333    fn test_normalize_version_pkg() {
334        assert_eq!(normalize_version("pkg/v1.2.3"), "1.2.3");
335    }
336    #[test]
337    fn test_normalize_version_no_prefix() {
338        assert_eq!(normalize_version("1.2.3"), "1.2.3");
339    }
340    #[test]
341    fn test_normalize_version_scoped() {
342        assert_eq!(normalize_version("cli/v0.3.2"), "0.3.2");
343    }
344    #[test]
345    fn test_validate_version_formal() {
346        assert!(validate_version("v1.0.0"));
347    }
348    #[test]
349    fn test_validate_version_prerelease() {
350        assert!(validate_version("v1.0.0-rc.1"));
351    }
352    #[test]
353    fn test_validate_version_no_v() {
354        assert!(!validate_version("1.0.0"));
355    }
356    #[test]
357    fn test_validate_version_empty() {
358        assert!(!validate_version(""));
359    }
360    #[test]
361    fn test_validate_version_scope_only() {
362        assert!(!validate_version("cli/"));
363    }
364    #[test]
365    fn test_get_remote_repo_no_git_repo() {
366        assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None);
367    }
368    #[test]
369    fn test_create_release_no_gh() {
370        assert!(!create_release("v0.0.0-test", "", "no/repo"));
371    }
372    #[test]
373    fn test_create_tag_in_non_git_dir() {
374        assert!(!create_tag(
375            "v0.0.0-test",
376            tempfile::tempdir().unwrap().path()
377        ));
378    }
379    #[test]
380    fn test_create_tag_idempotent() {
381        let d = tempfile::tempdir().unwrap();
382        git_init(d.path());
383        git_commit(d.path(), "init");
384        assert!(create_tag("v0.0.0-test", d.path()));
385        assert!(create_tag("v0.0.0-test", d.path()));
386    }
387    #[test]
388    fn test_push_tag_in_non_git_dir() {
389        assert!(!push_tag(
390            "v0.0.0-test",
391            tempfile::tempdir().unwrap().path()
392        ));
393    }
394    #[test]
395    fn test_push_tag_fails_with_non_existent_remote() {
396        let d = tempfile::tempdir().unwrap();
397        git_init(d.path());
398        git_commit(d.path(), "init");
399        assert!(create_tag("v0.0.0-test-remote", d.path()));
400        std::process::Command::new("git")
401            .args([
402                "remote",
403                "add",
404                "origin",
405                "https://nonexistent.invalid/repo.git",
406            ])
407            .current_dir(d.path())
408            .output()
409            .unwrap();
410        assert!(!push_tag("v0.0.0-test-remote", d.path()));
411    }
412    #[test]
413    fn test_get_remote_repo_in_git_without_remote() {
414        let d = tempfile::tempdir().unwrap();
415        std::process::Command::new("git")
416            .args(["init", "-b", "main"])
417            .current_dir(d.path())
418            .output()
419            .unwrap();
420        assert_eq!(get_remote_repo(d.path()), None);
421    }
422    #[test]
423    fn test_rollback_tag_removes_tag() {
424        let d = tempfile::tempdir().unwrap();
425        std::process::Command::new("git")
426            .args(["init", "-b", "main"])
427            .current_dir(d.path())
428            .output()
429            .unwrap();
430        std::fs::write(d.path().join("f"), "").unwrap();
431        std::process::Command::new("git")
432            .args(["add", "."])
433            .current_dir(d.path())
434            .output()
435            .unwrap();
436        std::process::Command::new("git")
437            .args([
438                "-c",
439                "user.name=t",
440                "-c",
441                "user.email=t@t",
442                "commit",
443                "-m",
444                "x",
445            ])
446            .current_dir(d.path())
447            .output()
448            .unwrap();
449        assert!(create_tag("v0.0.0-test-rollback", d.path()));
450        rollback_tag("v0.0.0-test-rollback", d.path());
451        let o = std::process::Command::new("git")
452            .args(["tag", "-l"])
453            .current_dir(d.path())
454            .output()
455            .unwrap();
456        assert!(!String::from_utf8_lossy(&o.stdout).contains("v0.0.0-test-rollback"));
457    }
458}