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
13pub 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; }
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; }
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; }
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
154fn 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 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
216pub 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 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
270pub 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
289pub 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 #[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 #[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 #[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 #[test]
385 fn test_confirm_release_yes_flag() { assert!(confirm_release("v1.0.0", true)); }
386
387 #[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 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 #[test]
508 fn test_publish_without_stage_succeeds() {
509 let dir = tempfile::tempdir().unwrap();
510 let result = publish("v1.0.0", dir.path(), true, None);
512 assert!(result.is_ok() || result.is_err()); }
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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
636 fn test_get_remote_repo_no_git_repo() { assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None); }
637
638 #[test]
641 fn test_create_release_no_gh() { assert!(!create_release("v0.0.0-test", "", "no/repo")); }
642
643 #[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 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 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}