1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::contract;
5
6pub fn status(repo_path: &Path) {
7 let mut stdout = std::io::stdout();
8 status_to(&mut stdout, repo_path).ok();
9}
10
11pub fn status_to(writer: &mut impl std::io::Write, repo_path: &Path) -> std::io::Result<()> {
12 let scopes_map = load_scopes_map(repo_path);
13 let latest_tags = get_latest_tags_by_scope(repo_path);
14 let dirty = is_dirty(repo_path);
15
16 let other_scope_dirs: Vec<std::path::PathBuf> = scopes_map
17 .iter()
18 .filter(|(k, _)| *k != "(root)")
19 .map(|(_, v)| repo_path.join(v))
20 .collect();
21
22 writeln!(writer, "发布状态")?;
23 writeln!(writer, "{}", "─".repeat(40))?;
24
25 if latest_tags.is_empty() {
26 writeln!(writer, " 最新标签: (无)")?;
27 return Ok(());
28 }
29
30 for (scope, tag) in &latest_tags {
31 let tag_only = tag.split('/').last().unwrap_or(tag);
32 let ver = tag_only.strip_prefix('v').unwrap_or(tag_only);
33
34 let scope_dir = if scope == "(root)" {
35 repo_path.to_path_buf()
36 } else {
37 match scopes_map.get(scope) {
38 Some(rel) => repo_path.join(rel),
39 None => {
40 let d = repo_path.join(scope);
41 if d.is_dir() {
42 d
43 } else {
44 repo_path.to_path_buf()
45 }
46 }
47 }
48 };
49
50 writeln!(writer, " [{}]", scope)?;
51 let rel_path = scopes_map.get(scope).cloned().unwrap_or_else(|| {
52 if scope == "(root)" {
53 ".".to_string()
54 } else {
55 scope.clone()
56 }
57 });
58 writeln!(writer, " 路径: {}", rel_path)?;
59 writeln!(writer, " 最新标签: {}", tag)?;
60
61 let unreleased = count_unreleased_in_dir(repo_path, tag, &scope_dir);
62 writeln!(writer, " 未发布提交: {}", unreleased)?;
63
64 if check_changelog(&scope_dir, ver) {
65 writeln!(writer, " CHANGELOG: ✅")?;
66 } else {
67 writeln!(writer, " CHANGELOG: ❌ 缺少 {} 条目", ver)?;
68 }
69
70 check_github_release(writer, repo_path, tag, &scope_dir, ver)?;
71 check_all_configs(writer, &scope_dir, &other_scope_dirs, ver)?;
72 }
73
74 if dirty {
75 writeln!(writer, " 工作区: ❌ 有未提交变更")?;
76 } else {
77 writeln!(writer, " 工作区: ✅ 干净")?;
78 }
79
80 Ok(())
81}
82
83fn check_github_release(
85 writer: &mut impl std::io::Write,
86 repo_path: &Path,
87 tag: &str,
88 scope_dir: &Path,
89 _version: &str,
90) -> std::io::Result<()> {
91 let repo = get_github_repo(repo_path);
93 let repo = match repo {
94 Some(r) => r,
95 None => return Ok(()),
96 };
97
98 let out = std::process::Command::new("gh")
100 .args([
101 "release", "view", tag, "--repo", &repo, "--json", "body", "--jq", ".body",
102 ])
103 .output()
104 .ok();
105
106 let body = match out {
107 Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
108 _ => {
109 writeln!(writer, " GitHub Release: ❌ 不存在")?;
110 return Ok(());
111 }
112 };
113
114 let changelog_path = scope_dir.join("CHANGELOG.md");
116 let notes = super::util::extract_notes(tag, &changelog_path);
117 let notes = notes.unwrap_or_default();
118
119 if body == notes {
120 writeln!(writer, " GitHub Release: ✅ body 与 CHANGELOG 一致")?;
121 } else if body.trim().is_empty() {
122 writeln!(writer, " GitHub Release: ⚠️ body 为空")?;
123 } else if notes.is_empty() {
124 writeln!(
125 writer,
126 " GitHub Release: ✅ 已创建 (CHANGELOG 无此版本条目)"
127 )?;
128 } else {
129 writeln!(writer, " GitHub Release: ⚠️ body 与 CHANGELOG 不同步")?;
130 }
131
132 Ok(())
133}
134
135fn load_scopes_map(repo_path: &Path) -> HashMap<String, String> {
137 let mut map: HashMap<String, String> = contract::load_scopes(repo_path)
138 .into_iter()
139 .map(|s| (s.name, s.dir))
140 .collect();
141 if !map.contains_key("(root)") {
142 map.insert("(root)".to_string(), "".to_string());
143 }
144 map
145}
146
147fn get_latest_tags_by_scope(repo_path: &Path) -> Vec<(String, String)> {
148 let repo = match git2::Repository::open(repo_path) {
149 Ok(r) => r,
150 Err(_) => return vec![],
151 };
152 let tag_names = match repo.tag_names(None) {
153 Ok(t) => t,
154 Err(_) => return vec![],
155 };
156 let mut tags: Vec<&str> = tag_names.iter().flatten().collect();
157 tags.sort_by(|a, b| b.cmp(a));
158 collect_latest_tags(&tags)
159}
160
161pub fn collect_latest_tags(tags: &[&str]) -> Vec<(String, String)> {
162 let mut scopes: Vec<(String, String)> = Vec::new();
163 for t in tags {
164 let scope = if t.contains('/') {
165 t.split('/').next().unwrap_or("").to_string()
166 } else {
167 "(root)".to_string()
168 };
169 if !scopes.iter().any(|(s, _)| s == &scope) {
170 scopes.push((scope, t.to_string()));
171 }
172 }
173 scopes
174}
175
176fn count_unreleased_in_dir(repo_path: &Path, tag: &str, scope_dir: &Path) -> usize {
177 if is_git_repo(scope_dir) {
179 return count_unreleased_in_submodule(scope_dir, tag);
180 }
181 let repo = match git2::Repository::open(repo_path) {
182 Ok(r) => r,
183 Err(_) => return 0,
184 };
185 let tag_ref = format!("refs/tags/{}", tag);
186 let tag_oid = match repo.find_reference(&tag_ref).ok().and_then(|r| r.target()) {
187 Some(t) => t,
188 None => return 0,
189 };
190 let head_oid = match repo.head().ok().and_then(|h| h.target()) {
191 Some(t) => t,
192 None => return 0,
193 };
194 let mut revwalk = match repo.revwalk() {
195 Ok(w) => w,
196 Err(_) => return 0,
197 };
198 if revwalk.push(head_oid).is_err() || revwalk.hide(tag_oid).is_err() {
199 return 0;
200 }
201 if scope_dir == repo_path {
202 return revwalk.count();
203 }
204 let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
205 let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
206 revwalk
207 .filter_map(|oid| oid.ok())
208 .filter(|oid| {
209 if let Ok(commit) = repo.find_commit(*oid) {
210 if let Ok(tree) = commit.tree() {
211 tree.iter().any(|entry| {
212 entry.name().map_or(false, |n| {
213 n == &rel_str || n.starts_with(&format!("{}/", rel_str))
214 })
215 })
216 } else {
217 false
218 }
219 } else {
220 false
221 }
222 })
223 .count()
224}
225
226fn is_git_repo(path: &Path) -> bool {
228 let git_dir = path.join(".git");
229 git_dir.is_dir() || git_dir.is_file()
230}
231
232fn count_unreleased_in_submodule(submodule_path: &Path, tag: &str) -> usize {
234 let repo = match git2::Repository::open(submodule_path) {
235 Ok(r) => r,
236 Err(_) => return 0,
237 };
238 let tag_ref = format!("refs/tags/{}", tag);
239 let tag_oid = match repo.find_reference(&tag_ref).ok().and_then(|r| r.target()) {
240 Some(t) => t,
241 None => return 0,
242 };
243 let head_oid = match repo.head().ok().and_then(|h| h.target()) {
244 Some(t) => t,
245 None => return 0,
246 };
247 let mut revwalk = match repo.revwalk() {
248 Ok(w) => w,
249 Err(_) => return 0,
250 };
251 if revwalk.push(head_oid).is_err() || revwalk.hide(tag_oid).is_err() {
252 return 0;
253 }
254 revwalk.count()
255}
256
257fn get_github_repo(repo_path: &Path) -> Option<String> {
258 let repo = git2::Repository::open(repo_path).ok()?;
259 let remote = repo.find_remote("origin").ok()?;
260 let url = remote.url()?;
261 let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
262 let caps = re.captures(url)?;
263 Some(caps.get(1)?.as_str().to_string())
264}
265
266fn check_all_configs(
267 writer: &mut impl std::io::Write,
268 repo_path: &Path,
269 other_scope_dirs: &[std::path::PathBuf],
270 expected: &str,
271) -> std::io::Result<()> {
272 let checks: [(&str, fn(&str) -> Option<String>); 5] = [
273 ("Cargo.toml", |c| extract_kv(c, "version")),
274 ("pyproject.toml", |c| extract_kv(c, "version")),
275 ("package.json", extract_json_version),
276 ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
277 ("setup.cfg", |c| extract_kv(c, "version")),
278 ];
279 for (name, extract) in &checks {
280 let content = match std::fs::read_to_string(&repo_path.join(name)) {
281 Ok(c) => c,
282 Err(_) => continue,
283 };
284 match extract(&content) {
285 Some(v) if v == expected => {
286 writeln!(writer, " {:<15} {} ✅", format!("{}:", name), v)?
287 }
288 Some(v) => writeln!(
289 writer,
290 " {:<15} {} ❌ (期望 {})",
291 format!("{}:", name),
292 v,
293 expected
294 )?,
295 None => writeln!(writer, " {:<15} (未找到版本字段)", format!("{}:", name))?,
296 }
297 }
298 let vf = repo_path.join("VERSION");
299 if let Ok(c) = std::fs::read_to_string(&vf) {
300 let v = c.trim().to_string();
301 if !v.is_empty() {
302 if v == expected {
303 writeln!(writer, " VERSION {} ✅", v)?;
304 } else {
305 writeln!(writer, " VERSION {} ❌ (期望 {})", v, expected)?;
306 }
307 }
308 }
309 for p in find_go_files(repo_path, other_scope_dirs) {
310 let content = match std::fs::read_to_string(&p) {
311 Ok(c) => c,
312 Err(_) => continue,
313 };
314 for prefix in &[
315 "var Version = \"",
316 "var VERSION = \"",
317 "const Version = \"",
318 "const VERSION = \"",
319 ] {
320 for line in content.lines() {
321 let t = line.trim();
322 if let Some(rest) = t.strip_prefix(prefix) {
323 if let Some(end) = rest.find('"') {
324 let v = rest[..end].to_string();
325 if !v.is_empty() {
326 let rel = p.strip_prefix(repo_path).unwrap_or(&p);
327 let name = rel.to_string_lossy();
328 if v == expected {
329 writeln!(writer, " {:<15} {} ✅", format!("{}:", name), v)?;
330 } else {
331 writeln!(
332 writer,
333 " {:<15} {} ❌ (期望 {})",
334 format!("{}:", name),
335 v,
336 expected
337 )?;
338 }
339 }
340 }
341 }
342 }
343 }
344 }
345 Ok(())
346}
347
348fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
349 let mut files = Vec::new();
350 let entries = match std::fs::read_dir(dir) {
351 Ok(e) => e,
352 Err(_) => return files,
353 };
354 for entry in entries.flatten() {
355 let p = entry.path();
356 if p.is_dir() {
357 if excludes.iter().any(|e| p == *e) {
358 continue;
359 }
360 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
361 if !name.starts_with('.')
362 && name != "node_modules"
363 && name != "target"
364 && name != "vendor"
365 {
366 files.extend(find_go_files(&p, excludes));
367 }
368 } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
369 files.push(p);
370 }
371 }
372 files
373}
374
375fn extract_kv(content: &str, key: &str) -> Option<String> {
376 let p1 = format!("{} = \"", key);
377 let p2 = format!("{} = '", key);
378 for line in content.lines() {
379 let t = line.trim();
380 if let Some(r) = t.strip_prefix(&p1) {
381 if let Some(e) = r.find('"') {
382 let v = r[..e].to_string();
383 if !v.is_empty() {
384 return Some(v);
385 }
386 }
387 }
388 if let Some(r) = t.strip_prefix(&p2) {
389 if let Some(e) = r.find('\'') {
390 let v = r[..e].to_string();
391 if !v.is_empty() {
392 return Some(v);
393 }
394 }
395 }
396 }
397 None
398}
399
400fn extract_json_version(content: &str) -> Option<String> {
401 for line in content.lines() {
402 let t = line.trim();
403 if let Some(pos) = t.find("\"version\":") {
405 let after_colon = t[pos + "\"version\":".len()..].trim();
406 let value_start = after_colon.find('"')?;
408 let after_open = &after_colon[value_start + 1..];
409 let value_end = after_open.find('"')?;
411 let v = &after_open[..value_end];
412 if !v.is_empty() {
413 return Some(v.to_string());
414 }
415 }
416 }
417 None
418}
419
420fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
421 let p = format!("{}:", key);
422 for line in content.lines() {
423 let t = line.trim();
424 if let Some(r) = t.strip_prefix(&p) {
425 let v = r.trim();
426 if !v.is_empty() && !v.starts_with('#') {
427 return Some(v.to_string());
428 }
429 }
430 }
431 None
432}
433
434fn check_changelog(repo_path: &Path, version: &str) -> bool {
435 if version.is_empty() {
436 return false;
437 }
438 std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
439 .unwrap_or_default()
440 .contains(&format!("[{}]", version))
441}
442
443fn is_dirty(repo_path: &Path) -> bool {
444 let repo = match git2::Repository::open(repo_path) {
445 Ok(r) => r,
446 Err(_) => return false,
447 };
448 repo.statuses(None).map_or(false, |s| !s.is_empty())
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
458 fn test_extract_kv_double_quotes() {
459 assert_eq!(
460 extract_kv("version = \"1.0.0\"\n", "version"),
461 Some("1.0.0".into())
462 );
463 }
464
465 #[test]
466 fn test_extract_kv_single_quotes() {
467 assert_eq!(
468 extract_kv("version = '2.0.0'\n", "version"),
469 Some("2.0.0".into())
470 );
471 }
472
473 #[test]
474 fn test_extract_kv_missing_key() {
475 assert_eq!(extract_kv("name = \"foo\"\n", "version"), None);
476 }
477
478 #[test]
479 fn test_extract_kv_empty_value() {
480 assert_eq!(extract_kv("version = \"\"\n", "version"), None);
481 }
482
483 #[test]
484 fn test_extract_kv_indented() {
485 assert_eq!(
486 extract_kv(" version = \"0.5.0\"\n", "version"),
487 Some("0.5.0".into())
488 );
489 }
490
491 #[test]
494 fn test_extract_json_version_normal() {
495 let content = "{\n \"version\": \"1.0.0\",\n}\n";
496 assert_eq!(extract_json_version(content), Some("1.0.0".into()));
497 }
498
499 #[test]
500 fn test_extract_json_version_single_line() {
501 let content = r#"{"name":"foo","version":"2.0.0"}"#;
502 assert_eq!(extract_json_version(content), Some("2.0.0".into()));
503 }
504
505 #[test]
506 fn test_extract_json_version_trailing_comma() {
507 let content = r#"{"version":"1.0.0",}"#;
508 assert_eq!(extract_json_version(content), Some("1.0.0".into()));
509 }
510
511 #[test]
512 fn test_extract_json_version_missing() {
513 let content = r#"{"name":"foo"}"#;
514 assert_eq!(extract_json_version(content), None);
515 }
516
517 #[test]
518 fn test_extract_json_version_empty() {
519 let content = r#"{"version":""}"#;
520 assert_eq!(extract_json_version(content), None);
521 }
522
523 #[test]
526 fn test_extract_kv_yaml_normal() {
527 assert_eq!(
528 extract_kv_yaml("version: 1.0.0\n", "version"),
529 Some("1.0.0".into())
530 );
531 }
532
533 #[test]
534 fn test_extract_kv_yaml_indented() {
535 assert_eq!(
536 extract_kv_yaml(" version: 3.0.0\n", "version"),
537 Some("3.0.0".into())
538 );
539 }
540
541 #[test]
542 fn test_extract_kv_yaml_ignores_comment() {
543 assert_eq!(extract_kv_yaml("version: # 注释\n", "version"), None);
544 }
545
546 #[test]
547 fn test_extract_kv_yaml_missing() {
548 assert_eq!(extract_kv_yaml("name: foo\n", "version"), None);
549 }
550
551 #[test]
552 fn test_extract_kv_yaml_empty_value() {
553 assert_eq!(extract_kv_yaml("version:\n", "version"), None);
554 }
555
556 #[test]
557 fn test_collect_tags_empty() {
558 assert!(collect_latest_tags(&[]).is_empty());
559 }
560
561 #[test]
562 fn test_collect_tags_root_only() {
563 let tags = collect_latest_tags(&["v2.0.0", "v1.0.0"]);
564 assert_eq!(tags.len(), 1);
565 assert_eq!(tags[0].0, "(root)");
566 assert_eq!(tags[0].1, "v2.0.0");
567 }
568
569 #[test]
570 fn test_collect_tags_scoped() {
571 let tags = collect_latest_tags(&["cli/v0.1.0", "web/v0.2.0"]);
572 assert_eq!(tags.len(), 2);
573 assert_eq!(tags[0].0, "cli");
574 assert_eq!(tags[1].0, "web");
575 }
576
577 #[test]
578 fn test_collect_tags_prerelease_is_kept() {
579 let tags = collect_latest_tags(&["cli/v0.2.0-rc.1", "cli/v0.1.0"]);
581 assert_eq!(tags.len(), 1);
582 assert_eq!(tags[0].1, "cli/v0.2.0-rc.1");
583 }
584
585 #[test]
586 fn test_collect_tags_prerelease_as_fallback() {
587 let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
588 assert_eq!(tags.len(), 1);
589 assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
590 }
591
592 fn with_mock_path<F: FnOnce(&Path) -> R, R>(scripts: &[(&str, &str)], f: F) -> R {
594 let dir = tempfile::tempdir().unwrap();
595 let bin = dir.path().join("bin");
596 std::fs::create_dir(&bin).unwrap();
597 for (name, body) in scripts {
598 let path = bin.join(name);
599 std::fs::write(&path, body).unwrap();
600 #[cfg(unix)]
601 std::process::Command::new("chmod")
602 .args(["+x", path.to_str().unwrap()])
603 .output()
604 .unwrap();
605 }
606 let old_path = std::env::var("PATH").unwrap_or_default();
607 std::env::set_var("PATH", format!("{}:{}", bin.display(), old_path));
608 let result = f(dir.path());
609 std::env::set_var("PATH", &old_path);
610 result
611 }
612
613 const GH_NOT_FOUND: &str = "#!/bin/sh\nexit 1\n";
614 const GH_WITH_BODY: &str = "#!/bin/sh\necho '{\"body\":\"content\"}'\n";
615
616 #[test]
617 fn test_status_gh_not_found() {
618 let dir = tempfile::tempdir().unwrap();
620 git_init_test(dir.path());
621 git_tag_test(dir.path(), "v1.0.0");
622 set_remote(dir.path());
623 with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
624 status(dir.path());
625 });
626 }
627
628 #[test]
629 fn test_status_gh_with_body() {
630 let dir = tempfile::tempdir().unwrap();
632 git_init_test(dir.path());
633 std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
634 git_commit_test(dir.path());
635 git_tag_test(dir.path(), "v1.0.0");
636 set_remote(dir.path());
637 with_mock_path(&[("gh", GH_WITH_BODY)], |_| {
638 status(dir.path());
639 });
640 }
641
642 #[test]
643 fn test_status_custom_tags() {
644 let dir = tempfile::tempdir().unwrap();
646 git_init_test(dir.path());
648 git_tag_test(dir.path(), "cli/v0.1.0");
649 git_tag_test(dir.path(), "web/v0.2.0");
650 set_remote(dir.path());
651 with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
652 status(dir.path());
653 });
654 }
655
656 fn git_init_test(path: &Path) {
659 std::process::Command::new("git")
660 .args(["init", "-b", "main"])
661 .current_dir(path)
662 .output()
663 .unwrap();
664 std::process::Command::new("git")
665 .args(["config", "user.email", "t@t"])
666 .current_dir(path)
667 .output()
668 .unwrap();
669 std::process::Command::new("git")
670 .args(["config", "user.name", "t"])
671 .current_dir(path)
672 .output()
673 .unwrap();
674 std::fs::write(path.join("f"), "").unwrap();
675 std::process::Command::new("git")
676 .args(["add", "."])
677 .current_dir(path)
678 .output()
679 .unwrap();
680 std::process::Command::new("git")
681 .args(["commit", "-m", "init"])
682 .current_dir(path)
683 .output()
684 .unwrap();
685 }
686
687 fn git_commit_test(path: &Path) {
688 std::fs::write(path.join("f"), "x").unwrap();
689 std::process::Command::new("git")
690 .args(["add", "."])
691 .current_dir(path)
692 .output()
693 .unwrap();
694 std::process::Command::new("git")
695 .args(["commit", "-m", "x"])
696 .current_dir(path)
697 .output()
698 .unwrap();
699 }
700
701 fn git_tag_test(path: &Path, tag: &str) {
702 std::process::Command::new("git")
703 .args(["-C", path.to_str().unwrap(), "tag", tag])
704 .output()
705 .unwrap();
706 }
707
708 fn set_remote(path: &Path) {
709 std::process::Command::new("git")
710 .args([
711 "-C",
712 path.to_str().unwrap(),
713 "remote",
714 "add",
715 "origin",
716 "https://github.com/owner/repo.git",
717 ])
718 .output()
719 .unwrap();
720 }
721
722 #[test]
723 fn test_collect_tags_mixed_root_and_scoped() {
724 let tags = collect_latest_tags(&["v1.0.0", "cli/v0.2.0", "cli/v0.1.0"]);
725 assert_eq!(tags.len(), 2);
726 let root = tags.iter().find(|(s, _)| s == "(root)").unwrap();
727 assert_eq!(root.1, "v1.0.0");
728 let cli = tags.iter().find(|(s, _)| s == "cli").unwrap();
729 assert_eq!(cli.1, "cli/v0.2.0");
730 }
731
732 #[test]
733 fn test_status_to_output() {
734 let d = tempfile::tempdir().unwrap();
735 std::process::Command::new("git")
737 .args(["init", "-b", "main"])
738 .current_dir(d.path())
739 .output()
740 .unwrap();
741 std::process::Command::new("git")
742 .args(["config", "user.email", "t@t"])
743 .current_dir(d.path())
744 .output()
745 .unwrap();
746 std::process::Command::new("git")
747 .args(["config", "user.name", "t"])
748 .current_dir(d.path())
749 .output()
750 .unwrap();
751 std::fs::write(d.path().join("f"), "").unwrap();
752 std::process::Command::new("git")
753 .args(["add", "."])
754 .current_dir(d.path())
755 .output()
756 .unwrap();
757 std::process::Command::new("git")
758 .args(["commit", "-m", "init"])
759 .current_dir(d.path())
760 .output()
761 .unwrap();
762 std::process::Command::new("git")
764 .args(["tag", "v1.0.0"])
765 .current_dir(d.path())
766 .output()
767 .unwrap();
768 std::fs::write(d.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
770
771 let mut buf = Vec::new();
772 let result = status_to(&mut buf, d.path());
773 assert!(result.is_ok(), "status_to 应成功: {:?}", result);
774 let out = String::from_utf8_lossy(&buf);
775 assert!(out.contains("发布状态"), "应包含标题");
776 assert!(out.contains("v1.0.0"), "应包含 tag 信息");
777 }
778}