qtcloud_devops_cli/release/
util.rs1use 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 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 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}