pub mod change;
pub mod diff;
pub mod impact;
pub mod state;
pub mod watch;
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::config::schema::WikiConfig;
use crate::ingest::parser::FileInsight;
use crate::model::KnowledgeGraph;
use crate::project::ProjectRoot;
use self::change::{classify_entity_changes_at, no_entity_change_files, EntityChangeSet};
use self::impact::{propagate_impact, propagate_impact_semantic};
use self::state::GenerationState;
pub fn should_skip_noop(root: &ProjectRoot, config: &WikiConfig) -> anyhow::Result<bool> {
let state_dir = config.output_dir().join(".state");
let state = match GenerationState::load(&state_dir) {
Ok(s) => s,
Err(_) => return Ok(false),
};
let Some(last_hash) = state.last_commit_hash else {
return Ok(false);
};
if !state.failed_modules.is_empty() {
return Ok(false);
}
if !config.output_dir().join("wiki").exists() {
return Ok(false);
}
let repo = match git2::Repository::open(root.path()) {
Ok(r) => r,
Err(_) => return Ok(false),
};
let head_hash = match repo.head() {
Ok(head) => head.peel_to_commit().ok().map(|c| c.id().to_string()),
Err(_) => return Ok(false),
};
if head_hash.as_deref() != Some(last_hash.as_str()) {
return Ok(false);
}
if status_has_source_changes(&repo, config, root) {
return Ok(false);
}
Ok(true)
}
fn status_has_source_changes(
repo: &git2::Repository,
config: &WikiConfig,
root: &ProjectRoot,
) -> bool {
let out_abs = config.output_dir().to_string_lossy().replace('\\', "/");
let root_abs = root.path().to_string_lossy().replace('\\', "/");
let out_norm = out_abs.strip_prefix(&root_abs).map(|s| s.trim_start_matches('/')).unwrap_or(&out_abs);
match repo.statuses(None) {
Ok(statuses) => statuses.iter().any(|s| {
let Some(path) = s.path() else { return false };
let norm = norm_sep(path);
let in_output = norm == out_norm || norm.starts_with(&format!("{out_norm}/"));
let is_agents_md = norm == "AGENTS.md";
!(in_output || is_agents_md)
}),
Err(_) => true,
}
}
pub(crate) fn norm_sep(p: &str) -> String {
p.replace('\\', "/")
}
pub struct IncrementalResult {
pub changed_files: Vec<std::path::PathBuf>,
pub affected_modules: Vec<String>,
pub entity_changes: EntityChangeSet,
pub has_deleted_files: bool,
}
pub fn run_incremental_update_at(
root: &ProjectRoot,
insights: &[FileInsight],
graph: &KnowledgeGraph,
config: &WikiConfig,
watch_paths: &[PathBuf],
) -> Result<IncrementalResult> {
let state_dir = config.output_dir().join(".state");
let (changed_files, affected_modules, entity_changes, has_deleted_files) =
run_file_watch_incremental(root, insights, graph, &state_dir, watch_paths)?;
let mut changed_files = changed_files;
if let Ok(state) = GenerationState::load(&state_dir)
&& !state.failed_modules.is_empty()
{
let snapshot_path =
crate::output::export_snapshot_path(config.output_dir());
let mut failed_files: Vec<std::path::PathBuf> = Vec::new();
if let Ok(content) = std::fs::read_to_string(&snapshot_path)
&& let Ok(snapshot) =
serde_json::from_str::<crate::output::ExportSnapshot>(&content)
{
for card in &snapshot.cards {
if state.failed_modules.contains(&card.module_name) {
for rf in &card.related_files {
let p = std::path::PathBuf::from(rf);
if !failed_files.contains(&p) {
failed_files.push(p);
}
}
}
}
} else {
tracing::warn!(
"失败补偿重试:导出快照不可读({}),跳过补偿并入",
snapshot_path.display()
);
}
let mut merged = changed_files.clone();
for f in failed_files {
if root.path().join(&f).exists() && !merged.contains(&f) {
merged.push(f);
}
}
if merged.len() > changed_files.len() {
tracing::warn!(
"检测到上次生成失败模块({} 个),并入本次变更集补偿重试",
state.failed_modules.len()
);
changed_files = merged;
}
}
Ok(IncrementalResult { changed_files, affected_modules, entity_changes, has_deleted_files })
}
fn run_file_watch_incremental(
root: &ProjectRoot,
insights: &[FileInsight],
graph: &KnowledgeGraph,
state_dir: &Path,
watch_paths: &[PathBuf],
) -> Result<(Vec<PathBuf>, Vec<String>, EntityChangeSet, bool)> {
let state = match GenerationState::load(state_dir) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!("状态文件读取失败,按无基线处理(本次全量变更): {}", e);
None
}
};
let mut changed_files: Vec<PathBuf> = Vec::new();
for insight in insights {
if let Ok(true) = state.as_ref().map(|s| s.is_file_changed(root, &insight.path)).unwrap_or(Ok(true)) {
changed_files.push(insight.path.clone());
}
}
for p in watch_paths {
if !changed_files.contains(p) {
changed_files.push(p.clone());
}
}
if let Some(state) = &state {
for path_str in state.file_fingerprints.keys() {
let p = PathBuf::from(path_str);
if !insights.iter().any(|i| i.path == p) && !changed_files.contains(&p) {
changed_files.push(p);
}
}
}
if changed_files.is_empty() {
tracing::info!("无文件变更");
return Ok((Vec::new(), Vec::new(), EntityChangeSet::default(), false));
}
let (entity_changes, classification_failed) = if let Some(state) = &state {
let rel = |p: &PathBuf| p.strip_prefix(root.path()).unwrap_or(p).to_path_buf();
let exists_at_root = |p: &PathBuf| root.path().join(p).exists();
let diff = crate::incremental::diff::GitDiffResult {
modified: changed_files
.iter()
.filter(|p| exists_at_root(p))
.map(rel)
.collect(),
deleted: changed_files
.iter()
.filter(|p| !exists_at_root(p))
.map(rel)
.collect(),
from_commit: state.last_commit_hash.clone().unwrap_or_default(),
..Default::default()
};
match classify_entity_changes_at(root, &diff, insights) {
Ok(set) => (set, false),
Err(e) => {
tracing::warn!("FileWatch 实体级变化分类失败,回退双向传播: {}", e);
(EntityChangeSet::default(), true)
}
}
} else {
(EntityChangeSet::default(), true)
};
let changed_for_impact = if classification_failed {
changed_files.clone()
} else {
let no_entity_change = no_entity_change_files(&changed_files, &entity_changes, root);
changed_files
.iter()
.filter(|f| !no_entity_change.contains(*f))
.cloned()
.collect()
};
let affected_modules = if entity_changes.changes.is_empty() {
propagate_impact(&changed_for_impact, graph, crate::config::schema::IMPACT_MAX_DEPTH)
} else {
propagate_impact_semantic(&changed_for_impact, &entity_changes, graph, crate::config::schema::IMPACT_MAX_DEPTH)
};
if let Ok(mut new_state) = GenerationState::from_insights(root, insights, "file-watch") {
if let Some(old_state) = &state {
new_state.preserve_protection(old_state);
}
if let Err(e) = new_state.save(state_dir) {
tracing::warn!("保存生成状态失败: {}", e);
}
}
tracing::info!("FileWatch 增量分析完成: {} 个模块受影响", affected_modules.len());
let has_deleted = changed_files.iter().any(|p| !p.exists());
Ok((changed_files, affected_modules, entity_changes, has_deleted))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::ProjectRoot;
fn make_insight(path: &str) -> FileInsight {
FileInsight {
path: std::path::PathBuf::from(path),
language: "rust".into(),
entities: Vec::new(),
imports: Vec::new(),
doc_comments: Vec::new(),
source: String::new(),
}
}
fn make_config() -> WikiConfig {
WikiConfig {
..Default::default()
}
}
#[test]
fn test_deleted_files_in_changed_set() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_deleted_changed_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let repo = git2::Repository::init(&dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "test").unwrap();
cfg.set_str("user.email", "test@test.com").unwrap();
let src = dir.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("foo.rs"), "fn foo() {}\n").unwrap();
let mut index = repo.index().unwrap();
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
let first_hash = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
let state_dir = dir.join(".state");
let baseline_insights = vec![make_insight("src/foo.rs")];
let baseline = GenerationState::from_insights(
&crate::project::ProjectRoot::new(dir.clone()),
&baseline_insights,
&first_hash,
)
.unwrap();
baseline.save(&state_dir).unwrap();
std::fs::remove_file(src.join("foo.rs")).unwrap();
let mut index = repo.index().unwrap();
index.remove_path(Path::new("src/foo.rs")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "delete", &tree, &[&repo.head().unwrap().peel_to_commit().unwrap()]).unwrap();
let insights: Vec<FileInsight> = Vec::new();
let graph = KnowledgeGraph::default();
let state_dir = dir.join(".state");
let (changed, _affected, _entity_changes, _has_deleted) =
run_file_watch_incremental(&ProjectRoot::new(dir.clone()), &insights, &graph, &state_dir, &[]).unwrap();
assert!(
changed.iter().any(|p| p == Path::new("src/foo.rs")),
"被删文件路径应计入 changed_files(否则删除清理与索引清理永不触发): {:?}",
changed
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_file_watch_deleted_path_in_changed_files() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_watch_deleted_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a.rs"), "v1").unwrap();
let insight = make_insight(src.join("a.rs").to_string_lossy().as_ref());
let state_dir = dir.join(".state");
let root = crate::project::ProjectRoot::new(dir.clone());
let state = GenerationState::from_insights(&root, std::slice::from_ref(&insight), "test").unwrap();
state.save(&state_dir).unwrap();
let deleted = src.join("b.rs");
let graph = KnowledgeGraph::default();
let (changed, _affected, _entity_changes, _has_deleted) =
run_file_watch_incremental(&root, std::slice::from_ref(&insight), &graph, &state_dir, std::slice::from_ref(&deleted)).unwrap();
assert_eq!(
changed,
vec![deleted],
"删除路径必须进入 changed_files(否则删除清理永不触发)"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_file_watch_union_fingerprint_and_watch_paths() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_watch_union_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a.rs"), "v1").unwrap();
let insight = make_insight(src.join("a.rs").to_string_lossy().as_ref());
let state_dir = dir.join(".state");
let root = crate::project::ProjectRoot::new(dir.clone());
let state = GenerationState::from_insights(&root, std::slice::from_ref(&insight), "test").unwrap();
state.save(&state_dir).unwrap();
std::fs::write(src.join("a.rs"), "v2").unwrap();
let deleted = src.join("b.rs");
let graph = KnowledgeGraph::default();
let (changed, _affected, _entity_changes, _has_deleted) =
run_file_watch_incremental(&root, std::slice::from_ref(&insight), &graph, &state_dir, std::slice::from_ref(&deleted)).unwrap();
assert!(
changed.contains(&insight.path),
"指纹命中的文件应计入: {:?}",
changed
);
assert!(
changed.contains(&deleted),
"watch 路径应并入(与指纹命中取并集): {:?}",
changed
);
assert_eq!(changed.len(), 2, "指纹与 watch 路径取并集,不应重复");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_file_watch_midway_save_preserves_protection() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_watch_protect_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("a.rs"), "v1").unwrap();
let insight = make_insight(src.join("a.rs").to_string_lossy().as_ref());
let state_dir = dir.join(".state");
let root = crate::project::ProjectRoot::new(dir.clone());
let mut old = GenerationState::from_insights(&root, std::slice::from_ref(&insight), "old").unwrap();
old.protected_docs = vec!["wiki/zh/manual.md".to_string()];
old.doc_fingerprints = std::collections::HashMap::from([("wiki/zh/manual.md".to_string(), "fp".to_string())]);
old.save(&state_dir).unwrap();
std::fs::write(src.join("a.rs"), "v2").unwrap();
let changed = src.join("b.rs");
let graph = KnowledgeGraph::default();
run_file_watch_incremental(&root, std::slice::from_ref(&insight), &graph, &state_dir, std::slice::from_ref(&changed)).unwrap();
let saved = GenerationState::load(&state_dir).unwrap();
assert_eq!(saved.protected_docs, vec!["wiki/zh/manual.md"], "中途存盘不得清空保护集");
assert_eq!(saved.doc_fingerprints.get("wiki/zh/manual.md").map(String::as_str), Some("fp"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_first_update_no_baseline_falls_back_full() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_first_update_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let repo = git2::Repository::init(&dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "test").unwrap();
cfg.set_str("user.email", "test@test.com").unwrap();
std::fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
let mut index = repo.index().unwrap();
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
let insights = vec![make_insight("a.rs")];
let graph = KnowledgeGraph::default();
let state_dir = dir.join(".state");
let (changed, _affected, _entity_changes, _has_deleted) =
run_file_watch_incremental(&ProjectRoot::new(dir.clone()), &insights, &graph, &state_dir, &[]).unwrap();
assert_eq!(
changed,
vec![PathBuf::from("a.rs")],
"首次 update 无基线时应回退全量(changed_files = 全部源文件),不得静默跳过: {:?}",
changed
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_with_baseline_no_change_skips() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_skip_nochange_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let repo = git2::Repository::init(&dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "test").unwrap();
cfg.set_str("user.email", "test@test.com").unwrap();
std::fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
let mut index = repo.index().unwrap();
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
let head_hash = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
let insights = vec![make_insight("a.rs")];
let graph = KnowledgeGraph::default();
let state_dir = dir.join(".state");
let baseline = GenerationState::from_insights(
&crate::project::ProjectRoot::new(dir.clone()),
&insights,
&head_hash,
)
.unwrap();
baseline.save(&state_dir).unwrap();
let (changed, _affected, _entity_changes, _has_deleted) =
run_file_watch_incremental(&ProjectRoot::new(dir.clone()), &insights, &graph, &state_dir, &[]).unwrap();
assert!(changed.is_empty(), "有基线且无变更时应跳过,不得回退全量: {:?}", changed);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_corrupt_state_falls_back_full() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_test_corrupt_state_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let repo = git2::Repository::init(&dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "test").unwrap();
cfg.set_str("user.email", "test@test.com").unwrap();
std::fs::write(dir.join("a.rs"), "fn a() {}\n").unwrap();
let mut index = repo.index().unwrap();
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
let state_dir = dir.join(".state");
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(state_dir.join("generation_state.json"), "{ 这不是合法 JSON").unwrap();
let insights = vec![make_insight("a.rs")];
let graph = KnowledgeGraph::default();
let (changed, _affected, _entity_changes, _has_deleted) =
run_file_watch_incremental(&ProjectRoot::new(dir.clone()), &insights, &graph, &state_dir, &[]).unwrap();
assert_eq!(
changed,
vec![PathBuf::from("a.rs")],
"状态损坏应按无基线回退全量(保守:宁可全量也不产出空产物): {:?}",
changed
);
let _ = std::fs::remove_dir_all(&dir);
}
fn setup_noop_fixture() -> (PathBuf, String, WikiConfig) {
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"code_repo_wiki_test_noop_{}_{}",
std::process::id(),
SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&dir);
let repo = git2::Repository::init(&dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "test").unwrap();
cfg.set_str("user.email", "test@test.com").unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src").join("a.rs"), "fn a() {}\n").unwrap();
let mut index = repo.index().unwrap();
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
let mut config = make_config();
config.output_dir = Some(dir.join("out"));
(dir, head, config)
}
fn save_baseline(dir: &Path, config: &WikiConfig, commit_hash: &str) {
let insights = vec![make_insight("src/a.rs")];
let state = GenerationState::from_insights(
&ProjectRoot::new(dir.to_path_buf()),
&insights,
commit_hash,
)
.unwrap();
let state_dir = config.output_dir().join(".state");
state.save(&state_dir).unwrap();
std::fs::create_dir_all(config.output_dir().join("wiki").join("zh")).unwrap();
}
#[test]
fn test_noop_skip_when_head_matches_and_clean() {
let (dir, head, config) = setup_noop_fixture();
save_baseline(&dir, &config, &head);
assert!(
should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
"head 相同 + 工作树干净 + 产物存在应跳过"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_noop_no_skip_when_head_changed() {
let (dir, first, config) = setup_noop_fixture();
save_baseline(&dir, &config, &first);
let repo = git2::Repository::open(&dir).unwrap();
std::fs::write(dir.join("src").join("a.rs"), "fn a() { println!(); }\n").unwrap();
let mut index = repo.index().unwrap();
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "second", &tree, &[&repo.head().unwrap().peel_to_commit().unwrap()]).unwrap();
assert!(
!should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
"有新提交不得跳过"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_noop_status_scope_filtering() {
let (dir, head, config) = setup_noop_fixture();
save_baseline(&dir, &config, &head);
std::fs::write(dir.join("src").join("a.rs"), "fn a() { /* dirty */ }\n").unwrap();
assert!(
!should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
"源码未提交变更不得跳过"
);
std::fs::write(dir.join("src").join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(
config.output_dir().join("out.txt"),
"not code",
)
.unwrap();
assert!(
should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
"产物目录变更不应阻断跳过"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_noop_no_skip_when_output_missing() {
let (dir, head, config) = setup_noop_fixture();
let insights = vec![make_insight("src/a.rs")];
let state = GenerationState::from_insights(
&ProjectRoot::new(dir.clone()),
&insights,
&head,
)
.unwrap();
let state_dir = config.output_dir().join(".state");
state.save(&state_dir).unwrap();
assert!(
!should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
"产物目录缺失不得跳过"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_noop_no_skip_without_baseline() {
let (dir, _head, config) = setup_noop_fixture();
assert!(
!should_skip_noop(&ProjectRoot::new(dir.clone()), &config).unwrap(),
"无基线状态不得跳过"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_incremental_merges_failed_modules_from_state() {
let dir = std::env::temp_dir()
.join(format!("code_repo_wiki_test_failed_retry_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let root = ProjectRoot::new(dir.clone());
std::fs::create_dir_all(dir.join("src").join("m20")).unwrap();
let repo = git2::Repository::init(&dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "test").unwrap();
cfg.set_str("user.email", "test@test.com").unwrap();
std::fs::write(dir.join("src").join("m20").join("a.rs"), "pub fn fa() {}\n").unwrap();
std::fs::write(dir.join("src").join("m20").join("b.rs"), "pub fn fb() {}\n").unwrap();
let mut index = repo.index().unwrap();
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap().id().to_string();
let state_dir = dir.join(".state");
let mut state = GenerationState::from_insights(&root, &[], &head).unwrap();
state.failed_modules = vec!["src::m20".into()];
state.save(&state_dir).unwrap();
let snapshot = crate::output::ExportSnapshot {
version: 1,
documents: vec![],
cards: vec![crate::model::KnowledgeCard {
module_name: "src::m20".into(),
module_type: "module".into(),
summary: String::new(),
key_entities: vec![],
dependencies: vec![],
dependents: vec![],
design_patterns: vec![],
todo_notes: vec![],
related_files: vec!["src/m20/a.rs".into(), "src/m20/b.rs".into()],
coding_spec: None,
tech_stack: vec![],
architecture: None,
pending_manual_edits: vec![],
features: vec![],
}],
modules: vec![],
};
std::fs::write(
dir.join(".state").join("export_snapshot.json"),
serde_json::to_string(&snapshot).unwrap(),
)
.unwrap();
let mut config = make_config();
config.output_dir = Some(dir.to_path_buf());
let insights = vec![make_insight("src/m20/a.rs"), make_insight("src/m20/b.rs")];
let graph = KnowledgeGraph::default();
let result = run_incremental_update_at(&root, &insights, &graph, &config, &[]).unwrap();
assert!(
result.changed_files.contains(&PathBuf::from("src/m20/a.rs")),
"失败模块的存活文件必须并入变更集(补生成)"
);
assert!(
result.changed_files.contains(&PathBuf::from("src/m20/b.rs")),
"同模块全部存活文件都并入"
);
assert!(
!should_skip_noop(&root, &config).unwrap(),
"存在失败模块时 no-op 快速判定不得跳过"
);
let _ = std::fs::remove_dir_all(&dir);
}
}