1use std::path::{Path, PathBuf};
17use std::time::Instant;
18
19use anyhow::{bail, Context, Result};
20
21use crate::config::schema::WikiConfig;
22use crate::project::ProjectRoot;
23
24use super::{collect_wiki_pages, measure_coverage, measure_doc_info, measure_lint, DocInfoReport, LintReport, TimeReport, CoverageReport};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RepoEntry {
29 pub name: String,
31 pub url: Option<String>,
33 pub local: Option<PathBuf>,
35 pub commit: Option<String>,
38}
39
40#[derive(Debug, Clone, serde::Serialize)]
42pub struct RepoReport {
43 pub name: String,
44 pub coverage: CoverageReport,
45 pub doc_info: DocInfoReport,
46 pub lint: LintReport,
47 pub time: TimeReport,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub error: Option<String>,
51}
52
53#[derive(Debug, Clone, serde::Serialize)]
55pub struct ManifestReport {
56 pub generated_at: String,
58 pub repos: Vec<RepoReport>,
59}
60
61pub fn parse_manifest(path: &Path) -> Result<Vec<RepoEntry>> {
70 let content = std::fs::read_to_string(path)
71 .with_context(|| format!("读取清单文件失败: {}", path.display()))?;
72 let manifest_dir = path.parent().unwrap_or_else(|| Path::new("."));
73 let mut entries = Vec::new();
74 for (idx, raw) in content.lines().enumerate() {
75 let line = raw.trim();
76 if line.is_empty() || line.starts_with('#') {
77 continue;
78 }
79 let (name, target, commit) = match line.split_once('|') {
81 None => (String::new(), line.to_string(), None),
82 Some(_) => {
83 let mut parts = line.splitn(3, '|').map(str::trim);
84 let n = parts.next().unwrap_or("").to_string();
85 let t = parts.next().unwrap_or("").to_string();
86 let c = parts.next().map(str::to_string);
87 (n, t, c)
88 }
89 };
90 if target.is_empty() {
91 bail!("清单第 {} 行解析失败(缺少仓库路径): {line}", idx + 1);
92 }
93 let entry = if target.starts_with("http://")
95 || target.starts_with("https://")
96 || target.starts_with("git@")
97 {
98 let name = if name.is_empty() {
99 target
101 .trim_end_matches('/')
102 .rsplit(['/', ':'])
103 .next()
104 .unwrap_or("unknown")
105 .trim_end_matches(".git")
106 .to_string()
107 } else {
108 name.to_string()
109 };
110 RepoEntry { name, url: Some(target.to_string()), local: None, commit }
111 } else {
112 let name = if name.is_empty() {
113 target
117 .rsplit(['/', '\\'])
118 .next()
119 .unwrap_or("unknown")
120 .to_string()
121 } else {
122 name
123 };
124 let is_win_drive = target.len() >= 3
128 && target.as_bytes()[1] == b':'
129 && (target.as_bytes()[2] == b'\\' || target.as_bytes()[2] == b'/')
130 && target.as_bytes()[0].is_ascii_alphabetic();
131 let p = PathBuf::from(target);
134 let abs = if p.is_absolute() || is_win_drive {
135 p
136 } else {
137 manifest_dir.join(p)
138 };
139 RepoEntry { name, url: None, local: Some(abs), commit }
140 };
141 if entry.name.is_empty() {
142 bail!("清单第 {} 行解析失败(仓库名为空): {line}", idx + 1);
143 }
144 entries.push(entry);
145 }
146 Ok(entries)
147}
148
149pub fn run_manifest(
156 entries: &[RepoEntry],
157 template_config: &WikiConfig,
158 work_dir: &Path,
159) -> Result<ManifestReport> {
160 std::fs::create_dir_all(work_dir)
161 .with_context(|| format!("创建 work_dir 失败: {}", work_dir.display()))?;
162
163 let template_path = work_dir.join("template-config.toml");
165 std::fs::write(&template_path, toml::to_string_pretty(template_config)?)
166 .with_context(|| "写模板配置文件失败")?;
167
168 let mut repos = Vec::with_capacity(entries.len());
169 for entry in entries {
170 let start = Instant::now();
171 let repo_path = match (&entry.url, &entry.local) {
173 (Some(url), _) => {
174 let dest = work_dir.join(&entry.name);
175 if !dest.exists()
176 && let Err(e) = git2::Repository::clone(url, &dest)
177 {
178 repos.push(RepoReport {
179 name: entry.name.clone(),
180 coverage: empty_coverage(),
181 doc_info: empty_doc_info(),
182 lint: empty_lint(),
183 time: empty_time(),
184 error: Some(format!("clone 失败: {e}")),
185 });
186 continue;
187 }
188 dest
189 }
190 (None, Some(local)) => {
191 if !local.is_dir() {
192 repos.push(RepoReport {
193 name: entry.name.clone(),
194 coverage: empty_coverage(),
195 doc_info: empty_doc_info(),
196 lint: empty_lint(),
197 time: empty_time(),
198 error: Some(format!("本地路径不存在: {}", local.display())),
199 });
200 continue;
201 }
202 local.clone()
203 }
204 (None, None) => {
205 repos.push(RepoReport {
206 name: entry.name.clone(),
207 coverage: empty_coverage(),
208 doc_info: empty_doc_info(),
209 lint: empty_lint(),
210 time: empty_time(),
211 error: Some("清单条目既无 url 也无 local".to_string()),
212 });
213 continue;
214 }
215 };
216
217 if let Some(commit) = &entry.commit
220 && let Err(e) = checkout_commit(&repo_path, commit)
221 {
222 repos.push(RepoReport {
223 name: entry.name.clone(),
224 coverage: empty_coverage(),
225 doc_info: empty_doc_info(),
226 lint: empty_lint(),
227 time: empty_time(),
228 error: Some(format!("checkout {commit} 失败: {e}")),
229 });
230 continue;
231 }
232
233 let out_dir = work_dir.join(format!("{}-out", entry.name));
235 let root = ProjectRoot::new(repo_path);
236 let res = crate::run_pipeline(
237 Some(&template_path),
238 Some(&out_dir),
239 true, &root,
241 &crate::GenerationMode::Full,
242 );
243 let result = match res {
244 Ok(_) => {
245 let pages = collect_wiki_pages(&out_dir);
246 let cov = measure_coverage(&root, &pages);
247 let doc_info = measure_doc_info(&pages);
248 let lint = measure_lint(&out_dir, &root);
249 RepoReport {
250 name: entry.name.clone(),
251 coverage: cov.unwrap_or_else(|e| {
252 RepoReport {
253 name: entry.name.clone(),
254 coverage: empty_coverage(),
255 doc_info: empty_doc_info(),
256 lint: empty_lint(),
257 time: empty_time(),
258 error: Some(format!("测量失败: {e}")),
259 }
260 .coverage
261 }),
262 doc_info,
263 lint,
264 time: TimeReport {
265 scan_ms: 0,
266 generate_ms: 0,
267 total_ms: start.elapsed().as_millis() as u64,
268 },
269 error: None,
270 }
271 }
272 Err(e) => RepoReport {
273 name: entry.name.clone(),
274 coverage: empty_coverage(),
275 doc_info: empty_doc_info(),
276 lint: empty_lint(),
277 time: empty_time(),
278 error: Some(format!("生成失败: {e}")),
279 },
280 };
281 repos.push(result);
282 }
283
284 Ok(ManifestReport {
285 generated_at: chrono::Utc::now().to_rfc3339(),
286 repos,
287 })
288}
289
290fn empty_coverage() -> CoverageReport {
291 CoverageReport { total_entities: 0, covered_entities: 0, ratio: 0.0 }
292}
293fn empty_doc_info() -> DocInfoReport {
294 DocInfoReport {
295 pages: 0,
296 words: 0,
297 cross_references: 0,
298 code_blocks: 0,
299 diagrams: 0,
300 llm_judged: false,
301 llm_score: 0.0,
302 llm_judged_modules: 0,
303 llm_abstain_modules: 0,
304 }
305}
306fn empty_lint() -> LintReport {
307 LintReport { total_issues: 0, by_kind: Default::default() }
308}
309fn empty_time() -> TimeReport {
310 TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 }
311}
312
313pub fn render_manifest_markdown(report: &ManifestReport) -> String {
315 let mut out = String::new();
316 out.push_str(&format!("# 清单跑分报告({} 仓库)\n\n", report.repos.len()));
317 out.push_str(&format!("> 生成时间: {}\n\n", report.generated_at));
318 out.push_str("| 仓库 | 实体 | 覆盖 | 页面 | 词数 | 交叉引用 | 代码块 | 图 | lint 问题 | 耗时(ms) |\n");
319 out.push_str("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n");
320 for r in &report.repos {
321 if let Some(err) = &r.error {
322 out.push_str(&format!("| {} | — | — | — | — | — | — | — | **失败**: {err} | — |\n", r.name));
323 } else {
324 out.push_str(&format!(
325 "| {} | {} | {:.1}% | {} | {} | {} | {} | {} | {} | {} |\n",
326 r.name,
327 r.coverage.total_entities,
328 r.coverage.ratio * 100.0,
329 r.doc_info.pages,
330 r.doc_info.words,
331 r.doc_info.cross_references,
332 r.doc_info.code_blocks,
333 r.doc_info.diagrams,
334 r.lint.total_issues,
335 r.time.total_ms,
336 ));
337 }
338 }
339 out.push('\n');
340 out
341}
342
343fn checkout_commit(repo_path: &Path, commit: &str) -> Result<()> {
351 let repo = git2::Repository::open(repo_path)
352 .with_context(|| format!("打开仓库失败: {}", repo_path.display()))?;
353 let obj = repo
354 .revparse_single(commit)
355 .with_context(|| format!("commit 无法解析: {commit}"))?;
356 repo.checkout_tree(&obj, None)
357 .with_context(|| format!("checkout 树失败: {commit}"))?;
358 repo.set_head_detached(obj.id())
359 .with_context(|| "设置 HEAD 失败".to_string())?;
360 Ok(())
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
369 fn test_parse_manifest_formats() {
370 let dir = std::env::temp_dir().join(format!("rw_manifest_parse_{}", std::process::id()));
371 std::fs::create_dir_all(&dir).unwrap();
372 let manifest = dir.join("manifest.txt");
373 std::fs::write(
374 &manifest,
375 "# 注释行\n\nD:\\tmp\\repo-a\nhttps://github.com/owner/repo-b.git\n命名仓库 | ./local-c\n钉死版本 | https://github.com/owner/repo-d.git | abc1234\n",
376 )
377 .unwrap();
378 let entries = parse_manifest(&manifest).unwrap();
379 assert_eq!(entries.len(), 4);
380 assert_eq!(entries[0].name, "repo-a");
381 assert_eq!(entries[0].local.as_deref(), Some(Path::new("D:\\tmp\\repo-a")));
382 assert_eq!(entries[0].commit, None);
383 assert_eq!(entries[1].name, "repo-b");
384 assert_eq!(entries[1].url.as_deref(), Some("https://github.com/owner/repo-b.git"));
385 assert_eq!(entries[2].name, "命名仓库");
386 assert_eq!(entries[3].name, "钉死版本");
387 assert_eq!(entries[3].url.as_deref(), Some("https://github.com/owner/repo-d.git"));
388 assert_eq!(entries[3].commit.as_deref(), Some("abc1234"));
389 let _ = std::fs::remove_dir_all(&dir);
390 }
391
392 #[test]
394 fn test_checkout_commit_pins_worktree() {
395 use git2::Repository;
396 let base = std::env::temp_dir().join(format!("rw_manifest_checkout_{}", std::process::id()));
397 std::fs::create_dir_all(&base).unwrap();
398 let repo = Repository::init(&base).unwrap();
399 repo.config().unwrap().set_bool("core.autocrlf", false).unwrap();
403 let sig = git2::Signature::now("t", "t@t").unwrap();
404 let mut idx = repo.index().unwrap();
405 std::fs::write(base.join("f.txt"), "v1\n").unwrap();
406 idx.add_path(Path::new("f.txt")).unwrap();
407 idx.write().unwrap();
408 let tree1 = idx.write_tree().unwrap();
409 repo.commit(Some("HEAD"), &sig, &sig, "first", &repo.find_tree(tree1).unwrap(), &[]).unwrap();
410 let c1 = repo.head().unwrap().peel_to_commit().unwrap().id();
411 std::fs::write(base.join("f.txt"), "v2\n").unwrap();
412 let mut idx = repo.index().unwrap();
413 idx.add_path(Path::new("f.txt")).unwrap();
414 idx.write().unwrap();
415 let tree2 = idx.write_tree().unwrap();
416 repo.commit(Some("HEAD"), &sig, &sig, "second", &repo.find_tree(tree2).unwrap(), &[&repo.find_commit(c1).unwrap()]).unwrap();
417 assert_eq!(std::fs::read_to_string(base.join("f.txt")).unwrap(), "v2\n");
418 checkout_commit(&base, &c1.to_string()).unwrap();
419 assert_eq!(std::fs::read_to_string(base.join("f.txt")).unwrap(), "v1\n", "钉死后工作树应为第一 commit 内容");
420 assert!(checkout_commit(&base, "deadbeef00").is_err(), "不存在的 commit 应报错");
421 let _ = std::fs::remove_dir_all(&base);
422 }
423
424 #[test]
426 fn test_run_manifest_smoke() {
427 let base = std::env::temp_dir().join(format!("rw_manifest_run_{}", std::process::id()));
428 std::fs::create_dir_all(&base).unwrap();
429 for name in ["repo-a", "repo-b"] {
431 let r = base.join(name);
432 std::fs::create_dir_all(r.join("src")).unwrap();
433 std::fs::write(r.join("src").join("main.rs"), "pub fn alpha() {}\n").unwrap();
434 }
435 let manifest_path = base.join("manifest.txt");
436 std::fs::write(
437 &manifest_path,
438 format!("{}\n{}\n{}\n", base.join("repo-a").display(), base.join("repo-b").display(), base.join("missing-repo").display()),
439 )
440 .unwrap();
441
442 let template = WikiConfig {
443 llm: crate::config::schema::LlmSection {
444 provider: crate::config::schema::LlmProviderType::Mock,
445 ..Default::default()
446 },
447 ..Default::default()
448 };
449 let work_dir = base.join("work");
450 let entries = parse_manifest(&manifest_path).unwrap();
451 let report = run_manifest(&entries, &template, &work_dir).unwrap();
452 assert_eq!(report.repos.len(), 3);
453 assert_eq!(report.repos[0].name, "repo-a");
454 assert!(report.repos[0].error.is_none(), "repo-a 应成功: {:?}", report.repos[0].error);
455 assert_eq!(report.repos[0].coverage.total_entities, 1, "应解析出 alpha");
456 assert!(report.repos[0].doc_info.pages > 0, "mock 生成后应有产物页");
457 assert!(report.repos[2].error.is_some(), "缺失路径应标注失败");
458 let md = render_manifest_markdown(&report);
459 assert!(md.contains("| repo-a |"), "矩阵应含 repo-a 行: {md}");
460 assert!(md.contains("**失败**"), "矩阵应含失败标注: {md}");
461 let _ = std::fs::remove_dir_all(&base);
462 }
463}