Skip to main content

code_repo_wiki/generate/
card.rs

1use std::path::PathBuf;
2use std::sync::atomic::{AtomicUsize, Ordering};
3
4use anyhow::Result;
5use futures::future::join_all;
6
7use crate::config::schema::WikiConfig;
8use crate::generate::chunk::Chunk;
9use crate::generate::llm::{LlmProvider, Provider};
10use crate::generate::prompt;
11use crate::model::{EntitySummary, KnowledgeCard};
12
13/// 卡片操作动作(CLI card 子命令与 Qoder /knowledge 对等)
14pub enum CardAction {
15    /// 为单个模块生成卡片(重新生成)
16    Generate { module: String },
17    /// 按指令修改已有卡片
18    Modify { module: String, instruction: String, references: Vec<PathBuf> },
19    /// 在已有卡片上追加内容
20    Supplement { module: String, instruction: String, references: Vec<PathBuf> },
21    /// 忽略现有内容全量重写
22    Rewrite { module: String, instruction: String, references: Vec<PathBuf> },
23}
24
25/// 卡片编辑模式(决定 prompt 是否携带现有内容)
26pub enum CardEditMode {
27    /// 修改:现有内容 + 指令
28    Modify,
29    /// 追加:现有保留,新内容附后
30    Supplement,
31    /// 重写:仅指令 + 模块来源信息
32    Rewrite,
33}
34
35impl CardEditMode {
36    fn as_str(&self) -> &'static str {
37        match self {
38            CardEditMode::Modify => "modify",
39            CardEditMode::Supplement => "supplement",
40            CardEditMode::Rewrite => "rewrite",
41        }
42    }
43}
44
45/// Knowledge Card 生成器
46///
47/// 通过 LLM 为每个代码模块生成结构化的 Knowledge Card,
48/// 供 AI Agent 快速理解模块职责和关键实体。
49pub struct CardGenerator<'a, P: LlmProvider> {
50    provider: &'a P,
51    call_count: AtomicUsize,
52    semaphore: tokio::sync::Semaphore,
53    /// 输出语言(用于 prompt 模板)
54    language: String,
55    /// 项目配置(卡片定位:生成前从旧卡片恢复人工修改记录,与单卡路径同构)
56    config: WikiConfig,
57    /// 生成失败的模块名列表(演进计划 T3.2 失败隔离:失败只记录不中断)
58    failed: std::sync::Mutex<Vec<String>>,
59}
60
61impl<'a, P: LlmProvider> CardGenerator<'a, P> {
62    /// 使用指定的 LLM Provider 创建 CardGenerator
63    ///
64    /// max_concurrent 控制并行 LLM 调用的最大并发数(0 表示不限制)。
65    /// language 指定生成内容的语言。
66    /// config 提供产物路径定位(卡片文件读/写规则),供人工修改记录恢复。
67    pub fn new(
68        provider: &'a P,
69        config: WikiConfig,
70        max_concurrent: usize,
71        language: String,
72    ) -> Self {
73        // tokio Semaphore 许可数有 MAX_PERMITS 上限(约 2^61),usize::MAX 会 panic;
74        // "0=不限制" 用足够大的许可数表达(对真实并发规模永不构成瓶颈)
75        let max = if max_concurrent == 0 { 1_000_000_000 } else { max_concurrent };
76        Self {
77            provider,
78            call_count: AtomicUsize::new(0),
79            semaphore: tokio::sync::Semaphore::new(max),
80            language,
81            config,
82            failed: std::sync::Mutex::new(Vec::new()),
83        }
84    }
85
86    /// 返回已完成的 LLM 调用次数
87    pub fn llm_call_count(&self) -> usize {
88        self.call_count.load(Ordering::Relaxed)
89    }
90
91    /// 返回生成失败的模块名列表(演进计划 T3.2:失败隔离的可见性出口)
92    pub fn failed_modules(&self) -> Vec<String> {
93        self.failed.lock().map(|g| g.clone()).unwrap_or_default()
94    }
95
96    /// 为单个模块生成 Knowledge Card
97    ///
98    /// 跳过空块。LLM 调用失败时返回错误。
99    /// 通过 Semaphore 控制并发(acquire → complete → release)。
100    /// pending_manual_edits 为人工修改记录:注入 LLM 输入,
101    /// 且解析后回填新卡片(该字段由管道维护,LLM 不会输出,回填保证不丢)。
102    pub async fn generate_card(
103        &self,
104        chunk: &Chunk,
105        pending_manual_edits: &[String],
106    ) -> Result<KnowledgeCard> {
107        if chunk.is_empty() {
108            anyhow::bail!("空块,跳过生成");
109        }
110
111        // 获取并发许可(无限并发时 Semaphore::new(usize::MAX) 永不会阻塞)
112        let _permit = self.semaphore.acquire().await.map_err(|_| {
113            anyhow::anyhow!("信号量已关闭")
114        })?;
115
116        self.call_count.fetch_add(1, Ordering::Relaxed);
117
118        let messages = prompt::knowledge_card_prompt(
119            chunk,
120            &self.language,
121            pending_manual_edits,
122        );
123        let response = self.provider.complete(&messages).await?;
124
125        let mut card = parse_card_response(&response, chunk)?;
126        if !pending_manual_edits.is_empty() {
127            card.pending_manual_edits = pending_manual_edits.to_vec();
128        }
129        // 反向链接回填(演进计划 T3.3):key_entities 按名匹配 chunk 实体,
130        // 填充 "文件路径:起始行-结束行" 的源码定位,不经过 LLM(防幻觉)
131        backfill_entity_sources(&mut card, chunk);
132        Ok(card)
133    }
134
135    /// 并行生成所有模块的 Knowledge Card
136    ///
137    /// 使用 join_all + Semaphore 实现可控并发。失败的卡片会被跳过(不中断整体流程)。
138    ///
139    /// 人工修改记录的两路来源在**生成前**合并为 LLM 输入(与单卡重生成
140    /// generate_module_card 同构,管道不再"生成后补记"):
141    /// 1. 旧卡片磁盘上的"人工修改待同步"节(recover_pending_manual_edits,
142    ///    记录随生成回填到新卡片,保证不丢);
143    /// 2. extra_edits:本次运行新检测到的人工修改(模块名 → 记录文本),
144    ///    由上层(lib.rs 从状态指纹比对结果)组装传入。
145    pub async fn generate_all_cards(
146        &self,
147        chunks: &[Chunk],
148        extra_edits: &std::collections::HashMap<String, Vec<String>>,
149    ) -> Result<Vec<KnowledgeCard>> {
150        let mut handles = Vec::with_capacity(chunks.len());
151
152        // task_modules 与 handles 同源收集:循环内仅对非空 chunk push,
153        // 下方 zip 时二者长度/顺序 1:1,空 chunk 跳过不会造成失败归因错位
154        let mut task_modules: Vec<String> = Vec::with_capacity(chunks.len());
155        for chunk in chunks {
156            // v31 修复(C-03):空 chunk 是确定性「无内容」而非生成失败——
157            // 跳过不产生 Err、不记 failed_modules(污染会使 should_skip_noop
158            // 永久失效并引发无关模块补偿重试)。真实 LLM 失败仍由 Err 分支记录。
159            // module 名与 handles 同源收集(下方 zip 与 task_modules 1:1 对齐,
160            // 空 chunk 跳过不会造成失败归因错位或结果截断)。
161            if chunk.is_empty() {
162                continue;
163            }
164            let module = chunk.module_path.join("::");
165            task_modules.push(module.clone());
166            // 旧卡片读取失败按失败隔离语义降级(单卡不中断整体生成),
167            // 但显式告警——静默丢人工修改记录会让人工修改保护失效
168            let mut pending = match self.recover_pending_manual_edits(&module) {
169                Ok(p) => p,
170                Err(e) => {
171                    tracing::warn!(
172                        "读取旧卡片人工修改记录失败,本次生成不携带旧记录 {}: {}",
173                        module,
174                        e
175                    );
176                    Vec::new()
177                }
178            };
179            if let Some(extra) = extra_edits.get(&module) {
180                for note in extra {
181                    if !pending.contains(note) {
182                        pending.push(note.clone());
183                    }
184                }
185            }
186            // async move 拥有 pending(避免 future 借用循环内临时值);
187            // chunk 为 &Chunk,self 为 &self,均为引用移动
188            let generator = self;
189            handles.push(async move { generator.generate_card(chunk, &pending).await });
190        }
191
192        let results = join_all(handles).await;
193        let cards: Vec<KnowledgeCard> = task_modules
194            .into_iter()
195            .zip(results)
196            .filter_map(|(module, r)| {
197                match r {
198                    Ok(card) => Some(card),
199                    Err(e) => {
200                        // 失败隔离:记录失败的模块名(T3.2),不中断其他模块生成
201                        tracing::warn!("Knowledge Card 生成失败,跳过 {}: {}", module, e);
202                        if let Ok(mut failed) = self.failed.lock() {
203                            failed.push(module);
204                        }
205                        None
206                    }
207                }
208            })
209            .collect();
210
211        Ok(cards)
212    }
213
214    /// 从旧卡片磁盘文件恢复"人工修改待同步"记录(无该节时返回空)
215    ///
216    /// 按模块名精确定位卡片文件(read_card 路径规则与渲染层一致),
217    /// 解析"## 人工修改待同步"节内容——与单卡重生成路径共用同一解析逻辑,
218    /// 保证两条路径对记录的恢复行为一致。
219    ///
220    /// 返回 Result:磁盘读取错误(损坏/权限)向上传播,由调用方按
221    /// 失败隔离语义显式告警并降级(生成时携带空记录),不静默吞掉。
222    fn recover_pending_manual_edits(&self, module: &str) -> Result<Vec<String>> {
223        Ok(read_card(&self.config, module)?
224            .map(|content| extract_pending_manual_edits(&content))
225            .unwrap_or_default())
226    }
227}
228
229/// 卡片文件路径:cards/{主语言}/{module.replace("::","_")}.md(与 render_all 写盘规则一致)
230fn card_path(config: &WikiConfig, module: &str) -> PathBuf {
231    let primary_lang = &crate::output::wiki_languages(config)[0];
232    crate::output::card_page_path(config.output_dir(), primary_lang, module)
233}
234
235/// 读取现有卡片 markdown(按模块名定位 cards/{lang}/{module}.md)
236///
237/// 卡片不存在时返回 Ok(None)。
238pub fn read_card(config: &WikiConfig, module: &str) -> Result<Option<String>> {
239    let path = card_path(config, module);
240    match std::fs::read_to_string(&path) {
241        Ok(content) => Ok(Some(content)),
242        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
243        Err(e) => Err(e.into()),
244    }
245}
246
247/// 读取参考文件并拼接为参考材料段落(空列表跳过拼接)
248fn read_references(references: &[PathBuf]) -> Result<String> {
249    let mut block = String::new();
250    for path in references {
251        let content = std::fs::read_to_string(path)?;
252        block.push_str(&format!("\n\n### {}\n{}", path.display(), content));
253    }
254    Ok(block)
255}
256
257/// 去除 LLM 返回中的 Markdown 代码块包裹(如有),否则原样返回
258fn extract_markdown(text: &str) -> &str {
259    let text = text.trim();
260    match text.strip_prefix("```") {
261        Some(rest) => rest
262            .split_once('\n')
263            .map(|(_, body)| body.trim().trim_end_matches("```").trim())
264            .unwrap_or(text),
265        None => text,
266    }
267}
268
269/// 原子写回卡片文件:统一走 fs::write_file_atomic(同目录临时文件 +
270/// rename 原子覆盖)。rustc 1.84+ 的 rename 在 Windows 已是 POSIX 语义
271/// 原子替换,无需先删目标(删掉旧实现的 remove_file 前置,消除
272/// "目标已删、临时文件未就位"的中间窗口)。
273fn write_card_atomic(config: &WikiConfig, module: &str, content: &str) -> Result<()> {
274    let path = card_path(config, module);
275    crate::fs::write_file_atomic(&path, content)
276}
277
278/// 从卡片 markdown 中提取"人工修改待同步"节内容(无该节时返回空)
279///
280/// 节内格式为 "- 记录" 列表行;遇到其他 "## " 标题即结束。
281/// 用于单卡重生成(generate_module_card)时恢复旧卡片上的记录作为 LLM 输入。
282fn extract_pending_manual_edits(content: &str) -> Vec<String> {
283    let mut items = Vec::new();
284    let mut in_section = false;
285    for line in content.lines() {
286        if line.starts_with("## ") {
287            in_section = line == "## 人工修改待同步";
288            continue;
289        }
290        if in_section && let Some(item) = line.strip_prefix("- ") {
291            items.push(item.to_string());
292        }
293    }
294    items
295}
296
297/// 为单个模块重新生成卡片:全量扫描分块 → 定位模块 chunk → LLM 生成 → 写回
298///
299/// 仅写主语言目录的卡片文件(与其他语言目录由全量 generate 统一刷新)。
300/// 旧卡片上的"人工修改待同步"记录会被恢复为本次 LLM 输入,并保留在新卡片中。
301pub async fn generate_module_card(
302    provider: &Provider,
303    config: &WikiConfig,
304    root: &crate::project::ProjectRoot,
305    module: &str,
306) -> Result<()> {
307    let insights = crate::ingest::scan_and_parse_at(root)?.insights;
308    let graph = crate::analysis::build_graph(&insights)?;
309    let chunks = crate::generate::chunk::chunk_by_module(&insights, &graph.modules, &graph);
310    let chunk = chunks
311        .into_iter()
312        .find(|c| c.module_path.join("::") == module)
313        .ok_or_else(|| anyhow::anyhow!(
314            "未找到模块 {module} 对应的代码分块,请检查模块名或先运行 `code-repo-wiki generate` 全量生成"
315        ))?;
316
317    // 从旧卡片恢复人工修改记录(卡片不存在或尚无该节时为空)
318    let pending = read_card(config, module)?
319        .map(|content| extract_pending_manual_edits(&content))
320        .unwrap_or_default();
321
322    let generator = CardGenerator::new(provider, config.clone(), 1, config.wiki.language.clone());
323    let card = generator.generate_card(&chunk, &pending).await?;
324    let content = crate::output::markdown::render_knowledge_card(&card);
325
326    write_card_atomic(config, module, &content)?;
327    tracing::info!("卡片已生成: {} → {}", module, card_path(config, module).display());
328    Ok(())
329}
330
331/// 修改/补充/重写:现有内容 + 指令 + 参考文件 → LLM → 原子写回
332pub async fn edit_card(
333    provider: &Provider,
334    config: &WikiConfig,
335    module: &str,
336    instruction: &str,
337    references: &[PathBuf],
338    mode: CardEditMode,
339) -> Result<()> {
340    let existing = read_card(config, module)?.ok_or_else(|| anyhow::anyhow!(
341        "模块 {module} 的卡片不存在({}),请先运行 `code-repo-wiki generate` 或 `code-repo-wiki card generate <module>` 生成",
342        card_path(config, module).display()
343    ))?;
344    let reference_block = read_references(references)?;
345
346    let messages = prompt::edit_card_prompt(
347        mode.as_str(),
348        module,
349        &existing,
350        instruction,
351        &reference_block,
352        &config.wiki.language,
353    );
354    let response = provider.complete(&messages).await?;
355    let content = extract_markdown(&response);
356
357    write_card_atomic(config, module, content)?;
358    tracing::info!("卡片已更新: {} → {}", module, card_path(config, module).display());
359    Ok(())
360}
361
362/// 解析 LLM 返回的 JSON 响应为 KnowledgeCard
363fn parse_card_response(response: &str, chunk: &Chunk) -> Result<KnowledgeCard> {
364    let json_str = extract_json(response);
365
366    let parsed: serde_json::Value =
367        serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("解析卡片 JSON 失败: {}", e))?;
368
369    let summary = parsed["summary"].as_str().unwrap_or("").to_string();
370
371    let key_entities: Vec<EntitySummary> = parsed["key_entities"]
372        .as_array()
373        .map(|arr| {
374            arr.iter()
375                .map(|v| EntitySummary {
376                    name: v["name"].as_str().unwrap_or("").to_string(),
377                    kind: v["kind"].as_str().unwrap_or("").to_string(),
378                    visibility: v["visibility"].as_str().unwrap_or("public").to_string(),
379                    doc: v["doc"].as_str().map(|s| s.to_string()),
380                    source: None,
381                })
382                .collect()
383        })
384        .unwrap_or_default();
385
386    let design_patterns: Vec<String> = parsed["design_patterns"]
387        .as_array()
388        .map(|arr| {
389            arr.iter()
390                .filter_map(|v| v.as_str().map(|s| s.to_string()))
391                .collect()
392        })
393        .unwrap_or_default();
394
395    let todo_notes: Vec<String> = parsed["todo_notes"]
396        .as_array()
397        .map(|arr| {
398            arr.iter()
399                .filter_map(|v| v.as_str().map(|s| s.to_string()))
400                .collect()
401        })
402        .unwrap_or_default();
403
404    // LLM 描述性字段:缺失时置空,不因单个字段缺失而丢弃整张卡片
405    let coding_spec = parsed["coding_spec"].as_str().map(|s| s.to_string());
406    let tech_stack: Vec<String> = parsed["tech_stack"]
407        .as_array()
408        .map(|arr| {
409            arr.iter()
410                .filter_map(|v| v.as_str().map(|s| s.to_string()))
411                .collect()
412        })
413        .unwrap_or_default();
414    let architecture = parsed["architecture"].as_str().map(|s| s.to_string());
415
416    Ok(KnowledgeCard {
417        module_name: chunk.module_path.join("::"),
418        module_type: "module".to_string(),
419        summary,
420        key_entities,
421        dependencies: chunk.dependencies.clone(),
422        dependents: Vec::new(),
423        design_patterns,
424        todo_notes,
425        // 关联文件不经过 LLM,直接由 chunk 的源文件列表填充
426        related_files: chunk
427            .file_paths
428            .iter()
429            .map(|p| p.display().to_string())
430            .collect(),
431        coding_spec,
432        tech_stack,
433        architecture,
434        pending_manual_edits: Vec::new(),
435        features: Vec::new(),
436    })
437}
438
439/// 回填实体反向链接(演进计划 T3.3)
440///
441/// key_entities 按实体名匹配 chunk 实体,填充 "文件路径:起始行-结束行"。
442/// 文件归属来自 chunk.entity_sources(T2.3 引入的实体→文件平行向量);
443/// 源码定位不经过 LLM,杜绝幻觉。未匹配到实体的条目保持 None。
444fn backfill_entity_sources(card: &mut KnowledgeCard, chunk: &Chunk) {
445    for es in &mut card.key_entities {
446        if es.source.is_some() {
447            continue;
448        }
449        if let Some((idx, entity)) = chunk
450            .entities
451            .iter()
452            .enumerate()
453            .find(|(_, e)| e.name == es.name)
454            && let Some(file) = chunk.entity_sources.get(idx)
455        {
456            es.source = Some(format!(
457                "{}:{}-{}",
458                file.display(),
459                entity.line_start,
460                entity.line_end
461            ));
462        }
463    }
464}
465
466/// 从 LLM 响应中提取 JSON 字符串(去除 Markdown 代码块标记)
467fn extract_json(text: &str) -> &str {
468    let text = text.trim();
469    if let Some(start) = text.find('{') {
470        let end = text.rfind('}').map(|i| i + 1).unwrap_or(text.len());
471        &text[start..end]
472    } else {
473        text
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::generate::chunk::chunk_by_file;
481    use crate::generate::llm::MockProvider;
482    
483    use crate::ingest::parser::{Entity, FileInsight, ImportStmt};
484    use std::path::PathBuf;
485
486    fn make_test_chunk() -> Chunk {
487        let entity = Entity {
488            name: "Config".into(),
489            kind: "struct".into(),
490            line_start: 1,
491            line_end: 30,
492            doc_comment: Some("配置管理".into()),
493            signature: None, visibility: None,
494        };
495        let insight = FileInsight {
496            path: PathBuf::from("src/config.rs"),
497            language: "rust".into(),
498            entities: vec![entity],
499            imports: vec![ImportStmt {
500                source: "serde".into(),
501                alias: None,
502                line: 1,
503            }],
504            doc_comments: vec![],
505            source: String::new(),
506        };
507        chunk_by_file(&insight)
508    }
509
510    #[test]
511    fn test_extract_json() {
512        let input = "```json\n{\"summary\": \"test\"}\n```";
513        assert_eq!(extract_json(input), "{\"summary\": \"test\"}");
514
515        let input = "{\"summary\": \"test\"}";
516        assert_eq!(extract_json(input), "{\"summary\": \"test\"}");
517    }
518
519    #[test]
520    fn test_parse_card_response() {
521        let response = r#"{"summary": "配置模块", "key_entities": [{"name": "Config", "kind": "struct", "visibility": "public", "doc": "配置结构"}], "design_patterns": ["Builder"], "todo_notes": [], "coding_spec": "遵循 rustfmt", "tech_stack": ["serde"], "architecture": "分层"}"#;
522        let chunk = make_test_chunk();
523        let card = parse_card_response(response, &chunk).unwrap();
524
525        assert_eq!(card.summary, "配置模块");
526        assert_eq!(card.key_entities.len(), 1);
527        assert_eq!(card.key_entities[0].name, "Config");
528        // 关联文件直接来自 chunk,不经过 LLM
529        assert_eq!(card.related_files, vec!["src/config.rs".to_string()]);
530        // LLM 描述性字段
531        assert_eq!(card.coding_spec.as_deref(), Some("遵循 rustfmt"));
532        assert_eq!(card.tech_stack, vec!["serde".to_string()]);
533        assert_eq!(card.architecture.as_deref(), Some("分层"));
534    }
535
536    #[test]
537    fn test_parse_card_empty_response() {
538        let response = r#"{"summary": "", "key_entities": [], "design_patterns": [], "todo_notes": []}"#;
539        let chunk = make_test_chunk();
540        let card = parse_card_response(response, &chunk).unwrap();
541
542        assert!(card.summary.is_empty());
543        assert!(card.key_entities.is_empty());
544        // LLM 未输出描述性字段时保持空值
545        assert!(card.coding_spec.is_none());
546        assert!(card.tech_stack.is_empty());
547        assert!(card.architecture.is_none());
548    }
549
550    /// 预置卡片并返回临时输出目录的配置(tag 用于区分同进程内的多个测试目录)
551    fn card_fixture(tag: &str, module: &str, content: &str) -> (WikiConfig, std::path::PathBuf) {
552        let dir = std::env::temp_dir().join(format!("code_repo_wiki_card_{tag}_{}_{}", module.replace("::", "_"), std::process::id()));
553        let _ = std::fs::remove_dir_all(&dir);
554        let config = WikiConfig { output_dir: Some(dir.to_path_buf()), ..Default::default() };
555        std::fs::create_dir_all(config.output_dir().join("cards").join("zh")).unwrap();
556        std::fs::write(card_path(&config, module), content).unwrap();
557        (config, dir)
558    }
559
560    #[tokio::test]
561    async fn test_edit_card_supplement_roundtrip() {
562        let (config, dir) = card_fixture("supplement", "crate::test", "# crate::test\n\n## 摘要\n旧内容");
563        let provider = Provider::Mock(MockProvider::new());
564        edit_card(
565            &provider,
566            &config,
567            "crate::test",
568            "追加新内容",
569            &[],
570            CardEditMode::Supplement,
571        )
572        .await
573        .unwrap();
574
575        // 写回路径与 render_all 一致:cards/zh/crate_test.md
576        let written = std::fs::read_to_string(config.output_dir().join("cards").join("zh").join("crate_test.md")).unwrap();
577        assert!(written.contains("模拟摘要"), "应写入 Mock Provider 的响应内容");
578        let _ = std::fs::remove_dir_all(&dir);
579    }
580
581    #[tokio::test]
582    async fn test_edit_card_requires_existing() {
583        let (config, dir) = card_fixture("missing", "crate::test", "内容");
584        let provider = Provider::Mock(MockProvider::new());
585        let err = edit_card(
586            &provider,
587            &config,
588            "crate::missing",
589            "指令",
590            &[],
591            CardEditMode::Rewrite,
592        )
593        .await
594        .unwrap_err();
595        assert!(err.to_string().contains("卡片不存在"), "应报卡片不存在: {}", err);
596        let _ = std::fs::remove_dir_all(&dir);
597    }
598
599    #[test]
600    fn test_extract_markdown_strips_codeblock() {
601        assert_eq!(extract_markdown("```markdown\n# 标题\n```"), "# 标题");
602        assert_eq!(extract_markdown("# 标题"), "# 标题");
603    }
604
605    #[test]
606    fn test_extract_pending_manual_edits() {
607        let content = "# src::test\n\n## 摘要\n旧内容\n\n## 人工修改待同步\n\n- 人工修改待同步: a.md 内容摘要: 1\n- 人工修改待同步: b.md 内容摘要: 2\n\n## 待办事项\n\n- [ ] x\n";
608        let items = extract_pending_manual_edits(content);
609        assert_eq!(items.len(), 2, "应提取节内两条记录");
610        assert!(items[0].contains("a.md"));
611        assert!(items[1].contains("b.md"));
612        // 无该节 → 空
613        assert!(extract_pending_manual_edits("# t\n\n## 摘要\nx").is_empty());
614    }
615
616    #[tokio::test]
617    async fn test_generate_card_keeps_pending_manual_edits() {
618        let chunk = make_test_chunk();
619        let provider = Provider::Mock(MockProvider::new());
620        let (config, dir) = card_fixture("pending", "src", "旧卡片内容");
621        let generator = CardGenerator::new(&provider, config, 1, "zh".into());
622        // 带记录:LLM 输入注入且生成后回填(渲染不丢)
623        let pending = vec!["人工修改待同步: wiki/zh/src_config.md 内容摘要: 用户改的".into()];
624        let card = generator.generate_card(&chunk, &pending).await.unwrap();
625        assert_eq!(card.pending_manual_edits, pending);
626        // 无记录:不回填
627        let card = generator.generate_card(&chunk, &[]).await.unwrap();
628        assert!(card.pending_manual_edits.is_empty());
629
630        let _ = std::fs::remove_dir_all(&dir);
631    }
632
633    /// 管道路径闭环:generate_all_cards 生成前合并两路人工修改记录——
634    /// 旧卡片磁盘恢复(recover_pending_manual_edits)+ 本次新增(extra_edits),
635    /// 去重后注入 LLM 输入并回填新卡片
636    #[tokio::test]
637    async fn test_generate_all_cards_merges_recovered_and_extra_edits() {
638        let chunk = make_test_chunk();
639        let provider = Provider::Mock(MockProvider::new());
640        // 预置旧卡片:含一条"人工修改待同步"记录(磁盘恢复源)
641        let (config, dir) = card_fixture(
642            "merge",
643            "src",
644            "# src\n\n## 摘要\n旧内容\n\n## 人工修改待同步\n\n- 人工修改待同步: wiki/zh/src.md 内容摘要: 旧记录\n",
645        );
646
647        let generator = CardGenerator::new(&provider, config, 1, "zh".into());
648        // 本次新增记录(模块名 → 记录文本,lib.rs 组装)
649        let mut extra = std::collections::HashMap::new();
650        extra.insert(
651            "src".to_string(),
652            vec!["人工修改待同步: wiki/zh/src.md 内容摘要: 新修改".to_string()],
653        );
654
655        let cards = generator.generate_all_cards(&[chunk], &extra).await.unwrap();
656        assert_eq!(cards.len(), 1);
657        assert_eq!(cards[0].pending_manual_edits.len(), 2, "旧记录 + 新记录应合并为 2 条");
658        assert!(cards[0].pending_manual_edits.iter().any(|n| n.contains("旧记录")));
659        assert!(cards[0].pending_manual_edits.iter().any(|n| n.contains("新修改")));
660
661        // 去重:同一条记录同时存在于磁盘与 extra 时只保留一份
662        let cards2 = generator.generate_all_cards(
663            &[make_test_chunk()],
664            &extra,
665        ).await.unwrap();
666        assert!(
667            cards2[0].pending_manual_edits.len() <= 2,
668            "重复记录应被去重: {:?}",
669            cards2[0].pending_manual_edits
670        );
671
672        let _ = std::fs::remove_dir_all(&dir);
673    }
674
675    /// P3-4(A8 防回归):旧卡片读取失败(卡片路径被替换为目录 →
676    /// read_to_string 返回 IsADirectory)时,显式告警 + 降级为空记录,
677    /// 不中断整批卡片生成;本次 extra 记录照常注入
678    #[tokio::test]
679    async fn test_generate_all_cards_survives_card_read_failure() {
680        let chunk = make_test_chunk();
681        let provider = Provider::Mock(MockProvider::new());
682        let (config, dir) = card_fixture("readfail", "src", "# src\n\n## 摘要\n旧内容");
683
684        // 卡片文件替换为同名目录:读取必然失败(非 NotFound 的 IO 错误路径)
685        let card_file = card_path(&config, "src");
686        std::fs::remove_file(&card_file).unwrap();
687        std::fs::create_dir_all(&card_file).unwrap();
688
689        let generator = CardGenerator::new(&provider, config, 1, "zh".into());
690        let mut extra = std::collections::HashMap::new();
691        extra.insert(
692            "src".to_string(),
693            vec!["人工修改待同步: wiki/zh/src.md 内容摘要: 新修改".to_string()],
694        );
695
696        let cards = generator.generate_all_cards(&[chunk], &extra).await.unwrap();
697        assert_eq!(cards.len(), 1, "旧卡片读失败不应中断整批生成");
698        assert_eq!(
699            cards[0].pending_manual_edits,
700            vec!["人工修改待同步: wiki/zh/src.md 内容摘要: 新修改".to_string()],
701            "旧记录读取失败降级为空,只携带本次 extra 记录"
702        );
703
704        let _ = std::fs::remove_dir_all(&dir);
705    }
706
707    /// v31 C-03 防回归:管线入口已剔除空 chunk(chunks/cards/wiki/backfill
708    /// 全链路 1:1 对齐)——generate_all_cards 收到非空 chunk 数组;
709    /// 空 chunk 交错在中间时([空, 失败] 与 [空, 成功])失败归因必须
710    /// 正确落在真实失败模块名下、成功卡片不得静默丢失、不得记入空模块。
711    #[tokio::test]
712    async fn test_generate_all_cards_skips_empty_chunks_records_real_failures() {
713        // 构造空 chunk(管线入口过滤后不应到达生成循环;此处验证交错对齐)
714        let mut empty = make_test_chunk();
715        empty.module_path = vec!["zzz".into()];
716        empty.entities = Vec::new();
717        empty.imports = Vec::new();
718        let provider = Provider::Mock(MockProvider::new());
719
720        // ① [空, 真实失败]:失败必须记在真实模块 src 名下,而非空模块 zzz
721        let failing = FailingProvider;
722        let (config, dir) = card_fixture("interleave-fail", "src", "# src\n\n## 摘要\n旧内容");
723        let fail_gen = CardGenerator::new(&failing, config, 1, "zh".into());
724        let cards = fail_gen
725            .generate_all_cards(&[empty.clone(), make_test_chunk()], &std::collections::HashMap::new())
726            .await
727            .unwrap();
728        assert!(cards.is_empty(), "失败模块不产出卡片");
729        assert_eq!(
730            fail_gen.failed_modules(),
731            vec!["src"],
732            "失败必须归因到真实失败模块(空 chunk 已被入口剔除,不参与对齐): {:?}",
733            fail_gen.failed_modules()
734        );
735        let _ = std::fs::remove_dir_all(&dir);
736
737        // ② [空, 成功]:成功卡片保留且摘要正确(无错位截断)
738        let (config2, dir2) = card_fixture("interleave-ok", "src", "# src\n\n## 摘要\n旧内容");
739        let gen2 = CardGenerator::new(&provider, config2, 1, "zh".into());
740        let cards2 = gen2
741            .generate_all_cards(&[empty, make_test_chunk()], &std::collections::HashMap::new())
742            .await
743            .unwrap();
744        assert_eq!(cards2.len(), 1, "成功卡片不得静默丢失");
745        assert_eq!(cards2[0].module_name, "src");
746        assert!(gen2.failed_modules().is_empty(), "无真实失败时不记 failed_modules");
747        let _ = std::fs::remove_dir_all(&dir2);
748    }
749
750    /// 测试专用:总是失败的 LLM provider(验证真实失败仍入 failed_modules)
751    struct FailingProvider;
752
753    impl LlmProvider for FailingProvider {
754        async fn complete(
755            &self,
756            _messages: &[crate::generate::llm::Message],
757        ) -> anyhow::Result<String> {
758            anyhow::bail!("模拟 LLM 调用失败")
759        }
760
761        async fn complete_stream(
762            &self,
763            _messages: &[crate::generate::llm::Message],
764        ) -> anyhow::Result<Vec<String>> {
765            anyhow::bail!("模拟 LLM 调用失败")
766        }
767
768        fn call_count(&self) -> usize {
769            0
770        }
771    }
772}