use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{bail, Context, Result};
use crate::config::schema::WikiConfig;
use crate::project::ProjectRoot;
use super::{collect_wiki_pages, measure_coverage, measure_doc_info, measure_lint, DocInfoReport, LintReport, TimeReport, CoverageReport};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoEntry {
pub name: String,
pub url: Option<String>,
pub local: Option<PathBuf>,
pub commit: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct RepoReport {
pub name: String,
pub coverage: CoverageReport,
pub doc_info: DocInfoReport,
pub lint: LintReport,
pub time: TimeReport,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ManifestReport {
pub generated_at: String,
pub repos: Vec<RepoReport>,
}
pub fn parse_manifest(path: &Path) -> Result<Vec<RepoEntry>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("读取清单文件失败: {}", path.display()))?;
let manifest_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut entries = Vec::new();
for (idx, raw) in content.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (name, target, commit) = match line.split_once('|') {
None => (String::new(), line.to_string(), None),
Some(_) => {
let mut parts = line.splitn(3, '|').map(str::trim);
let n = parts.next().unwrap_or("").to_string();
let t = parts.next().unwrap_or("").to_string();
let c = parts.next().map(str::to_string);
(n, t, c)
}
};
if target.is_empty() {
bail!("清单第 {} 行解析失败(缺少仓库路径): {line}", idx + 1);
}
let entry = if target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("git@")
{
let name = if name.is_empty() {
target
.trim_end_matches('/')
.rsplit(['/', ':'])
.next()
.unwrap_or("unknown")
.trim_end_matches(".git")
.to_string()
} else {
name.to_string()
};
RepoEntry { name, url: Some(target.to_string()), local: None, commit }
} else {
let name = if name.is_empty() {
target
.rsplit(['/', '\\'])
.next()
.unwrap_or("unknown")
.to_string()
} else {
name
};
let is_win_drive = target.len() >= 3
&& target.as_bytes()[1] == b':'
&& (target.as_bytes()[2] == b'\\' || target.as_bytes()[2] == b'/')
&& target.as_bytes()[0].is_ascii_alphabetic();
let p = PathBuf::from(target);
let abs = if p.is_absolute() || is_win_drive {
p
} else {
manifest_dir.join(p)
};
RepoEntry { name, url: None, local: Some(abs), commit }
};
if entry.name.is_empty() {
bail!("清单第 {} 行解析失败(仓库名为空): {line}", idx + 1);
}
entries.push(entry);
}
Ok(entries)
}
pub fn run_manifest(
entries: &[RepoEntry],
template_config: &WikiConfig,
work_dir: &Path,
) -> Result<ManifestReport> {
std::fs::create_dir_all(work_dir)
.with_context(|| format!("创建 work_dir 失败: {}", work_dir.display()))?;
let template_path = work_dir.join("template-config.toml");
std::fs::write(&template_path, toml::to_string_pretty(template_config)?)
.with_context(|| "写模板配置文件失败")?;
let mut repos = Vec::with_capacity(entries.len());
for entry in entries {
let start = Instant::now();
let repo_path = match (&entry.url, &entry.local) {
(Some(url), _) => {
let dest = work_dir.join(&entry.name);
if !dest.exists()
&& let Err(e) = git2::Repository::clone(url, &dest)
{
repos.push(RepoReport {
name: entry.name.clone(),
coverage: empty_coverage(),
doc_info: empty_doc_info(),
lint: empty_lint(),
time: empty_time(),
error: Some(format!("clone 失败: {e}")),
});
continue;
}
dest
}
(None, Some(local)) => {
if !local.is_dir() {
repos.push(RepoReport {
name: entry.name.clone(),
coverage: empty_coverage(),
doc_info: empty_doc_info(),
lint: empty_lint(),
time: empty_time(),
error: Some(format!("本地路径不存在: {}", local.display())),
});
continue;
}
local.clone()
}
(None, None) => {
repos.push(RepoReport {
name: entry.name.clone(),
coverage: empty_coverage(),
doc_info: empty_doc_info(),
lint: empty_lint(),
time: empty_time(),
error: Some("清单条目既无 url 也无 local".to_string()),
});
continue;
}
};
if let Some(commit) = &entry.commit
&& let Err(e) = checkout_commit(&repo_path, commit)
{
repos.push(RepoReport {
name: entry.name.clone(),
coverage: empty_coverage(),
doc_info: empty_doc_info(),
lint: empty_lint(),
time: empty_time(),
error: Some(format!("checkout {commit} 失败: {e}")),
});
continue;
}
let out_dir = work_dir.join(format!("{}-out", entry.name));
let root = ProjectRoot::new(repo_path);
let res = crate::run_pipeline(
Some(&template_path),
Some(&out_dir),
true, &root,
&crate::GenerationMode::Full,
);
let result = match res {
Ok(_) => {
let pages = collect_wiki_pages(&out_dir);
let cov = measure_coverage(&root, &pages);
let doc_info = measure_doc_info(&pages);
let lint = measure_lint(&out_dir, &root);
RepoReport {
name: entry.name.clone(),
coverage: cov.unwrap_or_else(|e| {
RepoReport {
name: entry.name.clone(),
coverage: empty_coverage(),
doc_info: empty_doc_info(),
lint: empty_lint(),
time: empty_time(),
error: Some(format!("测量失败: {e}")),
}
.coverage
}),
doc_info,
lint,
time: TimeReport {
scan_ms: 0,
generate_ms: 0,
total_ms: start.elapsed().as_millis() as u64,
},
error: None,
}
}
Err(e) => RepoReport {
name: entry.name.clone(),
coverage: empty_coverage(),
doc_info: empty_doc_info(),
lint: empty_lint(),
time: empty_time(),
error: Some(format!("生成失败: {e}")),
},
};
repos.push(result);
}
Ok(ManifestReport {
generated_at: chrono::Utc::now().to_rfc3339(),
repos,
})
}
fn empty_coverage() -> CoverageReport {
CoverageReport { total_entities: 0, covered_entities: 0, ratio: 0.0 }
}
fn empty_doc_info() -> DocInfoReport {
DocInfoReport {
pages: 0,
words: 0,
cross_references: 0,
code_blocks: 0,
diagrams: 0,
llm_judged: false,
llm_score: 0.0,
llm_judged_modules: 0,
llm_abstain_modules: 0,
}
}
fn empty_lint() -> LintReport {
LintReport { total_issues: 0, by_kind: Default::default() }
}
fn empty_time() -> TimeReport {
TimeReport { scan_ms: 0, generate_ms: 0, total_ms: 0 }
}
pub fn render_manifest_markdown(report: &ManifestReport) -> String {
let mut out = String::new();
out.push_str(&format!("# 清单跑分报告({} 仓库)\n\n", report.repos.len()));
out.push_str(&format!("> 生成时间: {}\n\n", report.generated_at));
out.push_str("| 仓库 | 实体 | 覆盖 | 页面 | 词数 | 交叉引用 | 代码块 | 图 | lint 问题 | 耗时(ms) |\n");
out.push_str("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n");
for r in &report.repos {
if let Some(err) = &r.error {
out.push_str(&format!("| {} | — | — | — | — | — | — | — | **失败**: {err} | — |\n", r.name));
} else {
out.push_str(&format!(
"| {} | {} | {:.1}% | {} | {} | {} | {} | {} | {} | {} |\n",
r.name,
r.coverage.total_entities,
r.coverage.ratio * 100.0,
r.doc_info.pages,
r.doc_info.words,
r.doc_info.cross_references,
r.doc_info.code_blocks,
r.doc_info.diagrams,
r.lint.total_issues,
r.time.total_ms,
));
}
}
out.push('\n');
out
}
fn checkout_commit(repo_path: &Path, commit: &str) -> Result<()> {
let repo = git2::Repository::open(repo_path)
.with_context(|| format!("打开仓库失败: {}", repo_path.display()))?;
let obj = repo
.revparse_single(commit)
.with_context(|| format!("commit 无法解析: {commit}"))?;
repo.checkout_tree(&obj, None)
.with_context(|| format!("checkout 树失败: {commit}"))?;
repo.set_head_detached(obj.id())
.with_context(|| "设置 HEAD 失败".to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_manifest_formats() {
let dir = std::env::temp_dir().join(format!("rw_manifest_parse_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let manifest = dir.join("manifest.txt");
std::fs::write(
&manifest,
"# 注释行\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",
)
.unwrap();
let entries = parse_manifest(&manifest).unwrap();
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].name, "repo-a");
assert_eq!(entries[0].local.as_deref(), Some(Path::new("D:\\tmp\\repo-a")));
assert_eq!(entries[0].commit, None);
assert_eq!(entries[1].name, "repo-b");
assert_eq!(entries[1].url.as_deref(), Some("https://github.com/owner/repo-b.git"));
assert_eq!(entries[2].name, "命名仓库");
assert_eq!(entries[3].name, "钉死版本");
assert_eq!(entries[3].url.as_deref(), Some("https://github.com/owner/repo-d.git"));
assert_eq!(entries[3].commit.as_deref(), Some("abc1234"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_checkout_commit_pins_worktree() {
use git2::Repository;
let base = std::env::temp_dir().join(format!("rw_manifest_checkout_{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let repo = Repository::init(&base).unwrap();
repo.config().unwrap().set_bool("core.autocrlf", false).unwrap();
let sig = git2::Signature::now("t", "t@t").unwrap();
let mut idx = repo.index().unwrap();
std::fs::write(base.join("f.txt"), "v1\n").unwrap();
idx.add_path(Path::new("f.txt")).unwrap();
idx.write().unwrap();
let tree1 = idx.write_tree().unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "first", &repo.find_tree(tree1).unwrap(), &[]).unwrap();
let c1 = repo.head().unwrap().peel_to_commit().unwrap().id();
std::fs::write(base.join("f.txt"), "v2\n").unwrap();
let mut idx = repo.index().unwrap();
idx.add_path(Path::new("f.txt")).unwrap();
idx.write().unwrap();
let tree2 = idx.write_tree().unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "second", &repo.find_tree(tree2).unwrap(), &[&repo.find_commit(c1).unwrap()]).unwrap();
assert_eq!(std::fs::read_to_string(base.join("f.txt")).unwrap(), "v2\n");
checkout_commit(&base, &c1.to_string()).unwrap();
assert_eq!(std::fs::read_to_string(base.join("f.txt")).unwrap(), "v1\n", "钉死后工作树应为第一 commit 内容");
assert!(checkout_commit(&base, "deadbeef00").is_err(), "不存在的 commit 应报错");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn test_run_manifest_smoke() {
let base = std::env::temp_dir().join(format!("rw_manifest_run_{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
for name in ["repo-a", "repo-b"] {
let r = base.join(name);
std::fs::create_dir_all(r.join("src")).unwrap();
std::fs::write(r.join("src").join("main.rs"), "pub fn alpha() {}\n").unwrap();
}
let manifest_path = base.join("manifest.txt");
std::fs::write(
&manifest_path,
format!("{}\n{}\n{}\n", base.join("repo-a").display(), base.join("repo-b").display(), base.join("missing-repo").display()),
)
.unwrap();
let template = WikiConfig {
llm: crate::config::schema::LlmSection {
provider: crate::config::schema::LlmProviderType::Mock,
..Default::default()
},
..Default::default()
};
let work_dir = base.join("work");
let entries = parse_manifest(&manifest_path).unwrap();
let report = run_manifest(&entries, &template, &work_dir).unwrap();
assert_eq!(report.repos.len(), 3);
assert_eq!(report.repos[0].name, "repo-a");
assert!(report.repos[0].error.is_none(), "repo-a 应成功: {:?}", report.repos[0].error);
assert_eq!(report.repos[0].coverage.total_entities, 1, "应解析出 alpha");
assert!(report.repos[0].doc_info.pages > 0, "mock 生成后应有产物页");
assert!(report.repos[2].error.is_some(), "缺失路径应标注失败");
let md = render_manifest_markdown(&report);
assert!(md.contains("| repo-a |"), "矩阵应含 repo-a 行: {md}");
assert!(md.contains("**失败**"), "矩阵应含失败标注: {md}");
let _ = std::fs::remove_dir_all(&base);
}
}