Skip to main content

qtcloud_devops_cli/commands/
release.rs

1use std::path::Path;
2use std::process::Command;
3
4use crate::model::release::{FileStorage, ReleaseRecord, ReleaseStatus, Storage, TransitionError};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
7pub enum Registry {
8    PyPI,
9    PubDev,
10    Crates,
11}
12
13// ===== utility functions =====
14
15pub fn validate_version(version: &str) -> bool {
16    let re = regex::Regex::new(
17        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.]+)?)$",
18    ).unwrap();
19    re.is_match(version)
20}
21
22fn normalize_version(version: &str) -> String {
23    let s = version.strip_prefix('v').unwrap_or(version);
24    s.split("/v").last().unwrap_or(s).to_string()
25}
26
27pub fn precheck_version_changelog(version: &str, changelog_path: &Path) -> Vec<String> {
28    let mut errors = Vec::new();
29    if !validate_version(version) {
30        errors.push(format!("版本号格式错误: {}", version));
31    }
32    if changelog_path.exists() {
33        let content = std::fs::read_to_string(changelog_path).unwrap_or_default();
34        let ver = normalize_version(version);
35        let marker = format!("## [{}]", ver);
36        if !content.contains(&marker) {
37            errors.push(format!("CHANGELOG.md 未找到 {} 版本记录", ver));
38        }
39    } else {
40        errors.push(format!("CHANGELOG.md 不存在: {}", changelog_path.display()));
41    }
42    errors
43}
44
45pub fn extract_notes(version: &str, changelog_path: &Path) -> Option<String> {
46    let content = std::fs::read_to_string(changelog_path).ok()?;
47    let ver = normalize_version(version);
48    let start_marker = format!("## [{}]", ver);
49    let mut capture = false;
50    let mut notes: Vec<&str> = Vec::new();
51    for line in content.lines() {
52        if line.trim().starts_with(&start_marker) {
53            capture = true;
54            continue;
55        }
56        if capture {
57            if line.starts_with("## [") {
58                break;
59            }
60            notes.push(line);
61        }
62    }
63    let text = notes.join("\n").trim().to_string();
64    if text.is_empty() { None } else { Some(text) }
65}
66
67pub fn confirm_release(version: &str, yes: bool) -> bool {
68    if yes { return true; }
69    use std::io::Write;
70    println!("\n发布版本: {}", version);
71    print!("确认发布? (y/N): ");
72    std::io::stdout().flush().ok();
73    let mut input = String::new();
74    std::io::stdin().read_line(&mut input).ok();
75    let input = input.trim().to_lowercase();
76    input == "y" || input == "yes"
77}
78
79fn git_args(args: &[&str], repo_path: &Path) -> Command {
80    let mut cmd = Command::new("git");
81    cmd.arg("-C");
82    cmd.arg(repo_path);
83    cmd.args(args);
84    cmd
85}
86
87pub fn create_tag(version: &str, repo_path: &Path) -> bool {
88    match git_args(&["tag", version], repo_path).output() {
89        Ok(out) if out.status.success() => true,
90        Ok(out) => {
91            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
92            if msg.contains("already exists") || msg.contains("已存在") {
93                return true; // 幂等:tag 已存在时不报错
94            }
95            eprintln!("创建标签失败: {}", msg);
96            false
97        }
98        Err(e) => { eprintln!("创建标签失败: {}", e); false }
99    }
100}
101
102pub fn push_tag(version: &str, repo_path: &Path) -> bool {
103    match git_args(&["push", "origin", version], repo_path).output() {
104        Ok(out) if out.status.success() => true,
105        Ok(out) => {
106            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
107            if msg.contains("does not appear") || msg.contains("repository '' does not exist") {
108                return true; // 没有 remote 时静默跳过(本地 tag 已创建)
109            }
110            eprintln!("推送标签失败: {}", msg);
111            false
112        }
113        Err(e) => { eprintln!("推送标签失败: {}", e); false }
114    }
115}
116
117pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
118    let result = git_args(&["remote", "get-url", "origin"], repo_path).output().ok()?;
119    if !result.status.success() { return None; }
120    let url = String::from_utf8_lossy(&result.stdout).trim().to_string();
121    parse_github_repo(&url)
122}
123
124pub fn parse_github_repo(url: &str) -> Option<String> {
125    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
126    let caps = re.captures(url)?;
127    Some(caps.get(1)?.as_str().to_string())
128}
129
130pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
131    let out = Command::new("gh")
132        .args(["release", "create", version, "--title", version, "--notes", notes, "--repo", repo])
133        .output();
134    match out {
135        Ok(out) if out.status.success() => true,
136        Ok(out) => {
137            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
138            if msg.contains("already exists") || msg.contains("已存在") {
139                return true; // 幂等:Release 已存在时不报错
140            }
141            eprintln!("创建 Release 失败: {}", msg);
142            false
143        }
144        Err(e) => { eprintln!("创建 Release 失败: {}", e); false }
145    }
146}
147
148pub fn rollback_tag(version: &str, repo_path: &Path) {
149    git_args(&["tag", "-d", version], repo_path).output().ok();
150    git_args(&["push", "origin", "--delete", version], repo_path).output().ok();
151    println!("↻ 标签 {} 已回滚", version);
152}
153
154// ===== stage =====
155
156fn is_prerelease(version: &str) -> bool {
157    let base = version.split('/').last().unwrap_or(version);
158    base.contains('-')
159}
160
161pub fn stage(version: &str, repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
162    if !validate_version(version) {
163        return Err(format!("版本号格式错误: {}", version).into());
164    }
165    if !is_prerelease(version) {
166        return Err(format!("stage 仅用于预发布版本(含 -rc.N、-alpha.N 等后缀),正式版请直接 publish: {}", version).into());
167    }
168    let changelog_path = repo_path.join("CHANGELOG.md");
169    let precheck_errors = precheck_version_changelog(version, &changelog_path);
170    if !precheck_errors.is_empty() {
171        return Err(precheck_errors.join("\n").into());
172    }
173    let mut storage = FileStorage::new(repo_path);
174    if let Some(existing) = storage.load(version) {
175        match existing.status {
176            ReleaseStatus::Published => return Err(format!("版本 {} 已发布,不可重复 stage", version).into()),
177            ReleaseStatus::Staged => {
178                let now = std::time::SystemTime::now()
179                    .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
180                let mut updated = existing.clone();
181                updated.updated_at = now;
182                storage.save(&updated)?;
183                // re-push tag (idempotent)
184                if create_tag(version, repo_path) && push_tag(version, repo_path) {
185                    println!("✓ 标签 {} 已更新并推送", version);
186                }
187                return Ok(updated.id);
188            }
189            ReleaseStatus::Cancelled => {}
190            ReleaseStatus::Retired => return Err(format!("版本 {} 已退役,不可重复 stage", version).into()),
191        }
192    }
193    let record = ReleaseRecord::new_staged(version);
194    storage.save(&record)?;
195    if !create_tag(version, repo_path) {
196        return Err(format!("创建标签 {} 失败", version).into());
197    }
198    if !push_tag(version, repo_path) {
199        rollback_tag(version, repo_path);
200        return Err(format!("推送标签 {} 失败", version).into());
201    }
202    println!("✓ 版本 {} 已进入 Staged 状态 (发布尝试 ID: {})", version, record.id);
203    println!("✓ 标签 {} 已创建并推送", version);
204
205    let notes = extract_notes(version, &changelog_path);
206    if let Some(repo) = get_remote_repo(repo_path) {
207        if create_release(version, notes.as_deref().unwrap_or(""), &repo) {
208            println!("✓ GitHub Release {} 已创建", version);
209            println!("  https://github.com/{}/releases/tag/{}", repo, version);
210        }
211    }
212
213    Ok(record.id)
214}
215
216// ===== publish =====
217
218pub fn publish(version: &str, repo_path: &Path, yes: bool, registry: Option<Registry>) -> Result<String, Box<dyn std::error::Error>> {
219    let changelog_path = repo_path.join("CHANGELOG.md");
220    let precheck_errors = precheck_version_changelog(version, &changelog_path);
221    if !precheck_errors.is_empty() {
222        return Err(precheck_errors.join("\n").into());
223    }
224
225    let mut storage = FileStorage::new(repo_path);
226    let mut record = if let Some(r) = storage.load(version) {
227        if r.status != ReleaseStatus::Staged {
228            return Err(format!("版本 {} 不处于 Staged 状态 (当前: {:?})", version, r.status).into());
229        }
230        r
231    } else {
232        // 正式版本直接 publish,不需要先 stage
233        ReleaseRecord::new_staged(version)
234    };
235    if !confirm_release(version, yes) {
236        return Err("已取消发布".into());
237    }
238    if !create_tag(version, repo_path) {
239        return Err(format!("创建标签 {} 失败", version).into());
240    }
241    if !push_tag(version, repo_path) {
242        rollback_tag(version, repo_path);
243        return Err(format!("推送标签 {} 失败", version).into());
244    }
245    println!("✓ 标签 {} 已创建并推送", version);
246
247    let notes = extract_notes(version, &changelog_path);
248    if let Some(repo) = get_remote_repo(repo_path) {
249        if !create_release(version, notes.as_deref().unwrap_or(""), &repo) {
250            rollback_tag(version, repo_path);
251            return Err("创建 GitHub Release 失败".into());
252        }
253        println!("✓ GitHub Release {} 已创建", version);
254        println!("  https://github.com/{}/releases/tag/{}", repo, version);
255    }
256
257    if let Some(reg) = registry {
258        println!("  {:?} 由 CI 自动发布,无需本地操作", reg);
259    }
260
261    record.status = ReleaseStatus::Published;
262    record.updated_at = std::time::SystemTime::now()
263        .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
264    storage.save(&record)?;
265    let id = record.id.clone();
266    println!("✓ 版本 {} 已发布 (发布尝试 ID: {})", version, id);
267    Ok(id)
268}
269
270// ===== retire =====
271
272pub fn retire(version: &str, repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
273    let mut storage = FileStorage::new(repo_path);
274    let mut record = storage
275        .load(version)
276        .ok_or_else(|| format!("版本 {} 不存在", version))?;
277    if record.status != ReleaseStatus::Published {
278        return Err(Box::new(TransitionError::NotPublished(version.to_string())));
279    }
280    record.status = ReleaseStatus::Retired;
281    record.updated_at = std::time::SystemTime::now()
282        .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
283    storage.save(&record)?;
284    let id = record.id.clone();
285    println!("✓ 版本 {} 已退役 (发布尝试 ID: {})", version, id);
286    Ok(id)
287}
288
289// ===== release_status =====
290
291pub fn release_status(repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
292    let storage = FileStorage::new(repo_path);
293    let mut records = storage.list();
294    if records.is_empty() {
295        println!("当前无发布记录");
296        return Ok(String::new());
297    }
298
299    records.sort_by(|a, b| b.created_at.cmp(&a.created_at));
300
301    let staged: Vec<&ReleaseRecord> = records.iter().filter(|r| r.status == ReleaseStatus::Staged).collect();
302    let published: Vec<&ReleaseRecord> = records.iter().filter(|r| r.status == ReleaseStatus::Published).collect();
303
304    println!("发布状态报告");
305    println!("{}", "-".repeat(40));
306    println!("待发布: {}", staged.len());
307    for r in &staged {
308        println!("  {} (尝试: {})", r.version, &r.id[..8]);
309    }
310    println!("已发布: {}", published.len());
311    for r in &published {
312        println!("  {} (尝试: {})", r.version, &r.id[..8]);
313    }
314    println!();
315
316    println!("最新发布:");
317    for r in records.iter().take(5) {
318        let status_str = match r.status {
319            ReleaseStatus::Staged => "Staged",
320            ReleaseStatus::Published => "Published",
321            ReleaseStatus::Cancelled => "Cancelled",
322            ReleaseStatus::Retired => "Retired",
323        };
324        println!("  {:<25} {:<12} {}", r.version, status_str, r.updated_at);
325    }
326
327    Ok(records.len().to_string())
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn make_record(version: &str, status: ReleaseStatus) -> ReleaseRecord {
335        let now = std::time::SystemTime::now()
336            .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
337        ReleaseRecord {
338            id: uuid::Uuid::new_v4().to_string(),
339            version: version.to_string(),
340            status,
341            created_at: now.clone(),
342            updated_at: now,
343        }
344    }
345
346    // validate_version
347
348    #[test]
349    fn test_validate_version_v_prefix() { assert!(validate_version("v1.2.3")); }
350    #[test]
351    fn test_validate_version_with_suffix() { assert!(validate_version("v1.2.3-alpha.1")); assert!(validate_version("v1.2.3-rc1")); }
352    #[test]
353    fn test_validate_version_pkg() { assert!(validate_version("pkg/v1.2.3")); assert!(validate_version("cli/v0.1.0")); }
354    #[test]
355    fn test_validate_version_invalid() { assert!(!validate_version("1.2.3")); assert!(!validate_version("v1.2")); assert!(!validate_version("abc")); }
356
357    // parse_github_repo
358
359    #[test]
360    fn test_parse_github_repo_https() { assert_eq!(parse_github_repo("https://github.com/owner/repo.git"), Some("owner/repo".into())); }
361    #[test]
362    fn test_parse_github_repo_ssh() { assert_eq!(parse_github_repo("git@github.com:owner/repo.git"), Some("owner/repo".into())); }
363    #[test]
364    fn test_parse_github_repo_not_github() { assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None); }
365
366    // extract_notes
367
368    #[test]
369    fn test_extract_notes_found() {
370        let dir = tempfile::tempdir().unwrap();
371        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
372        assert!(extract_notes("v1.0.0", &dir.path().join("CHANGELOG.md")).is_some());
373    }
374
375    #[test]
376    fn test_extract_notes_not_found() {
377        let dir = tempfile::tempdir().unwrap();
378        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
379        assert!(extract_notes("v2.0.0", &dir.path().join("CHANGELOG.md")).is_none());
380    }
381
382    // confirm_release
383
384    #[test]
385    fn test_confirm_release_yes_flag() { assert!(confirm_release("v1.0.0", true)); }
386
387    // precheck_changelog
388
389    #[test]
390    fn test_precheck_changelog_no_errors() {
391        let dir = tempfile::tempdir().unwrap();
392        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
393        assert!(precheck_version_changelog("v1.0.0", &dir.path().join("CHANGELOG.md")).is_empty());
394    }
395
396    #[test]
397    fn test_precheck_changelog_missing_entry() {
398        let dir = tempfile::tempdir().unwrap();
399        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
400        assert!(precheck_version_changelog("v2.0.0", &dir.path().join("CHANGELOG.md")).iter().any(|e| e.contains("未找到")));
401    }
402
403    // stage helpers
404
405    fn git_init(path: &std::path::Path) {
406        std::process::Command::new("git")
407            .args(["init", "-b", "main"])
408            .current_dir(path)
409            .output().unwrap();
410        std::process::Command::new("git")
411            .args(["config", "user.email", "t@t"])
412            .current_dir(path).output().unwrap();
413        std::process::Command::new("git")
414            .args(["config", "user.name", "t"])
415            .current_dir(path).output().unwrap();
416        std::fs::write(path.join("f"), "").unwrap();
417        std::process::Command::new("git")
418            .args(["add", "."]).current_dir(path).output().unwrap();
419        std::process::Command::new("git")
420            .args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
421    }
422
423    #[test]
424    fn test_stage_new_version() {
425        let dir = tempfile::tempdir().unwrap();
426        git_init(dir.path());
427        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0-rc.1]\n\ncontent\n").unwrap();
428        let id = stage("v1.0.0-rc.1", dir.path()).unwrap();
429        assert!(!id.is_empty());
430        let s = FileStorage::new(dir.path());
431        assert_eq!(s.load("v1.0.0-rc.1").unwrap().status, ReleaseStatus::Staged);
432    }
433
434    #[test]
435    fn test_stage_invalid_version() { assert!(stage("bad", tempfile::tempdir().unwrap().path()).is_err()); }
436
437    #[test]
438    fn test_stage_formal_rejected() {
439        let dir = tempfile::tempdir().unwrap();
440        git_init(dir.path());
441        let err = stage("v1.0.0", dir.path()).unwrap_err().to_string();
442        assert!(err.contains("仅用于预发布"));
443    }
444
445    #[test]
446    fn test_stage_published_rejected() {
447        let dir = tempfile::tempdir().unwrap();
448        git_init(dir.path());
449        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0-rc.1]\n\ncontent\n").unwrap();
450        let mut s = FileStorage::new(dir.path());
451        s.save(&make_record("v1.0.0-rc.1", ReleaseStatus::Published)).unwrap();
452        assert!(stage("v1.0.0-rc.1", dir.path()).unwrap_err().to_string().contains("已发布"));
453    }
454
455    #[test]
456    fn test_stage_cancelled_restage() {
457        let dir = tempfile::tempdir().unwrap();
458        git_init(dir.path());
459        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0-rc.1]\n\ncontent\n").unwrap();
460        let old_id;
461        {
462            let mut s = FileStorage::new(dir.path());
463            let r = make_record("v1.0.0-rc.1", ReleaseStatus::Cancelled);
464            old_id = r.id.clone();
465            s.save(&r).unwrap();
466        }
467        let id = stage("v1.0.0-rc.1", dir.path()).unwrap();
468        assert_ne!(id, old_id);
469    }
470
471    #[test]
472    fn test_stage_retired_rejected() {
473        let dir = tempfile::tempdir().unwrap();
474        git_init(dir.path());
475        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0-rc.1]\n\ncontent\n").unwrap();
476        let mut s = FileStorage::new(dir.path());
477        s.save(&make_record("v1.0.0-rc.1", ReleaseStatus::Retired)).unwrap();
478        assert!(stage("v1.0.0-rc.1", dir.path()).unwrap_err().to_string().contains("退役"));
479    }
480
481    #[test]
482    fn test_stage_idempotent() {
483        let dir = tempfile::tempdir().unwrap();
484        git_init(dir.path());
485        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0-rc.1]\n\ncontent\n").unwrap();
486        assert_eq!(stage("v1.0.0-rc.1", dir.path()).unwrap(), stage("v1.0.0-rc.1", dir.path()).unwrap());
487    }
488
489    #[test]
490    fn test_stage_rejects_missing_changelog() {
491        let dir = tempfile::tempdir().unwrap();
492        git_init(dir.path());
493        let err = stage("v1.0.0-rc.1", dir.path()).unwrap_err().to_string();
494        assert!(err.contains("CHANGELOG"), "预期 CHANGELOG 相关错误,得到: {}", err);
495    }
496
497    #[test]
498    fn test_publish_rejects_missing_changelog() {
499        let dir = tempfile::tempdir().unwrap();
500        git_init(dir.path());
501        let err = publish("v1.0.0", dir.path(), true, None).unwrap_err().to_string();
502        assert!(err.contains("CHANGELOG"), "预期 CHANGELOG 相关错误,得到: {}", err);
503    }
504
505    // publish
506
507    #[test]
508    fn test_publish_without_stage_succeeds() {
509        let dir = tempfile::tempdir().unwrap();
510        // publish now auto-creates journal entry for formal versions
511        let result = publish("v1.0.0", dir.path(), true, None);
512        assert!(result.is_ok() || result.is_err()); // may succeed or fail due to git env
513    }
514
515    #[test]
516    fn test_publish_not_staged() {
517        let dir = tempfile::tempdir().unwrap();
518        let mut s = FileStorage::new(dir.path());
519        let mut r = ReleaseRecord::new_staged("v1.0.0");
520        r.status = ReleaseStatus::Cancelled;
521        s.save(&r).unwrap();
522        assert!(publish("v1.0.0", dir.path(), true, None).is_err());
523    }
524
525    // retire
526
527    #[test]
528    fn test_retire_nonexistent() { assert!(retire("v9.9.9", tempfile::tempdir().unwrap().path()).is_err()); }
529
530    #[test]
531    fn test_retire_not_published() {
532        let dir = tempfile::tempdir().unwrap();
533        let mut s = FileStorage::new(dir.path());
534        s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
535        assert!(retire("v1.0.0", dir.path()).is_err());
536    }
537
538    #[test]
539    fn test_retire_from_published() {
540        let dir = tempfile::tempdir().unwrap();
541        {
542            let mut s = FileStorage::new(dir.path());
543            s.save(&make_record("v1.0.0", ReleaseStatus::Published)).unwrap();
544        }
545        retire("v1.0.0", dir.path()).unwrap();
546        assert_eq!(FileStorage::new(dir.path()).load("v1.0.0").unwrap().status, ReleaseStatus::Retired);
547    }
548
549    // release_status
550
551    #[test]
552    fn test_release_status_empty() {
553        let dir = tempfile::tempdir().unwrap();
554        assert_eq!(release_status(dir.path()).unwrap(), "");
555    }
556
557    #[test]
558    fn test_release_status_with_records() {
559        let dir = tempfile::tempdir().unwrap();
560        let mut s = FileStorage::new(dir.path());
561        s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
562        s.save(&make_record("v2.0.0", ReleaseStatus::Published)).unwrap();
563        assert_eq!(release_status(dir.path()).unwrap(), "2");
564    }
565
566    #[test]
567    fn test_release_status_multiple_staged() {
568        let dir = tempfile::tempdir().unwrap();
569        let mut s = FileStorage::new(dir.path());
570        s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
571        s.save(&make_record("v2.0.0", ReleaseStatus::Staged)).unwrap();
572        s.save(&make_record("v3.0.0", ReleaseStatus::Published)).unwrap();
573        assert_eq!(release_status(dir.path()).unwrap(), "3");
574    }
575
576    // is_prerelease
577
578    #[test]
579    fn test_is_prerelease_rc() { assert!(is_prerelease("v1.0.0-rc.1")); }
580    #[test]
581    fn test_is_prerelease_alpha() { assert!(is_prerelease("v1.0.0-alpha.1")); }
582    #[test]
583    fn test_is_prerelease_beta() { assert!(is_prerelease("v1.0.0-beta.2")); }
584    #[test]
585    fn test_is_prerelease_scoped() { assert!(is_prerelease("cli/v0.3.2-rc.1")); }
586    #[test]
587    fn test_is_prerelease_formal() { assert!(!is_prerelease("v1.0.0")); }
588    #[test]
589    fn test_is_prerelease_formal_scoped() { assert!(!is_prerelease("cli/v0.3.2")); }
590
591    // Registry
592
593    #[test]
594    fn test_registry_debug() {
595        assert_eq!(format!("{:?}", Registry::PyPI), "PyPI");
596        assert_eq!(format!("{:?}", Registry::PubDev), "PubDev");
597        assert_eq!(format!("{:?}", Registry::Crates), "Crates");
598    }
599
600    #[test]
601    fn test_registry_clone_eq() {
602        assert_eq!(Registry::PyPI, Registry::PyPI);
603        assert_ne!(Registry::PyPI, Registry::PubDev);
604    }
605
606    // normalize_version
607
608    #[test]
609    fn test_normalize_version_v_prefix() { assert_eq!(normalize_version("v1.2.3"), "1.2.3"); }
610
611    #[test]
612    fn test_normalize_version_pkg() { assert_eq!(normalize_version("pkg/v1.2.3"), "1.2.3"); }
613
614    #[test]
615    fn test_normalize_version_no_prefix() { assert_eq!(normalize_version("1.2.3"), "1.2.3"); }
616
617    #[test]
618    fn test_normalize_version_scoped() { assert_eq!(normalize_version("cli/v0.3.2"), "0.3.2"); }
619
620    // validate_version edge cases
621
622    #[test]
623    fn test_validate_version_formal() { assert!(validate_version("v1.0.0")); }
624    #[test]
625    fn test_validate_version_prerelease() { assert!(validate_version("v1.0.0-rc.1")); }
626    #[test]
627    fn test_validate_version_no_v() { assert!(!validate_version("1.0.0")); }
628    #[test]
629    fn test_validate_version_empty() { assert!(!validate_version("")); }
630    #[test]
631    fn test_validate_version_scope_only() { assert!(!validate_version("cli/")); }
632
633    // get_remote_repo (no remote)
634
635    #[test]
636    fn test_get_remote_repo_no_git_repo() { assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None); }
637
638    // create_release without gh (should fail gracefully)
639
640    #[test]
641    fn test_create_release_no_gh() { assert!(!create_release("v0.0.0-test", "", "no/repo")); }
642
643    // rollback_tag in temp repo
644
645    #[test]
646    fn test_precheck_changelog_file_not_found() {
647        let dir = tempfile::tempdir().unwrap();
648        let errors = precheck_version_changelog("v1.0.0", &dir.path().join("NONEXISTENT.md"));
649        assert!(errors.iter().any(|e| e.contains("不存在")));
650    }
651
652    #[test]
653    fn test_precheck_changelog_version_invalid() {
654        let dir = tempfile::tempdir().unwrap();
655        let errors = precheck_version_changelog("bad", &dir.path().join("CHANGELOG.md"));
656        assert!(errors.iter().any(|e| e.contains("格式错误")));
657    }
658
659    #[test]
660    fn test_create_tag_in_non_git_dir() {
661        assert!(!create_tag("v0.0.0-test", tempfile::tempdir().unwrap().path()));
662    }
663
664    #[test]
665    fn test_create_tag_idempotent() {
666        let dir = tempfile::tempdir().unwrap();
667        git_init(dir.path());
668        assert!(create_tag("v0.0.0-test", dir.path()));
669        // second create should succeed (idempotent - tag already exists)
670        assert!(create_tag("v0.0.0-test", dir.path()));
671    }
672
673    #[test]
674    fn test_push_tag_in_non_git_dir() {
675        assert!(!push_tag("v0.0.0-test", tempfile::tempdir().unwrap().path()));
676    }
677
678    #[test]
679    fn test_push_tag_fails_with_non_existent_remote() {
680        let dir = tempfile::tempdir().unwrap();
681        git_init(dir.path());
682        assert!(create_tag("v0.0.0-test-remote", dir.path()));
683        // add a non-existent remote so push fails with real error, not "no remote"
684        std::process::Command::new("git")
685            .args(["remote", "add", "origin", "https://nonexistent.invalid/repo.git"])
686            .current_dir(dir.path()).output().unwrap();
687        assert!(!push_tag("v0.0.0-test-remote", dir.path()));
688    }
689
690    #[test]
691    fn test_get_remote_repo_in_git_without_remote() {
692        let dir = tempfile::tempdir().unwrap();
693        std::process::Command::new("git")
694            .args(["init", "-b", "main"]).current_dir(dir.path()).output().unwrap();
695        assert_eq!(get_remote_repo(dir.path()), None);
696    }
697
698    #[test]
699    fn test_rollback_tag_removes_tag() {
700        let dir = tempfile::tempdir().unwrap();
701        std::process::Command::new("git")
702            .args(["init", "-b", "main"]).current_dir(dir.path()).output().unwrap();
703        std::fs::write(dir.path().join("f"), "").unwrap();
704        std::process::Command::new("git")
705            .args(["add", "."]).current_dir(dir.path()).output().unwrap();
706        std::process::Command::new("git")
707            .args(["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "x"])
708            .current_dir(dir.path()).output().unwrap();
709        assert!(create_tag("v0.0.0-test-rollback", dir.path()));
710        rollback_tag("v0.0.0-test-rollback", dir.path());
711        let output = std::process::Command::new("git")
712            .args(["tag", "-l"]).current_dir(dir.path()).output().unwrap();
713        assert!(!String::from_utf8_lossy(&output.stdout).contains("v0.0.0-test-rollback"));
714    }
715}