1use std::collections::HashMap;
4use std::io::Read;
5use std::path::Path;
6
7use anyhow::{Context, Result};
8
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct GenerationState {
15 pub last_commit_hash: Option<String>,
17 pub file_fingerprints: HashMap<String, String>,
19 pub generated_at: String,
21 #[serde(default)]
23 pub doc_fingerprints: HashMap<String, String>,
24 #[serde(default)]
29 pub doc_modules: HashMap<String, String>,
30 #[serde(default)]
32 pub protected_docs: Vec<String>,
33 #[serde(default)]
41 pub tool_version: Option<String>,
42 #[serde(default)]
51 pub failed_modules: Vec<String>,
52}
53
54impl GenerationState {
55 pub fn load(state_dir: &Path) -> Result<Self> {
57 let state_path = state_dir.join("generation_state.json");
58 let file = std::fs::File::open(&state_path)
59 .with_context(|| format!("打开状态文件失败: {}", state_path.display()))?;
60 let reader = std::io::BufReader::new(file);
61 let state: GenerationState = serde_json::from_reader(reader)
62 .with_context(|| "解析状态文件 JSON 失败")?;
63 Ok(state)
64 }
65
66 pub fn save(&self, state_dir: &Path) -> Result<()> {
77 std::fs::create_dir_all(state_dir)
78 .with_context(|| format!("创建状态目录失败: {}", state_dir.display()))?;
79 let state_path = state_dir.join("generation_state.json");
80
81 let mut obj = serde_json::Map::new();
82 obj.insert("last_commit_hash".into(), serde_json::to_value(&self.last_commit_hash)?);
83 obj.insert("file_fingerprints".into(), sorted_json_object(&self.file_fingerprints)?);
84 obj.insert("generated_at".into(), serde_json::to_value(&self.generated_at)?);
85 obj.insert("doc_fingerprints".into(), sorted_json_object(&self.doc_fingerprints)?);
86 obj.insert("doc_modules".into(), sorted_json_object(&self.doc_modules)?);
87 obj.insert("protected_docs".into(), serde_json::to_value(&self.protected_docs)?);
88 obj.insert("tool_version".into(), serde_json::to_value(&self.tool_version)?);
89 obj.insert("failed_modules".into(), serde_json::to_value(&self.failed_modules)?);
90
91 let content = serde_json::to_string_pretty(&serde_json::Value::Object(obj))?;
92 crate::fs::write_file_atomic(&state_path, &content)
93 }
94
95 pub fn preserve_protection(&mut self, old: &GenerationState) {
106 if self.protected_docs.is_empty() {
107 self.protected_docs = old.protected_docs.clone();
108 }
109 if self.doc_fingerprints.is_empty() {
110 self.doc_fingerprints = old.doc_fingerprints.clone();
111 }
112 if self.doc_modules.is_empty() {
113 self.doc_modules = old.doc_modules.clone();
114 }
115 }
116
117 pub fn from_insights(
123 root: &crate::project::ProjectRoot,
124 insights: &[crate::ingest::parser::FileInsight],
125 commit_hash: &str,
126 ) -> Result<Self> {
127 let mut file_fingerprints = HashMap::new();
128
129 for insight in insights {
130 let path_str = insight.path.to_string_lossy().to_string();
131 let abs = root.path().join(&insight.path);
132 match Self::compute_file_fingerprint(&abs) {
133 Ok(fp) => {
134 file_fingerprints.insert(path_str, fp);
135 }
136 Err(e) => {
137 tracing::warn!("计算文件指纹失败 {}: {}", abs.display(), e);
138 }
139 }
140 }
141
142 Ok(Self {
143 last_commit_hash: Some(commit_hash.to_string()),
144 file_fingerprints,
145 doc_fingerprints: HashMap::new(),
146 doc_modules: HashMap::new(),
147 protected_docs: Vec::new(),
148 generated_at: chrono::Utc::now().to_rfc3339(),
149 tool_version: Some(env!("CARGO_PKG_VERSION").to_string()),
150 failed_modules: Vec::new(),
151 })
152 }
153
154 pub fn compute_file_fingerprint(path: &Path) -> Result<String> {
158 let mut file = std::fs::File::open(path)
159 .with_context(|| format!("打开文件失败: {}", path.display()))?;
160 let mut buffer = Vec::new();
161 file.read_to_end(&mut buffer)
162 .with_context(|| format!("读取文件失败: {}", path.display()))?;
163 Ok(sha256_hex(&buffer))
164 }
165
166 pub fn record_doc_fingerprints(
183 docs: &[crate::model::WikiDocument],
184 cards: &[crate::model::KnowledgeCard],
185 output_dir: &Path,
186 languages: &[String],
187 ) -> Result<(HashMap<String, String>, HashMap<String, String>)> {
188 let mut fps = HashMap::new();
189 let mut modules = HashMap::new();
190 for lang in languages {
191 for doc in docs {
192 let doc_path = crate::output::wiki_page_path(output_dir, lang, doc);
193 if doc_path.exists() {
194 let fp = Self::compute_file_fingerprint(&doc_path)?;
195 fps.insert(doc_path.to_string_lossy().to_string(), fp);
196 if doc.kind == crate::model::DocumentKind::WikiPage {
198 modules.insert(
199 doc_path.to_string_lossy().to_string(),
200 doc.module_path.join("::"),
201 );
202 }
203 }
204 }
205 }
206 for lang in languages {
208 let api_path = crate::output::api_doc_path(output_dir, lang);
209 if api_path.exists() {
210 let fp = Self::compute_file_fingerprint(&api_path)?;
211 fps.insert(api_path.to_string_lossy().to_string(), fp);
212 }
213 }
214 if let Some(primary) = languages.first() {
215 let overview_path = crate::output::overview_doc_path(output_dir, primary);
216 if overview_path.exists() {
217 let fp = Self::compute_file_fingerprint(&overview_path)?;
218 fps.insert(overview_path.to_string_lossy().to_string(), fp);
219 }
220 }
221 let toc_path = crate::output::toc_doc_path(output_dir);
222 if toc_path.exists() {
223 let fp = Self::compute_file_fingerprint(&toc_path)?;
224 fps.insert(toc_path.to_string_lossy().to_string(), fp);
225 }
226 for lang in languages {
229 for card in cards {
230 let card_path = crate::output::card_page_path(output_dir, lang, &card.module_name);
231 if card_path.exists() {
232 let fp = Self::compute_file_fingerprint(&card_path)?;
233 fps.insert(card_path.to_string_lossy().to_string(), fp);
234 modules.insert(
235 card_path.to_string_lossy().to_string(),
236 card.module_name.clone(),
237 );
238 }
239 }
240 }
241 Ok((fps, modules))
242 }
243
244 pub fn detect_manually_modified(&self) -> Vec<String> {
251 let mut modified = Vec::new();
252 for (path, fp) in &self.doc_fingerprints {
253 let p = Path::new(path);
254 if !p.is_file() {
255 continue;
257 }
258 match Self::compute_file_fingerprint(p) {
259 Ok(cur) => {
260 if &cur != fp {
261 modified.push(path.clone());
262 }
263 }
264 Err(e) => {
265 tracing::warn!("文档指纹读取失败,保守计入保护集: {}: {}", path, e);
266 modified.push(path.clone());
267 }
268 }
269 }
270 modified
271 }
272
273 pub fn is_file_changed(&self, root: &crate::project::ProjectRoot, path: &Path) -> Result<bool> {
279 let path_str = path.to_string_lossy().to_string();
280 let old_fingerprint = match self.file_fingerprints.get(&path_str) {
281 Some(fp) => fp,
282 None => return Ok(true), };
284
285 let new_fingerprint = Self::compute_file_fingerprint(&root.path().join(path))?;
286 Ok(&new_fingerprint != old_fingerprint)
287 }
288}
289
290fn sha256_hex(data: &[u8]) -> String {
292 use sha2::{Digest, Sha256};
293 let mut hasher = Sha256::new();
294 hasher.update(data);
295 hex::encode(hasher.finalize())
296}
297
298fn sorted_json_object(map: &HashMap<String, String>) -> Result<serde_json::Value> {
301 let mut sorted: Vec<(&String, &String)> = map.iter().collect();
302 sorted.sort_by(|a, b| a.0.cmp(b.0));
303 let mut obj = serde_json::Map::new();
304 for (k, v) in sorted {
305 obj.insert(k.clone(), serde_json::to_value(v)?);
306 }
307 Ok(serde_json::Value::Object(obj))
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::project::ProjectRoot;
314 use std::path::PathBuf;
315
316 #[test]
317 fn test_sha256_hex() {
318 let data = b"hello world";
319 let hash = sha256_hex(data);
320 assert_eq!(hash.len(), 64);
321 assert_eq!(
323 hash,
324 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
325 );
326 }
327
328 #[test]
329 fn test_state_save_load_roundtrip() {
330 let dir = std::env::temp_dir().join("code-repo-wiki-test-state");
331 let _ = std::fs::remove_dir_all(&dir);
332
333 let state = GenerationState {
334 last_commit_hash: Some("abc123".into()),
335 file_fingerprints: {
336 let mut m = HashMap::new();
337 m.insert("src/main.rs".into(), "deadbeef".into());
338 m
339 },
340 doc_fingerprints: HashMap::new(),
341 doc_modules: HashMap::new(),
342 protected_docs: Vec::new(),
343 generated_at: "2025-01-01T00:00:00Z".into(),
344 tool_version: None,
345 failed_modules: vec!["src::output".into(), "tests::edge".into()],
346 };
347
348 state.save(&dir).unwrap();
349 assert!(dir.join("generation_state.json").exists());
350
351 let loaded = GenerationState::load(&dir).unwrap();
352 assert_eq!(loaded.last_commit_hash, Some("abc123".into()));
353 assert_eq!(
354 loaded.file_fingerprints.get("src/main.rs").unwrap(),
355 "deadbeef"
356 );
357 assert_eq!(loaded.failed_modules, vec!["src::output", "tests::edge"]);
361
362 let _ = std::fs::remove_dir_all(&dir);
363 }
364
365 #[test]
366 fn test_is_file_changed() {
367 let dir = std::env::temp_dir().join("code-repo-wiki-test-fingerprint");
368 let _ = std::fs::create_dir_all(&dir);
369 let file_path = dir.join("test.txt");
370 std::fs::write(&file_path, "hello").unwrap();
371
372 let state = GenerationState {
373 last_commit_hash: None,
374 file_fingerprints: {
375 let mut m = HashMap::new();
376 m.insert(
377 file_path.to_string_lossy().to_string(),
378 GenerationState::compute_file_fingerprint(&file_path).unwrap(),
379 );
380 m
381 },
382 doc_fingerprints: HashMap::new(),
383 doc_modules: HashMap::new(),
384 protected_docs: Vec::new(),
385 generated_at: String::new(),
386 tool_version: None,
387 failed_modules: vec![],
388 };
389
390 assert!(!state.is_file_changed(&ProjectRoot::new(dir.clone()), &file_path).unwrap());
391
392 std::fs::write(&file_path, "world").unwrap();
394 assert!(state.is_file_changed(&ProjectRoot::new(dir.clone()), &file_path).unwrap());
395
396 let _ = std::fs::remove_dir_all(&dir);
397 }
398
399 #[test]
400 fn test_new_file_is_changed() {
401 let state = GenerationState {
402 last_commit_hash: None,
403 file_fingerprints: HashMap::new(),
404 doc_fingerprints: HashMap::new(),
405 doc_modules: HashMap::new(),
406 protected_docs: Vec::new(),
407 generated_at: String::new(),
408 tool_version: None,
409 failed_modules: vec![],
410 };
411
412 let path = PathBuf::from("nonexistent.rs");
413 assert!(state
415 .is_file_changed(&ProjectRoot::new(std::env::temp_dir()), &path)
416 .unwrap());
417 }
418
419 #[test]
421 fn test_record_doc_fingerprints_includes_cards() {
422 let dir = std::env::temp_dir()
423 .join(format!("code_repo_wiki_test_card_fp_{}", std::process::id()));
424 let _ = std::fs::remove_dir_all(&dir);
425
426 let card_path = dir.join("cards").join("zh").join("src_testmodule.md");
428 std::fs::create_dir_all(card_path.parent().unwrap()).unwrap();
429 std::fs::write(&card_path, "卡片内容").unwrap();
430 let wiki_path = dir.join("wiki").join("zh").join("src_testmodule.md");
431 std::fs::create_dir_all(wiki_path.parent().unwrap()).unwrap();
432 std::fs::write(&wiki_path, "页面内容").unwrap();
433
434 let doc = crate::model::WikiDocument {
435 title: "TestModule".into(),
436 kind: crate::model::DocumentKind::WikiPage,
437 content: String::new(),
438 language: "zh".into(),
439 module_path: vec!["src".into(), "testmodule".into()],
440 references: vec![],
441 last_updated: String::new(),
442 based_on_commit: None,
443 fingerprint: None,
444 };
445 let card = crate::model::KnowledgeCard {
446 module_name: "src::testmodule".into(),
447 module_type: "module".into(),
448 summary: String::new(),
449 key_entities: vec![],
450 dependencies: vec![],
451 dependents: vec![],
452 design_patterns: vec![],
453 todo_notes: vec![],
454 related_files: vec![],
455 coding_spec: None,
456 tech_stack: vec![],
457 architecture: None,
458 pending_manual_edits: vec![],
459 features: Vec::new(),
460 };
461
462 let (fps, modules) = GenerationState::record_doc_fingerprints(&[doc], &[card], &dir, &["zh".into()]).unwrap();
463 assert!(
464 fps.contains_key(&card_path.to_string_lossy().to_string()),
465 "已落盘的卡片应计入指纹(人工编辑后检测保护的前提)"
466 );
467 assert!(
468 fps.contains_key(&wiki_path.to_string_lossy().to_string()),
469 "wiki 页应计入指纹"
470 );
471 assert_eq!(
472 modules.get(&card_path.to_string_lossy().to_string()).map(String::as_str),
473 Some("src::testmodule"),
474 "卡片指纹应记录模块归属(反向同步的精确匹配依据)"
475 );
476 assert_eq!(
477 modules.get(&wiki_path.to_string_lossy().to_string()).map(String::as_str),
478 Some("src::testmodule"),
479 "wiki 页指纹应记录模块归属(module_path 连接规则)"
480 );
481
482 let missing_card = crate::model::KnowledgeCard {
484 module_name: "src::missing".into(),
485 module_type: "module".into(),
486 summary: String::new(),
487 key_entities: vec![],
488 dependencies: vec![],
489 dependents: vec![],
490 design_patterns: vec![],
491 todo_notes: vec![],
492 related_files: vec![],
493 coding_spec: None,
494 tech_stack: vec![],
495 architecture: None,
496 pending_manual_edits: vec![],
497 features: Vec::new(),
498 };
499
500 let (fps2, modules2) = GenerationState::record_doc_fingerprints(&[], &[missing_card], &dir, &["zh".into()]).unwrap();
501 assert!(fps2.is_empty(), "文件不存在时不应记录指纹");
502 assert!(modules2.is_empty(), "文件不存在时不应记录模块归属");
503
504 let _ = std::fs::remove_dir_all(&dir);
505 }
506
507 #[test]
510 fn test_preserve_protection_merges_from_old() {
511 let old = GenerationState {
512 last_commit_hash: Some("old".into()),
513 file_fingerprints: HashMap::new(),
514 doc_fingerprints: HashMap::from([("a.md".to_string(), "fp".to_string())]),
515 doc_modules: HashMap::from([("a.md".to_string(), "src".to_string())]),
516 protected_docs: vec!["a.md".to_string()],
517 generated_at: String::new(),
518 tool_version: None,
519 failed_modules: vec![],
520 };
521 let mut fresh = GenerationState {
522 last_commit_hash: Some("new".into()),
523 file_fingerprints: HashMap::new(),
524 doc_fingerprints: HashMap::new(),
525 doc_modules: HashMap::new(),
526 protected_docs: vec![],
527 generated_at: String::new(),
528 tool_version: None,
529 failed_modules: vec![],
530 };
531 fresh.preserve_protection(&old);
532 assert_eq!(fresh.protected_docs, vec!["a.md"]);
533 assert_eq!(fresh.doc_fingerprints.get("a.md").map(String::as_str), Some("fp"));
534 assert_eq!(fresh.doc_modules.get("a.md").map(String::as_str), Some("src"));
535 assert_eq!(fresh.last_commit_hash.as_deref(), Some("new"));
537 }
538
539 #[test]
541 fn test_preserve_protection_keeps_new_when_present() {
542 let old = GenerationState {
543 last_commit_hash: None,
544 file_fingerprints: HashMap::new(),
545 doc_fingerprints: HashMap::from([("old.md".to_string(), "old".to_string())]),
546 doc_modules: HashMap::new(),
547 protected_docs: vec!["old.md".to_string()],
548 generated_at: String::new(),
549 tool_version: None,
550 failed_modules: vec![],
551 };
552 let mut fresh = GenerationState {
553 last_commit_hash: None,
554 file_fingerprints: HashMap::new(),
555 doc_fingerprints: HashMap::from([("new.md".to_string(), "new".to_string())]),
556 doc_modules: HashMap::new(),
557 protected_docs: vec!["new.md".to_string()],
558 generated_at: String::new(),
559 tool_version: None,
560 failed_modules: vec![],
561 };
562 fresh.preserve_protection(&old);
563 assert_eq!(fresh.protected_docs, vec!["new.md"], "新状态保护字段非空时应保留新值");
564 assert_eq!(fresh.doc_fingerprints.get("new.md").map(String::as_str), Some("new"));
565 assert!(!fresh.doc_fingerprints.contains_key("old.md"));
566 }
567
568 #[test]
572 fn test_save_is_byte_deterministic() {
573 let dir = std::env::temp_dir()
574 .join(format!("code_repo_wiki_test_state_deterministic_{}", std::process::id()));
575 let _ = std::fs::remove_dir_all(&dir);
576
577 let mut file_fps = HashMap::new();
579 file_fps.insert("z.rs".into(), "z-fp".into());
580 file_fps.insert("a/b.rs".into(), "b-fp".into());
581 file_fps.insert("m.rs".into(), "m-fp".into());
582 let mut doc_fps = HashMap::new();
583 doc_fps.insert("wiki/zh/zz.md".into(), "1".into());
584 doc_fps.insert("wiki/zh/aa.md".into(), "2".into());
585 let mut doc_mods = HashMap::new();
586 doc_mods.insert("wiki/zh/zz.md".into(), "z".into());
587 doc_mods.insert("wiki/zh/aa.md".into(), "a".into());
588
589 let state = GenerationState {
590 last_commit_hash: Some("abc".into()),
591 file_fingerprints: file_fps,
592 doc_fingerprints: doc_fps,
593 doc_modules: doc_mods,
594 protected_docs: vec!["wiki/zh/aa.md".into()],
595 generated_at: "2026-01-01T00:00:00Z".into(),
596 tool_version: None,
597 failed_modules: vec![],
598 };
599
600 state.save(&dir).unwrap();
601 let bytes1 = std::fs::read(dir.join("generation_state.json")).unwrap();
602
603 let dir2 = dir.join("again");
605 state.save(&dir2).unwrap();
606 let bytes2 = std::fs::read(dir2.join("generation_state.json")).unwrap();
607
608 assert_eq!(
609 bytes1, bytes2,
610 "同一状态两次 save 必须字节一致(HashMap 迭代序不得泄漏到序列化输出)"
611 );
612
613 let loaded = GenerationState::load(&dir).unwrap();
615 assert_eq!(loaded.file_fingerprints.get("z.rs").map(String::as_str), Some("z-fp"));
616 assert_eq!(loaded.file_fingerprints.get("a/b.rs").map(String::as_str), Some("b-fp"));
617 assert_eq!(loaded.doc_modules.get("wiki/zh/aa.md").map(String::as_str), Some("a"));
618
619 let _ = std::fs::remove_dir_all(&dir);
620 }
621
622 #[cfg(windows)]
625 #[test]
626 fn test_detect_manually_modified_read_failure_is_protected() {
627 use std::os::windows::fs::OpenOptionsExt;
628
629 let dir = std::env::temp_dir()
630 .join(format!("code_repo_wiki_test_detect_readfail_{}", std::process::id()));
631 let _ = std::fs::remove_dir_all(&dir);
632 std::fs::create_dir_all(&dir).unwrap();
633 let locked = dir.join("locked.md");
634 std::fs::write(&locked, "content").unwrap();
635
636 let _lock = std::fs::OpenOptions::new()
638 .read(true)
639 .share_mode(0)
640 .open(&locked)
641 .expect("独占打开应成功");
642
643 let state = GenerationState {
644 last_commit_hash: None,
645 file_fingerprints: HashMap::new(),
646 doc_fingerprints: HashMap::from([(
647 locked.to_string_lossy().to_string(),
648 "旧指纹".to_string(),
649 )]),
650 doc_modules: HashMap::new(),
651 protected_docs: Vec::new(),
652 generated_at: String::new(),
653 tool_version: None,
654 failed_modules: vec![],
655 };
656
657 let modified = state.detect_manually_modified();
658 assert!(
659 modified.iter().any(|p| Path::new(p) == locked.as_path()),
660 "指纹读取失败的文件应保守计入保护集(否则人工修改会被覆盖): {:?}",
661 modified
662 );
663
664 let _ = std::fs::remove_dir_all(&dir);
665 }
666
667 #[test]
669 fn test_detect_manually_modified_regular_branches() {
670 let dir = std::env::temp_dir()
671 .join(format!("code_repo_wiki_test_detect_regular_{}", std::process::id()));
672 let _ = std::fs::remove_dir_all(&dir);
673 std::fs::create_dir_all(&dir).unwrap();
674
675 let unchanged = dir.join("unchanged.md");
676 std::fs::write(&unchanged, "原样").unwrap();
677 let edited = dir.join("edited.md");
678 std::fs::write(&edited, "原样").unwrap();
679
680 let state = GenerationState {
681 last_commit_hash: None,
682 file_fingerprints: HashMap::new(),
683 doc_fingerprints: HashMap::from([
684 (
685 unchanged.to_string_lossy().to_string(),
686 GenerationState::compute_file_fingerprint(&unchanged).unwrap(),
687 ),
688 (
690 edited.to_string_lossy().to_string(),
691 "definitely-not-matching".to_string(),
692 ),
693 (dir.join("missing.md").to_string_lossy().to_string(), "x".to_string()),
695 ]),
696 doc_modules: HashMap::new(),
697 protected_docs: Vec::new(),
698 generated_at: String::new(),
699 tool_version: None,
700 failed_modules: vec![],
701 };
702
703 std::fs::write(&edited, "被人改了").unwrap();
704
705 let modified = state.detect_manually_modified();
706 assert_eq!(modified.len(), 1, "只有内容不符的文件应计入: {:?}", modified);
707 assert!(modified.iter().any(|p| Path::new(p) == edited.as_path()));
708
709 let _ = std::fs::remove_dir_all(&dir);
710 }
711}