use std::collections::HashMap;
use std::io::Read;
use std::path::Path;
use anyhow::{Context, Result};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GenerationState {
pub last_commit_hash: Option<String>,
pub file_fingerprints: HashMap<String, String>,
pub generated_at: String,
#[serde(default)]
pub doc_fingerprints: HashMap<String, String>,
#[serde(default)]
pub doc_modules: HashMap<String, String>,
#[serde(default)]
pub protected_docs: Vec<String>,
#[serde(default)]
pub tool_version: Option<String>,
#[serde(default)]
pub failed_modules: Vec<String>,
}
impl GenerationState {
pub fn load(state_dir: &Path) -> Result<Self> {
let state_path = state_dir.join("generation_state.json");
let file = std::fs::File::open(&state_path)
.with_context(|| format!("打开状态文件失败: {}", state_path.display()))?;
let reader = std::io::BufReader::new(file);
let state: GenerationState = serde_json::from_reader(reader)
.with_context(|| "解析状态文件 JSON 失败")?;
Ok(state)
}
pub fn save(&self, state_dir: &Path) -> Result<()> {
std::fs::create_dir_all(state_dir)
.with_context(|| format!("创建状态目录失败: {}", state_dir.display()))?;
let state_path = state_dir.join("generation_state.json");
let mut obj = serde_json::Map::new();
obj.insert("last_commit_hash".into(), serde_json::to_value(&self.last_commit_hash)?);
obj.insert("file_fingerprints".into(), sorted_json_object(&self.file_fingerprints)?);
obj.insert("generated_at".into(), serde_json::to_value(&self.generated_at)?);
obj.insert("doc_fingerprints".into(), sorted_json_object(&self.doc_fingerprints)?);
obj.insert("doc_modules".into(), sorted_json_object(&self.doc_modules)?);
obj.insert("protected_docs".into(), serde_json::to_value(&self.protected_docs)?);
obj.insert("tool_version".into(), serde_json::to_value(&self.tool_version)?);
obj.insert("failed_modules".into(), serde_json::to_value(&self.failed_modules)?);
let content = serde_json::to_string_pretty(&serde_json::Value::Object(obj))?;
crate::fs::write_file_atomic(&state_path, &content)
}
pub fn preserve_protection(&mut self, old: &GenerationState) {
if self.protected_docs.is_empty() {
self.protected_docs = old.protected_docs.clone();
}
if self.doc_fingerprints.is_empty() {
self.doc_fingerprints = old.doc_fingerprints.clone();
}
if self.doc_modules.is_empty() {
self.doc_modules = old.doc_modules.clone();
}
}
pub fn from_insights(
root: &crate::project::ProjectRoot,
insights: &[crate::ingest::parser::FileInsight],
commit_hash: &str,
) -> Result<Self> {
let mut file_fingerprints = HashMap::new();
for insight in insights {
let path_str = insight.path.to_string_lossy().to_string();
let abs = root.path().join(&insight.path);
match Self::compute_file_fingerprint(&abs) {
Ok(fp) => {
file_fingerprints.insert(path_str, fp);
}
Err(e) => {
tracing::warn!("计算文件指纹失败 {}: {}", abs.display(), e);
}
}
}
Ok(Self {
last_commit_hash: Some(commit_hash.to_string()),
file_fingerprints,
doc_fingerprints: HashMap::new(),
doc_modules: HashMap::new(),
protected_docs: Vec::new(),
generated_at: chrono::Utc::now().to_rfc3339(),
tool_version: Some(env!("CARGO_PKG_VERSION").to_string()),
failed_modules: Vec::new(),
})
}
pub fn compute_file_fingerprint(path: &Path) -> Result<String> {
let mut file = std::fs::File::open(path)
.with_context(|| format!("打开文件失败: {}", path.display()))?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)
.with_context(|| format!("读取文件失败: {}", path.display()))?;
Ok(sha256_hex(&buffer))
}
pub fn record_doc_fingerprints(
docs: &[crate::model::WikiDocument],
cards: &[crate::model::KnowledgeCard],
output_dir: &Path,
languages: &[String],
) -> Result<(HashMap<String, String>, HashMap<String, String>)> {
let mut fps = HashMap::new();
let mut modules = HashMap::new();
for lang in languages {
for doc in docs {
let doc_path = crate::output::wiki_page_path(output_dir, lang, doc);
if doc_path.exists() {
let fp = Self::compute_file_fingerprint(&doc_path)?;
fps.insert(doc_path.to_string_lossy().to_string(), fp);
if doc.kind == crate::model::DocumentKind::WikiPage {
modules.insert(
doc_path.to_string_lossy().to_string(),
doc.module_path.join("::"),
);
}
}
}
}
for lang in languages {
let api_path = crate::output::api_doc_path(output_dir, lang);
if api_path.exists() {
let fp = Self::compute_file_fingerprint(&api_path)?;
fps.insert(api_path.to_string_lossy().to_string(), fp);
}
}
if let Some(primary) = languages.first() {
let overview_path = crate::output::overview_doc_path(output_dir, primary);
if overview_path.exists() {
let fp = Self::compute_file_fingerprint(&overview_path)?;
fps.insert(overview_path.to_string_lossy().to_string(), fp);
}
}
let toc_path = crate::output::toc_doc_path(output_dir);
if toc_path.exists() {
let fp = Self::compute_file_fingerprint(&toc_path)?;
fps.insert(toc_path.to_string_lossy().to_string(), fp);
}
for lang in languages {
for card in cards {
let card_path = crate::output::card_page_path(output_dir, lang, &card.module_name);
if card_path.exists() {
let fp = Self::compute_file_fingerprint(&card_path)?;
fps.insert(card_path.to_string_lossy().to_string(), fp);
modules.insert(
card_path.to_string_lossy().to_string(),
card.module_name.clone(),
);
}
}
}
Ok((fps, modules))
}
pub fn detect_manually_modified(&self) -> Vec<String> {
let mut modified = Vec::new();
for (path, fp) in &self.doc_fingerprints {
let p = Path::new(path);
if !p.is_file() {
continue;
}
match Self::compute_file_fingerprint(p) {
Ok(cur) => {
if &cur != fp {
modified.push(path.clone());
}
}
Err(e) => {
tracing::warn!("文档指纹读取失败,保守计入保护集: {}: {}", path, e);
modified.push(path.clone());
}
}
}
modified
}
pub fn is_file_changed(&self, root: &crate::project::ProjectRoot, path: &Path) -> Result<bool> {
let path_str = path.to_string_lossy().to_string();
let old_fingerprint = match self.file_fingerprints.get(&path_str) {
Some(fp) => fp,
None => return Ok(true), };
let new_fingerprint = Self::compute_file_fingerprint(&root.path().join(path))?;
Ok(&new_fingerprint != old_fingerprint)
}
}
fn sha256_hex(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
fn sorted_json_object(map: &HashMap<String, String>) -> Result<serde_json::Value> {
let mut sorted: Vec<(&String, &String)> = map.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(b.0));
let mut obj = serde_json::Map::new();
for (k, v) in sorted {
obj.insert(k.clone(), serde_json::to_value(v)?);
}
Ok(serde_json::Value::Object(obj))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::ProjectRoot;
use std::path::PathBuf;
#[test]
fn test_sha256_hex() {
let data = b"hello world";
let hash = sha256_hex(data);
assert_eq!(hash.len(), 64);
assert_eq!(
hash,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn test_state_save_load_roundtrip() {
let dir = std::env::temp_dir().join("code-repo-wiki-test-state");
let _ = std::fs::remove_dir_all(&dir);
let state = GenerationState {
last_commit_hash: Some("abc123".into()),
file_fingerprints: {
let mut m = HashMap::new();
m.insert("src/main.rs".into(), "deadbeef".into());
m
},
doc_fingerprints: HashMap::new(),
doc_modules: HashMap::new(),
protected_docs: Vec::new(),
generated_at: "2025-01-01T00:00:00Z".into(),
tool_version: None,
failed_modules: vec!["src::output".into(), "tests::edge".into()],
};
state.save(&dir).unwrap();
assert!(dir.join("generation_state.json").exists());
let loaded = GenerationState::load(&dir).unwrap();
assert_eq!(loaded.last_commit_hash, Some("abc123".into()));
assert_eq!(
loaded.file_fingerprints.get("src/main.rs").unwrap(),
"deadbeef"
);
assert_eq!(loaded.failed_modules, vec!["src::output", "tests::edge"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_is_file_changed() {
let dir = std::env::temp_dir().join("code-repo-wiki-test-fingerprint");
let _ = std::fs::create_dir_all(&dir);
let file_path = dir.join("test.txt");
std::fs::write(&file_path, "hello").unwrap();
let state = GenerationState {
last_commit_hash: None,
file_fingerprints: {
let mut m = HashMap::new();
m.insert(
file_path.to_string_lossy().to_string(),
GenerationState::compute_file_fingerprint(&file_path).unwrap(),
);
m
},
doc_fingerprints: HashMap::new(),
doc_modules: HashMap::new(),
protected_docs: Vec::new(),
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
assert!(!state.is_file_changed(&ProjectRoot::new(dir.clone()), &file_path).unwrap());
std::fs::write(&file_path, "world").unwrap();
assert!(state.is_file_changed(&ProjectRoot::new(dir.clone()), &file_path).unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_new_file_is_changed() {
let state = GenerationState {
last_commit_hash: None,
file_fingerprints: HashMap::new(),
doc_fingerprints: HashMap::new(),
doc_modules: HashMap::new(),
protected_docs: Vec::new(),
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
let path = PathBuf::from("nonexistent.rs");
assert!(state
.is_file_changed(&ProjectRoot::new(std::env::temp_dir()), &path)
.unwrap());
}
#[test]
fn test_record_doc_fingerprints_includes_cards() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_card_fp_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let card_path = dir.join("cards").join("zh").join("src_testmodule.md");
std::fs::create_dir_all(card_path.parent().unwrap()).unwrap();
std::fs::write(&card_path, "卡片内容").unwrap();
let wiki_path = dir.join("wiki").join("zh").join("src_testmodule.md");
std::fs::create_dir_all(wiki_path.parent().unwrap()).unwrap();
std::fs::write(&wiki_path, "页面内容").unwrap();
let doc = crate::model::WikiDocument {
title: "TestModule".into(),
kind: crate::model::DocumentKind::WikiPage,
content: String::new(),
language: "zh".into(),
module_path: vec!["src".into(), "testmodule".into()],
references: vec![],
last_updated: String::new(),
based_on_commit: None,
fingerprint: None,
};
let card = crate::model::KnowledgeCard {
module_name: "src::testmodule".into(),
module_type: "module".into(),
summary: String::new(),
key_entities: vec![],
dependencies: vec![],
dependents: vec![],
design_patterns: vec![],
todo_notes: vec![],
related_files: vec![],
coding_spec: None,
tech_stack: vec![],
architecture: None,
pending_manual_edits: vec![],
features: Vec::new(),
};
let (fps, modules) = GenerationState::record_doc_fingerprints(&[doc], &[card], &dir, &["zh".into()]).unwrap();
assert!(
fps.contains_key(&card_path.to_string_lossy().to_string()),
"已落盘的卡片应计入指纹(人工编辑后检测保护的前提)"
);
assert!(
fps.contains_key(&wiki_path.to_string_lossy().to_string()),
"wiki 页应计入指纹"
);
assert_eq!(
modules.get(&card_path.to_string_lossy().to_string()).map(String::as_str),
Some("src::testmodule"),
"卡片指纹应记录模块归属(反向同步的精确匹配依据)"
);
assert_eq!(
modules.get(&wiki_path.to_string_lossy().to_string()).map(String::as_str),
Some("src::testmodule"),
"wiki 页指纹应记录模块归属(module_path 连接规则)"
);
let missing_card = crate::model::KnowledgeCard {
module_name: "src::missing".into(),
module_type: "module".into(),
summary: String::new(),
key_entities: vec![],
dependencies: vec![],
dependents: vec![],
design_patterns: vec![],
todo_notes: vec![],
related_files: vec![],
coding_spec: None,
tech_stack: vec![],
architecture: None,
pending_manual_edits: vec![],
features: Vec::new(),
};
let (fps2, modules2) = GenerationState::record_doc_fingerprints(&[], &[missing_card], &dir, &["zh".into()]).unwrap();
assert!(fps2.is_empty(), "文件不存在时不应记录指纹");
assert!(modules2.is_empty(), "文件不存在时不应记录模块归属");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_preserve_protection_merges_from_old() {
let old = GenerationState {
last_commit_hash: Some("old".into()),
file_fingerprints: HashMap::new(),
doc_fingerprints: HashMap::from([("a.md".to_string(), "fp".to_string())]),
doc_modules: HashMap::from([("a.md".to_string(), "src".to_string())]),
protected_docs: vec!["a.md".to_string()],
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
let mut fresh = GenerationState {
last_commit_hash: Some("new".into()),
file_fingerprints: HashMap::new(),
doc_fingerprints: HashMap::new(),
doc_modules: HashMap::new(),
protected_docs: vec![],
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
fresh.preserve_protection(&old);
assert_eq!(fresh.protected_docs, vec!["a.md"]);
assert_eq!(fresh.doc_fingerprints.get("a.md").map(String::as_str), Some("fp"));
assert_eq!(fresh.doc_modules.get("a.md").map(String::as_str), Some("src"));
assert_eq!(fresh.last_commit_hash.as_deref(), Some("new"));
}
#[test]
fn test_preserve_protection_keeps_new_when_present() {
let old = GenerationState {
last_commit_hash: None,
file_fingerprints: HashMap::new(),
doc_fingerprints: HashMap::from([("old.md".to_string(), "old".to_string())]),
doc_modules: HashMap::new(),
protected_docs: vec!["old.md".to_string()],
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
let mut fresh = GenerationState {
last_commit_hash: None,
file_fingerprints: HashMap::new(),
doc_fingerprints: HashMap::from([("new.md".to_string(), "new".to_string())]),
doc_modules: HashMap::new(),
protected_docs: vec!["new.md".to_string()],
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
fresh.preserve_protection(&old);
assert_eq!(fresh.protected_docs, vec!["new.md"], "新状态保护字段非空时应保留新值");
assert_eq!(fresh.doc_fingerprints.get("new.md").map(String::as_str), Some("new"));
assert!(!fresh.doc_fingerprints.contains_key("old.md"));
}
#[test]
fn test_save_is_byte_deterministic() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_state_deterministic_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut file_fps = HashMap::new();
file_fps.insert("z.rs".into(), "z-fp".into());
file_fps.insert("a/b.rs".into(), "b-fp".into());
file_fps.insert("m.rs".into(), "m-fp".into());
let mut doc_fps = HashMap::new();
doc_fps.insert("wiki/zh/zz.md".into(), "1".into());
doc_fps.insert("wiki/zh/aa.md".into(), "2".into());
let mut doc_mods = HashMap::new();
doc_mods.insert("wiki/zh/zz.md".into(), "z".into());
doc_mods.insert("wiki/zh/aa.md".into(), "a".into());
let state = GenerationState {
last_commit_hash: Some("abc".into()),
file_fingerprints: file_fps,
doc_fingerprints: doc_fps,
doc_modules: doc_mods,
protected_docs: vec!["wiki/zh/aa.md".into()],
generated_at: "2026-01-01T00:00:00Z".into(),
tool_version: None,
failed_modules: vec![],
};
state.save(&dir).unwrap();
let bytes1 = std::fs::read(dir.join("generation_state.json")).unwrap();
let dir2 = dir.join("again");
state.save(&dir2).unwrap();
let bytes2 = std::fs::read(dir2.join("generation_state.json")).unwrap();
assert_eq!(
bytes1, bytes2,
"同一状态两次 save 必须字节一致(HashMap 迭代序不得泄漏到序列化输出)"
);
let loaded = GenerationState::load(&dir).unwrap();
assert_eq!(loaded.file_fingerprints.get("z.rs").map(String::as_str), Some("z-fp"));
assert_eq!(loaded.file_fingerprints.get("a/b.rs").map(String::as_str), Some("b-fp"));
assert_eq!(loaded.doc_modules.get("wiki/zh/aa.md").map(String::as_str), Some("a"));
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(windows)]
#[test]
fn test_detect_manually_modified_read_failure_is_protected() {
use std::os::windows::fs::OpenOptionsExt;
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_detect_readfail_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let locked = dir.join("locked.md");
std::fs::write(&locked, "content").unwrap();
let _lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(0)
.open(&locked)
.expect("独占打开应成功");
let state = GenerationState {
last_commit_hash: None,
file_fingerprints: HashMap::new(),
doc_fingerprints: HashMap::from([(
locked.to_string_lossy().to_string(),
"旧指纹".to_string(),
)]),
doc_modules: HashMap::new(),
protected_docs: Vec::new(),
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
let modified = state.detect_manually_modified();
assert!(
modified.iter().any(|p| Path::new(p) == locked.as_path()),
"指纹读取失败的文件应保守计入保护集(否则人工修改会被覆盖): {:?}",
modified
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_detect_manually_modified_regular_branches() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_detect_regular_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let unchanged = dir.join("unchanged.md");
std::fs::write(&unchanged, "原样").unwrap();
let edited = dir.join("edited.md");
std::fs::write(&edited, "原样").unwrap();
let state = GenerationState {
last_commit_hash: None,
file_fingerprints: HashMap::new(),
doc_fingerprints: HashMap::from([
(
unchanged.to_string_lossy().to_string(),
GenerationState::compute_file_fingerprint(&unchanged).unwrap(),
),
(
edited.to_string_lossy().to_string(),
"definitely-not-matching".to_string(),
),
(dir.join("missing.md").to_string_lossy().to_string(), "x".to_string()),
]),
doc_modules: HashMap::new(),
protected_docs: Vec::new(),
generated_at: String::new(),
tool_version: None,
failed_modules: vec![],
};
std::fs::write(&edited, "被人改了").unwrap();
let modified = state.detect_manually_modified();
assert_eq!(modified.len(), 1, "只有内容不符的文件应计入: {:?}", modified);
assert!(modified.iter().any(|p| Path::new(p) == edited.as_path()));
let _ = std::fs::remove_dir_all(&dir);
}
}