Skip to main content

code_repo_wiki/incremental/
state.rs

1//! 生成状态持久化(单进程契约:本文件与 generation_state.json 无文件锁,
2//! 同一输出目录并发运行 code-repo-wiki 不被支持,最后写入者胜——见 README 限制项)
3use std::collections::HashMap;
4use std::io::Read;
5use std::path::Path;
6
7use anyhow::{Context, Result};
8
9/// 生成状态
10///
11/// 记录上一次生成时的 commit hash 和文件指纹,
12/// 用于增量更新时检测变更。
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct GenerationState {
15    /// 上一次生成时的 commit hash
16    pub last_commit_hash: Option<String>,
17    /// 文件路径 → SHA256 指纹
18    pub file_fingerprints: HashMap<String, String>,
19    /// 生成时间(ISO 8601)
20    pub generated_at: String,
21    /// 已生成文档路径 → SHA256 指纹(用于检测人工修改)
22    #[serde(default)]
23    pub doc_fingerprints: HashMap<String, String>,
24    /// 已生成文档路径 → 所属模块名(人工修改反向同步的精确归属依据;
25    /// 全局文档 api/overview/toc 无模块归属,不记录。模块名压平为
26    /// "::" 连接(module_path.join("::")),与卡片 module_name 同规则,
27    /// 精确匹配,杜绝 stem 匹配的下划线歧义)
28    #[serde(default)]
29    pub doc_modules: HashMap<String, String>,
30    /// 人工修改过的文档路径集合(保护集:下次自动更新不覆盖,直到 --force 清空)
31    #[serde(default)]
32    pub protected_docs: Vec<String>,
33    /// 生成时工具版本(v19 t01 版本自检依据)
34    ///
35    /// from_insights 写入 env!("CARGO_PKG_VERSION");doctor 读取并对比
36    /// 当前二进制版本,捕获「PATH 里的旧版二进制生成产物后又被新版
37    /// 调用」的静默漂移(旧版缺 doctor/dry-run,实测报 unrecognized
38    /// subcommand exit 2,用户无从知道产物是旧格式)。旧状态文件无此
39    /// 字段(serde default None)→ doctor 提示无法判断,不误报。
40    #[serde(default)]
41    pub tool_version: Option<String>,
42    /// 上次生成失败被隔离的模块名(卡片或页面生成失败,v22 修复)
43    ///
44    /// 失败隔离(generate_wiki_pages/generate_all_cards 的 record_failure)
45    /// 只跳过失败模块、不中断整体生成——但失败模块若源码不再变更将永远
46    /// 无法补生成(增量以 git diff 触发,失败模块不在 diff 中)。
47    /// 本轮生成结束时把失败模块写入状态,下次 update 时并入变更集重试;
48    /// no-op 快速判定(should_skip_noop)也因非空跳过。清空时机:
49    /// 成功重试(该模块生成成功)或全量生成(自然覆盖)。
50    #[serde(default)]
51    pub failed_modules: Vec<String>,
52}
53
54impl GenerationState {
55    /// 从状态目录加载生成状态
56    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    /// 保存生成状态到目录(原子写:fs::write_file_atomic 临时文件 +
67    /// rename 覆盖,防止崩溃留下半截 JSON——半截状态会被 load 判为
68    /// 损坏,进而触发调用方的 fail-loud 路径,见 lib.rs load_protection)
69    ///
70    /// 确定性序列化:file_fingerprints/doc_fingerprints/doc_modules 是
71    /// HashMap,迭代序随 RandomState 每次进程而异——直序列化会让状态
72    /// 文件字节级漂移(同样的内容两次写入字节不同,git diff 噪音且
73    /// 破坏"同输入同输出"的确定性契约)。写入前把三个 map 按键排序
74    /// 组装成 serde_json::Map,保证同状态同字节;load 反序列化对
75    /// JSON 对象键序不敏感,旧文件完全兼容。
76    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    /// 生成前中途存盘的保护字段保留(票 03)
96    ///
97    /// from_insights 构造的新状态只含 file_fingerprints/commit hash——
98    /// 若在 LLM 生成(流水线 Phase 3,最常见失败点)之前直接落盘,
99    /// doc_fingerprints/protected_docs/doc_modules 全空;一旦生成失败,
100    /// 磁盘状态即"无保护"版本,下次运行的人工修改保护失效。
101    /// 本方法把旧状态中的保护字段合并进新状态,使中途存盘只推进
102    /// 代码侧状态(commit hash/文件指纹),产物侧保护信息不因中途
103    /// 失败而丢失。新状态保护字段非空时保留新值(正常全量完成后
104    /// Phase 6 的最终保存不受影响)。
105    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    /// 从文件解析结果和 commit hash 构建新的生成状态
118    ///
119    /// root 注入:insight.path 是相对项目根的路径,指纹计算必须先与
120    /// root 拼接——直接相对 cwd 打开会随进程 cwd 漂移而错读(watch
121    /// 常驻进程的 cwd 漂移不再影响指纹基准)。
122    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    /// 计算文件的 SHA256 指纹
155    ///
156    /// 非 UTF-8 文件返回错误。
157    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    /// 记录所有已生成文档的 SHA256 指纹
167    ///
168    /// 路径与 render_all 实际写盘路径一致:`{output_dir}/wiki/{lang}/{file}.md`,
169    /// 文件名复用 output::wiki_page_path(ArchitectureOverview 特判写
170    /// architecture.md),languages 为主语言 + 扩展语言(与 output::wiki_languages 保持一致)。
171    /// 另补记三个全局文档(api.md / overview.md / _toc.md)的指纹,
172    /// 路径复用 output::api_doc_path / overview_doc_path / toc_doc_path,
173    /// 与 render_all 的保护判定路径同一规则产出。
174    /// 卡片同样计入指纹(路径 cards/{lang}/{module.replace("::","_")}.md,与
175    /// render_all 写盘路径一致),使人工编辑的卡片与 wiki 页一样被
176    /// detect_manually_modified 识别并纳入保护集,全量 generate 不再静默覆盖。
177    ///
178    /// 返回值:(文档指纹表, 文档模块归属表)。模块归属 = 产物路径 → 模块名
179    /// (wiki 页取 module_path.join("::"),卡片取 module_name;api/overview/toc
180    /// 全局文档无模块归属不记录),供人工修改反向同步的精确归属——
181    /// 精确匹配杜绝了 stem 匹配在模块名含下划线时的串卡片歧义。
182    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                    // 模块页记录归属;全局文档(Overview/Architecture)不记录
197                    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        // 全局文档指纹:api.md(每种语言一份)、overview.md(仅主语言)、_toc.md(输出目录根)
207        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        // 卡片指纹:所有语言目录下已落盘的卡片都计入(卡片实际写盘语言
227        // 取决于关联文档的 doc.language,与 render_all 的写盘路径一致)
228        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    /// 比对磁盘文档与生成时指纹,返回人工修改的文档路径集合
245    ///
246    /// 磁盘文件不存在时不视为人工修改(跳过保护);指纹读取失败时
247    /// **保守计入**修改集(保护优先)——宁可多保护一次也不让人工编辑
248    /// 内容在下次生成中被静默覆盖(读取失败通常伴随权限/IO 异常,
249    /// 此时无法确认磁盘内容是否仍是上次生成的原样)。
250    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                // 文件不存在(被删除或从未落盘):不构成"人工修改"
256                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    /// 检查文件是否已变更
274    ///
275    /// 如果文件不在指纹表中(新增文件)或指纹不匹配,返回 true。
276    /// root 注入:path 是相对项目根的路径,与 root 拼接后计算
277    /// (同 from_insights,不依赖进程 cwd)。
278    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), // 新文件
283        };
284
285        let new_fingerprint = Self::compute_file_fingerprint(&root.path().join(path))?;
286        Ok(&new_fingerprint != old_fingerprint)
287    }
288}
289
290/// 计算字节数组的 SHA256 十六进制字符串
291fn 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
298/// 把字符串→字符串 HashMap 按键排序序列化为 JSON 对象
299/// (save 的确定性序列化用:排序后的 serde_json::Map 保证输出字节稳定)
300fn 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        // SHA256("hello world") 的前几个字符
322        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        // v23 C 组防回归:失败模块必须随状态落盘(此前 lib.rs 顺序错误
358        // 导致恒为空数组,v22 补偿机制静默失效——实测全量 generate 的
359        // 3 个失败模块未被记录)
360        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        // 修改文件
393        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        // 新文件(不在指纹表中)应视为"已变更"
414        assert!(state
415            .is_file_changed(&ProjectRoot::new(std::env::temp_dir()), &path)
416            .unwrap());
417    }
418
419    /// A3:卡片指纹与 wiki 页指纹一起记录,人工编辑的卡片可被检测保护
420    #[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        // 预写 wiki 页与卡片(路径与 render_all 落盘一致)
427        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        // 未落盘的卡片(文件不存在)不计指纹,避免无中生有的保护
483        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    /// 票 03:preserve_protection 把旧状态保护字段合并进新状态
508    ///(生成前中途存盘时调用,防 LLM 失败后保护丢失)
509    #[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        // commit hash 不被旧值覆盖(只合并保护字段)
536        assert_eq!(fresh.last_commit_hash.as_deref(), Some("new"));
537    }
538
539    /// 票 03:新状态保护字段非空时不覆盖(最终保存语义优先)
540    #[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    /// B1:状态文件字节级确定性——同一状态两次 save 字节完全一致
569    /// (三个 HashMap 的迭代序随机,直序列化会漂移;save 按键排序后
570    /// 保证同状态同字节,防 git diff 噪音与确定性契约破坏)
571    #[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        // 故意乱序插入,且键序跨越不同前缀,确保排序逻辑真正生效
578        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        // 直接调用 save 到另一个目录再比对(同状态两次序列化)
604        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        // load 兼容:排序后的 JSON 反序列化回等值状态
614        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    /// A5:指纹读取失败(文件存在但不可读)时保守计入保护集,
623    /// 防止人工修改内容在下次生成中被静默覆盖
624    #[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        // 独占锁定文件:后续 File::open 在 Windows 上会因共享冲突失败
637        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    /// A5:常规分支——指纹不匹配计入、文件不存在跳过、指纹匹配不计入
668    #[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                // 指纹与磁盘内容不符 → 人工修改
689                (
690                    edited.to_string_lossy().to_string(),
691                    "definitely-not-matching".to_string(),
692                ),
693                // 文件不存在 → 跳过
694                (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}