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