1use std::path::Path;
2use std::process::Command;
3
4use crate::model::release::{FileStorage, ReleaseRecord, ReleaseStatus, Storage, TransitionError};
5
6pub fn validate_version(version: &str) -> bool {
9 let re = regex::Regex::new(
10 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.]+)?)$",
11 ).unwrap();
12 re.is_match(version)
13}
14
15fn normalize_version(version: &str) -> String {
16 let s = version.strip_prefix('v').unwrap_or(version);
17 s.split("/v").last().unwrap_or(s).to_string()
18}
19
20pub fn precheck_version_changelog(version: &str, changelog_path: &Path) -> Vec<String> {
21 let mut errors = Vec::new();
22 if !validate_version(version) {
23 errors.push(format!("版本号格式错误: {}", version));
24 }
25 if changelog_path.exists() {
26 let content = std::fs::read_to_string(changelog_path).unwrap_or_default();
27 let ver = normalize_version(version);
28 let marker = format!("## [{}]", ver);
29 if !content.contains(&marker) {
30 errors.push(format!("CHANGELOG.md 未找到 {} 版本记录", ver));
31 }
32 } else {
33 errors.push(format!("CHANGELOG.md 不存在: {}", changelog_path.display()));
34 }
35 errors
36}
37
38pub fn extract_notes(version: &str, changelog_path: &Path) -> Option<String> {
39 let content = std::fs::read_to_string(changelog_path).ok()?;
40 let ver = normalize_version(version);
41 let start_marker = format!("## [{}]", ver);
42 let mut capture = false;
43 let mut notes: Vec<&str> = Vec::new();
44 for line in content.lines() {
45 if line.trim().starts_with(&start_marker) {
46 capture = true;
47 continue;
48 }
49 if capture {
50 if line.starts_with("## [") {
51 break;
52 }
53 notes.push(line);
54 }
55 }
56 let text = notes.join("\n").trim().to_string();
57 if text.is_empty() { None } else { Some(text) }
58}
59
60pub fn confirm_release(version: &str, yes: bool) -> bool {
61 if yes { return true; }
62 use std::io::Write;
63 println!("\n发布版本: {}", version);
64 print!("确认发布? (y/N): ");
65 std::io::stdout().flush().ok();
66 let mut input = String::new();
67 std::io::stdin().read_line(&mut input).ok();
68 let input = input.trim().to_lowercase();
69 input == "y" || input == "yes"
70}
71
72fn git_args(args: &[&str], repo_path: &Path) -> Command {
73 let mut cmd = Command::new("git");
74 cmd.arg("-C");
75 cmd.arg(repo_path);
76 cmd.args(args);
77 cmd
78}
79
80pub fn create_tag(version: &str, repo_path: &Path) -> bool {
81 match git_args(&["tag", version], repo_path).output() {
82 Ok(out) if out.status.success() => true,
83 Ok(out) => { eprintln!("创建标签失败: {}", String::from_utf8_lossy(&out.stderr).trim()); false }
84 Err(e) => { eprintln!("创建标签失败: {}", e); false }
85 }
86}
87
88pub fn push_tag(version: &str, repo_path: &Path) -> bool {
89 match git_args(&["push", "origin", version], repo_path).output() {
90 Ok(out) if out.status.success() => true,
91 Ok(out) => { eprintln!("推送标签失败: {}", String::from_utf8_lossy(&out.stderr).trim()); false }
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 let url = String::from_utf8_lossy(&result.stdout).trim().to_string();
100 parse_github_repo(&url)
101}
102
103pub fn parse_github_repo(url: &str) -> Option<String> {
104 let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
105 let caps = re.captures(url)?;
106 Some(caps.get(1)?.as_str().to_string())
107}
108
109pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
110 match Command::new("gh")
111 .args(["release", "create", version, "--title", version, "--notes", notes, "--repo", repo])
112 .output()
113 {
114 Ok(out) if out.status.success() => true,
115 Ok(out) => { eprintln!("创建 Release 失败: {}", String::from_utf8_lossy(&out.stderr).trim()); false }
116 Err(e) => { eprintln!("创建 Release 失败: {}", e); false }
117 }
118}
119
120pub fn rollback_tag(version: &str, repo_path: &Path) {
121 git_args(&["tag", "-d", version], repo_path).output().ok();
122 git_args(&["push", "origin", "--delete", version], repo_path).output().ok();
123 println!("↻ 标签 {} 已回滚", version);
124}
125
126pub fn stage(version: &str, repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
129 if !validate_version(version) {
130 return Err(format!("版本号格式错误: {}", version).into());
131 }
132 let mut storage = FileStorage::new(repo_path);
133 if let Some(existing) = storage.load(version) {
134 match existing.status {
135 ReleaseStatus::Published => return Err(format!("版本 {} 已发布,不可重复 stage", version).into()),
136 ReleaseStatus::Staged => {
137 let now = std::time::SystemTime::now()
138 .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
139 let mut updated = existing.clone();
140 updated.updated_at = now;
141 storage.save(&updated)?;
142 return Ok(updated.id);
143 }
144 ReleaseStatus::Cancelled => {}
145 ReleaseStatus::Retired => return Err(format!("版本 {} 已退役,不可重复 stage", version).into()),
146 }
147 }
148 let record = ReleaseRecord::new_staged(version);
149 storage.save(&record)?;
150 println!("✓ 版本 {} 已进入 Staged 状态 (发布尝试 ID: {})", version, record.id);
151 Ok(record.id)
152}
153
154pub fn publish(version: &str, repo_path: &Path, yes: bool) -> Result<String, Box<dyn std::error::Error>> {
157 let mut storage = FileStorage::new(repo_path);
158 let mut record = storage
159 .load(version)
160 .ok_or_else(|| format!("版本 {} 不存在,请先执行 stage", version))?;
161 if record.status != ReleaseStatus::Staged {
162 return Err(format!("版本 {} 不处于 Staged 状态 (当前: {:?})", version, record.status).into());
163 }
164 if !confirm_release(version, yes) {
165 return Err("已取消发布".into());
166 }
167 if !create_tag(version, repo_path) {
168 return Err(format!("创建标签 {} 失败", version).into());
169 }
170 if !push_tag(version, repo_path) {
171 rollback_tag(version, repo_path);
172 return Err(format!("推送标签 {} 失败", version).into());
173 }
174 println!("✓ 标签 {} 已创建并推送", version);
175
176 let changelog_path = repo_path.join("CHANGELOG.md");
177 let notes = extract_notes(version, &changelog_path);
178 if let Some(repo) = get_remote_repo(repo_path) {
179 if !create_release(version, notes.as_deref().unwrap_or(""), &repo) {
180 rollback_tag(version, repo_path);
181 return Err("创建 GitHub Release 失败".into());
182 }
183 println!("✓ GitHub Release {} 已创建", version);
184 println!(" https://github.com/{}/releases/tag/{}", repo, version);
185 }
186
187 record.status = ReleaseStatus::Published;
188 record.updated_at = std::time::SystemTime::now()
189 .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
190 storage.save(&record)?;
191 let id = record.id.clone();
192 println!("✓ 版本 {} 已发布 (发布尝试 ID: {})", version, id);
193 Ok(id)
194}
195
196pub fn cancel(version: &str, repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
199 eprintln!("warning: cancel 已废弃,将在 v0.4.0 移除。rc 失败时直接递增 rc 序号即可,不需要 cancel。");
200 let mut storage = FileStorage::new(repo_path);
201 let mut record = storage
202 .load(version)
203 .ok_or_else(|| format!("版本 {} 不存在", version))?;
204 if record.status != ReleaseStatus::Staged {
205 return Err(Box::new(TransitionError::NotStaged(version.to_string())));
206 }
207 rollback_tag(version, repo_path);
208 if let Some(repo) = get_remote_repo(repo_path) {
209 std::process::Command::new("gh")
210 .args(["release", "delete", version, "--repo", &repo, "--yes"])
211 .output().ok();
212 println!("✓ GitHub Release {} 已删除", version);
213 }
214 record.status = ReleaseStatus::Cancelled;
215 record.updated_at = std::time::SystemTime::now()
216 .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
217 storage.save(&record)?;
218 let id = record.id.clone();
219 println!("✓ 版本 {} 已取消 (发布尝试 ID: {})", version, id);
220 Ok(id)
221}
222
223pub fn retire(version: &str, repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
226 let mut storage = FileStorage::new(repo_path);
227 let mut record = storage
228 .load(version)
229 .ok_or_else(|| format!("版本 {} 不存在", version))?;
230 if record.status != ReleaseStatus::Published {
231 return Err(Box::new(TransitionError::NotPublished(version.to_string())));
232 }
233 record.status = ReleaseStatus::Retired;
234 record.updated_at = std::time::SystemTime::now()
235 .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
236 storage.save(&record)?;
237 let id = record.id.clone();
238 println!("✓ 版本 {} 已退役 (发布尝试 ID: {})", version, id);
239 Ok(id)
240}
241
242pub fn release_status(repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
245 let storage = FileStorage::new(repo_path);
246 let mut records = storage.list();
247 if records.is_empty() {
248 println!("当前无发布记录");
249 return Ok(String::new());
250 }
251
252 records.sort_by(|a, b| b.created_at.cmp(&a.created_at));
253
254 let staged: Vec<&ReleaseRecord> = records.iter().filter(|r| r.status == ReleaseStatus::Staged).collect();
255 let published: Vec<&ReleaseRecord> = records.iter().filter(|r| r.status == ReleaseStatus::Published).collect();
256
257 println!("发布状态报告");
258 println!("{}", "-".repeat(40));
259 println!("待发布: {}", staged.len());
260 for r in &staged {
261 println!(" {} (尝试: {})", r.version, &r.id[..8]);
262 }
263 println!("已发布: {}", published.len());
264 for r in &published {
265 println!(" {} (尝试: {})", r.version, &r.id[..8]);
266 }
267 println!();
268
269 println!("最新发布:");
270 for r in records.iter().take(5) {
271 let status_str = match r.status {
272 ReleaseStatus::Staged => "Staged",
273 ReleaseStatus::Published => "Published",
274 ReleaseStatus::Cancelled => "Cancelled",
275 ReleaseStatus::Retired => "Retired",
276 };
277 println!(" {:<25} {:<12} {}", r.version, status_str, r.updated_at);
278 }
279
280 Ok(records.len().to_string())
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn make_record(version: &str, status: ReleaseStatus) -> ReleaseRecord {
288 let now = std::time::SystemTime::now()
289 .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
290 ReleaseRecord {
291 id: uuid::Uuid::new_v4().to_string(),
292 version: version.to_string(),
293 status,
294 created_at: now.clone(),
295 updated_at: now,
296 }
297 }
298
299 #[test]
302 fn test_validate_version_v_prefix() { assert!(validate_version("v1.2.3")); }
303 #[test]
304 fn test_validate_version_with_suffix() { assert!(validate_version("v1.2.3-alpha.1")); assert!(validate_version("v1.2.3-rc1")); }
305 #[test]
306 fn test_validate_version_pkg() { assert!(validate_version("pkg/v1.2.3")); assert!(validate_version("cli/v0.1.0")); }
307 #[test]
308 fn test_validate_version_invalid() { assert!(!validate_version("1.2.3")); assert!(!validate_version("v1.2")); assert!(!validate_version("abc")); }
309
310 #[test]
313 fn test_parse_github_repo_https() { assert_eq!(parse_github_repo("https://github.com/owner/repo.git"), Some("owner/repo".into())); }
314 #[test]
315 fn test_parse_github_repo_ssh() { assert_eq!(parse_github_repo("git@github.com:owner/repo.git"), Some("owner/repo".into())); }
316 #[test]
317 fn test_parse_github_repo_not_github() { assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None); }
318
319 #[test]
322 fn test_extract_notes_found() {
323 let dir = tempfile::tempdir().unwrap();
324 std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
325 assert!(extract_notes("v1.0.0", &dir.path().join("CHANGELOG.md")).is_some());
326 }
327
328 #[test]
329 fn test_extract_notes_not_found() {
330 let dir = tempfile::tempdir().unwrap();
331 std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
332 assert!(extract_notes("v2.0.0", &dir.path().join("CHANGELOG.md")).is_none());
333 }
334
335 #[test]
338 fn test_confirm_release_yes_flag() { assert!(confirm_release("v1.0.0", true)); }
339
340 #[test]
343 fn test_precheck_changelog_no_errors() {
344 let dir = tempfile::tempdir().unwrap();
345 std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
346 assert!(precheck_version_changelog("v1.0.0", &dir.path().join("CHANGELOG.md")).is_empty());
347 }
348
349 #[test]
350 fn test_precheck_changelog_missing_entry() {
351 let dir = tempfile::tempdir().unwrap();
352 std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
353 assert!(precheck_version_changelog("v2.0.0", &dir.path().join("CHANGELOG.md")).iter().any(|e| e.contains("未找到")));
354 }
355
356 #[test]
359 fn test_stage_new_version() {
360 let dir = tempfile::tempdir().unwrap();
361 let id = stage("v1.0.0", dir.path()).unwrap();
362 assert!(!id.is_empty());
363 let s = FileStorage::new(dir.path());
364 assert_eq!(s.load("v1.0.0").unwrap().status, ReleaseStatus::Staged);
365 }
366
367 #[test]
368 fn test_stage_invalid_version() { assert!(stage("bad", tempfile::tempdir().unwrap().path()).is_err()); }
369
370 #[test]
371 fn test_stage_published_rejected() {
372 let dir = tempfile::tempdir().unwrap();
373 let mut s = FileStorage::new(dir.path());
374 s.save(&make_record("v1.0.0", ReleaseStatus::Published)).unwrap();
375 assert!(stage("v1.0.0", dir.path()).unwrap_err().to_string().contains("已发布"));
376 }
377
378 #[test]
379 fn test_stage_cancelled_restage() {
380 let dir = tempfile::tempdir().unwrap();
381 let old_id;
382 {
383 let mut s = FileStorage::new(dir.path());
384 let r = make_record("v1.0.0", ReleaseStatus::Cancelled);
385 old_id = r.id.clone();
386 s.save(&r).unwrap();
387 }
388 let id = stage("v1.0.0", dir.path()).unwrap();
389 assert_ne!(id, old_id);
390 }
391
392 #[test]
393 fn test_stage_retired_rejected() {
394 let dir = tempfile::tempdir().unwrap();
395 let mut s = FileStorage::new(dir.path());
396 s.save(&make_record("v1.0.0", ReleaseStatus::Retired)).unwrap();
397 assert!(stage("v1.0.0", dir.path()).unwrap_err().to_string().contains("退役"));
398 }
399
400 #[test]
401 fn test_stage_idempotent() {
402 let dir = tempfile::tempdir().unwrap();
403 assert_eq!(stage("v1.0.0", dir.path()).unwrap(), stage("v1.0.0", dir.path()).unwrap());
404 }
405
406 #[test]
409 fn test_publish_not_found() {
410 let dir = tempfile::tempdir().unwrap();
411 assert!(publish("v1.0.0", dir.path(), true).unwrap_err().to_string().contains("请先执行 stage"));
412 }
413
414 #[test]
415 fn test_publish_not_staged() {
416 let dir = tempfile::tempdir().unwrap();
417 let mut s = FileStorage::new(dir.path());
418 let mut r = ReleaseRecord::new_staged("v1.0.0");
419 r.status = ReleaseStatus::Cancelled;
420 s.save(&r).unwrap();
421 assert!(publish("v1.0.0", dir.path(), true).is_err());
422 }
423
424 #[test]
427 fn test_cancel_nonexistent() { assert!(cancel("v9.9.9", tempfile::tempdir().unwrap().path()).is_err()); }
428
429 #[test]
430 fn test_cancel_not_staged() {
431 let dir = tempfile::tempdir().unwrap();
432 let mut s = FileStorage::new(dir.path());
433 s.save(&make_record("v1.0.0", ReleaseStatus::Published)).unwrap();
434 assert!(cancel("v1.0.0", dir.path()).is_err());
435 }
436
437 #[test]
438 fn test_cancel_happy_path() {
439 let dir = tempfile::tempdir().unwrap();
440 let record_id;
441 {
442 let mut s = FileStorage::new(dir.path());
443 let r = ReleaseRecord::new_staged("v1.0.0");
444 record_id = r.id.clone();
445 s.save(&r).unwrap();
446 }
447 cancel("v1.0.0", dir.path()).unwrap();
448 assert_eq!(FileStorage::new(dir.path()).load("v1.0.0").unwrap().status, ReleaseStatus::Cancelled);
449 assert_eq!(FileStorage::new(dir.path()).load("v1.0.0").unwrap().id, record_id);
450 }
451
452 #[test]
455 fn test_retire_nonexistent() { assert!(retire("v9.9.9", tempfile::tempdir().unwrap().path()).is_err()); }
456
457 #[test]
458 fn test_retire_not_published() {
459 let dir = tempfile::tempdir().unwrap();
460 let mut s = FileStorage::new(dir.path());
461 s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
462 assert!(retire("v1.0.0", dir.path()).is_err());
463 }
464
465 #[test]
466 fn test_retire_from_published() {
467 let dir = tempfile::tempdir().unwrap();
468 {
469 let mut s = FileStorage::new(dir.path());
470 s.save(&make_record("v1.0.0", ReleaseStatus::Published)).unwrap();
471 }
472 retire("v1.0.0", dir.path()).unwrap();
473 assert_eq!(FileStorage::new(dir.path()).load("v1.0.0").unwrap().status, ReleaseStatus::Retired);
474 }
475
476 #[test]
479 fn test_release_status_empty() {
480 let dir = tempfile::tempdir().unwrap();
481 assert_eq!(release_status(dir.path()).unwrap(), "");
482 }
483
484 #[test]
485 fn test_release_status_with_records() {
486 let dir = tempfile::tempdir().unwrap();
487 let mut s = FileStorage::new(dir.path());
488 s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
489 s.save(&make_record("v2.0.0", ReleaseStatus::Published)).unwrap();
490 assert_eq!(release_status(dir.path()).unwrap(), "2");
491 }
492
493 #[test]
494 fn test_release_status_multiple_staged() {
495 let dir = tempfile::tempdir().unwrap();
496 let mut s = FileStorage::new(dir.path());
497 s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
498 s.save(&make_record("v2.0.0", ReleaseStatus::Staged)).unwrap();
499 s.save(&make_record("v3.0.0", ReleaseStatus::Published)).unwrap();
500 assert_eq!(release_status(dir.path()).unwrap(), "3");
501 }
502}