Skip to main content

code_repo_wiki/
fs.rs

1//! 文件原子写入辅助(状态/快照/缓存/卡片统一落盘入口)
2//!
3//! 背景:项目内四处产物写入(generation_state.json / export_snapshot.json /
4//! insights_cache.json / 知识卡片)此前各自 `std::fs::write` 直写或
5//! remove+rename 折衷。直写非原子(截断写,崩溃/断电可留下半截文件,
6//! 损坏即数据风险);remove+rename 是旧 Windows 折衷(旧版 rename
7//! 不覆盖已存在目标),存在"目标被删、临时文件未就位"的中间窗口。
8//!
9//! 本模块统一为"同目录临时文件写入 + rename 原子覆盖":
10//! - rustc 1.84+ 的 `std::fs::rename` 在 Windows 10 1709+ 使用
11//!   FileRenameInfoEx + FILE_RENAME_POSIX_SEMANTICS(POSIX 语义),
12//!   原子覆盖已存在目标,因此无需再先删目标(删 remove_file 前置即
13//!   消除中间窗口);本仓库 rustc 1.97.1,前提满足(2026-08-02 实测)。
14//! - 同目录临时文件保证 rename 不跨文件系统(跨 mount 会失败)。
15//! - 调用方(state/快照/缓存/卡片)各自决定失败语义(fail-loud 或
16//!   warn+降级),本函数只负责原子落盘。
17
18use std::path::Path;
19
20use anyhow::{Context, Result};
21/// 原子写入:内容写入 `path` 同目录的临时文件后 rename 覆盖
22///
23/// - 父目录不存在时自动创建(与各调用点现有一致)
24/// - 临时文件名 = `{文件名}.tmp`(与历史 write_card_atomic 的约定一致,
25///   崩溃残留的 .tmp 会被下次写入覆盖,无需清理逻辑)
26/// - rename 覆盖目标为原子操作(POSIX 语义,见模块注释)
27pub fn write_file_atomic(path: &Path, content: &str) -> Result<()> {
28    if let Some(parent) = path.parent() {
29        std::fs::create_dir_all(parent)
30            .with_context(|| format!("创建目录失败: {}", parent.display()))?;
31    }
32    let tmp = path.with_extension("tmp");
33    std::fs::write(&tmp, content)
34        .with_context(|| format!("写入临时文件失败: {}", tmp.display()))?;
35    // 落盘屏障:rename 的原子性只保证「命名」原子替换,不保证数据已持久化。
36    // 断电/崩溃可能留下「已 rename 但内容截断」的文件(salt 9c18c27 实证),
37    // 因此在 rename 前显式 flush + fsync 数据。用写句柄打开以确保 Windows
38    // FlushFileBuffers 语义(读句柄在不同平台上行为不一)。
39    let file = std::fs::OpenOptions::new()
40        .write(true)
41        .open(&tmp)
42        .with_context(|| format!("打开临时文件刷新失败: {}", tmp.display()))?;
43    file.sync_all()
44        .with_context(|| format!("刷新临时文件失败: {}", tmp.display()))?;
45    std::fs::rename(&tmp, path)
46        .with_context(|| format!("原子替换失败: {} -> {}", tmp.display(), path.display()))?;
47    Ok(())
48}
49
50// ==================== 单实例运行锁(v36 D4)====================
51//
52// 并发 generate/update/watch 会把状态/索引/产物互相覆盖(最后写入者
53// 胜)。生成是「进程内串行、进程间互斥」的操作:本锁在
54// run_pipeline_with_progress 入口以 create_new 原子获取,作用域=单次
55// 生成全程,Drop 时释放(正常退出/错误传播都会走 Drop)。
56//
57// 崩溃残留(进程被杀,锁文件遗留):报错信息明确指引人工删除——
58// 不自动清理:自动清会把「另一实例正在生成中」误判为残留,反而引入
59// 真并发窗口。
60
61/// 运行锁:持有期间其他实例的生成入口被拒绝;Drop 释放
62#[derive(Debug)]
63pub struct RunLock {
64    path: std::path::PathBuf,
65}
66
67impl Drop for RunLock {
68    fn drop(&mut self) {
69        // 释放失败可忽略:锁文件残留会由下一次获取报错指引人工删除,
70        // 此处报错无调用方(Drop 语义),静默符合预期
71        let _ = std::fs::remove_file(&self.path);
72    }
73}
74
75/// 原子获取运行锁:.state/run.lock 不存在则创建(写入当前进程 PID 供
76/// 排查),存在则报错——报错信息包含锁路径与处理指引。
77pub fn acquire_run_lock(config: &crate::config::schema::WikiConfig) -> Result<RunLock> {
78    let state_dir = config.output_dir().join(".state");
79    std::fs::create_dir_all(&state_dir)
80        .with_context(|| format!("创建状态目录失败: {}", state_dir.display()))?;
81    let path = state_dir.join("run.lock");
82    match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
83        Ok(mut f) => {
84            use std::io::Write;
85            // 写入 PID 供「锁是谁留下的」排查;失败仅告警(锁本身已建立)
86            if let Err(e) = writeln!(f, "{}", std::process::id()) {
87                eprintln!("code-repo-wiki: 运行锁 PID 写入失败(不影响锁): {e}");
88            }
89            Ok(RunLock { path })
90        }
91        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => anyhow::bail!(
92            "另一 code-repo-wiki 实例正在运行(锁文件: {})。确认无残留实例后可删除该文件重试",
93            path.display()
94        ),
95        Err(e) => Err(e).with_context(|| format!("获取运行锁失败: {}", path.display())),
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn temp_path(tag: &str, name: &str) -> std::path::PathBuf {
104        let dir = std::env::temp_dir().join(format!("code_repo_wiki_fs_{}_{}", tag, std::process::id()));
105        let _ = std::fs::remove_dir_all(&dir);
106        std::fs::create_dir_all(&dir).unwrap();
107        dir.join(name)
108    }
109
110    fn lock_config(dir: &std::path::Path) -> crate::config::schema::WikiConfig {
111        crate::config::schema::WikiConfig {
112            output_dir: Some(dir.to_path_buf()),
113            ..Default::default()
114        }
115    }
116
117    /// 锁可获取;Drop 后释放(可再次获取)
118    #[test]
119    fn test_run_lock_acquire_and_release() {
120        let dir = temp_path("lock_roundtrip", "");
121        let config = lock_config(&dir);
122        let lock = acquire_run_lock(&config).unwrap();
123        assert!(dir.join(".state/run.lock").exists());
124        drop(lock);
125        assert!(!dir.join(".state/run.lock").exists(), "Drop 应释放锁");
126        // 释放后可再次获取(幂等循环)
127        let lock2 = acquire_run_lock(&config).unwrap();
128        drop(lock2);
129        let _ = std::fs::remove_dir_all(&dir);
130    }
131
132    /// 锁已存在时拒绝第二次获取,报错含路径与指引
133    #[test]
134    fn test_run_lock_rejects_second() {
135        let dir = temp_path("lock_reject", "");
136        let config = lock_config(&dir);
137        let _lock = acquire_run_lock(&config).unwrap();
138        let err = acquire_run_lock(&config).unwrap_err();
139        let msg = err.to_string();
140        assert!(msg.contains("正在运行"), "应报并发错误: {msg}");
141        assert!(msg.contains("run.lock"), "报错应含锁路径: {msg}");
142        let _ = std::fs::remove_dir_all(&dir);
143    }
144
145    /// 新文件写入成功且内容正确
146    #[test]
147    fn test_write_new_file() {
148        let path = temp_path("new", "a.json");
149        write_file_atomic(&path, "{\"v\":1}").unwrap();
150        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"v\":1}");
151        // 临时文件不应残留
152        assert!(!path.with_extension("tmp").exists(), "rename 后不应残留临时文件");
153        let _ = std::fs::remove_dir_all(path.parent().unwrap());
154    }
155
156    /// 覆盖已存在文件(原子替换语义)
157    #[test]
158    fn test_overwrite_existing() {
159        let path = temp_path("overwrite", "b.json");
160        write_file_atomic(&path, "old").unwrap();
161        write_file_atomic(&path, "new").unwrap();
162        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
163        let _ = std::fs::remove_dir_all(path.parent().unwrap());
164    }
165
166    /// 父目录不存在时自动创建
167    #[test]
168    fn test_creates_parent_dir() {
169        let dir = std::env::temp_dir().join(format!("code_repo_wiki_fs_nested_{}", std::process::id()));
170        let _ = std::fs::remove_dir_all(&dir);
171        let path = dir.join("deep").join("nested").join("c.json");
172        write_file_atomic(&path, "x").unwrap();
173        assert!(path.exists());
174        let _ = std::fs::remove_dir_all(&dir);
175    }
176}
177// v17 F 组增量闭环验证