use std::path::Path;
use anyhow::{Context, Result};
pub fn write_file_atomic(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, content)
.with_context(|| format!("写入临时文件失败: {}", tmp.display()))?;
let file = std::fs::OpenOptions::new()
.write(true)
.open(&tmp)
.with_context(|| format!("打开临时文件刷新失败: {}", tmp.display()))?;
file.sync_all()
.with_context(|| format!("刷新临时文件失败: {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("原子替换失败: {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
#[derive(Debug)]
pub struct RunLock {
path: std::path::PathBuf,
}
impl Drop for RunLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub fn acquire_run_lock(config: &crate::config::schema::WikiConfig) -> Result<RunLock> {
let state_dir = config.output_dir().join(".state");
std::fs::create_dir_all(&state_dir)
.with_context(|| format!("创建状态目录失败: {}", state_dir.display()))?;
let path = state_dir.join("run.lock");
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut f) => {
use std::io::Write;
if let Err(e) = writeln!(f, "{}", std::process::id()) {
eprintln!("code-repo-wiki: 运行锁 PID 写入失败(不影响锁): {e}");
}
Ok(RunLock { path })
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => anyhow::bail!(
"另一 code-repo-wiki 实例正在运行(锁文件: {})。确认无残留实例后可删除该文件重试",
path.display()
),
Err(e) => Err(e).with_context(|| format!("获取运行锁失败: {}", path.display())),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_path(tag: &str, name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_fs_{}_{}", tag, std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
fn lock_config(dir: &std::path::Path) -> crate::config::schema::WikiConfig {
crate::config::schema::WikiConfig {
output_dir: Some(dir.to_path_buf()),
..Default::default()
}
}
#[test]
fn test_run_lock_acquire_and_release() {
let dir = temp_path("lock_roundtrip", "");
let config = lock_config(&dir);
let lock = acquire_run_lock(&config).unwrap();
assert!(dir.join(".state/run.lock").exists());
drop(lock);
assert!(!dir.join(".state/run.lock").exists(), "Drop 应释放锁");
let lock2 = acquire_run_lock(&config).unwrap();
drop(lock2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_run_lock_rejects_second() {
let dir = temp_path("lock_reject", "");
let config = lock_config(&dir);
let _lock = acquire_run_lock(&config).unwrap();
let err = acquire_run_lock(&config).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("正在运行"), "应报并发错误: {msg}");
assert!(msg.contains("run.lock"), "报错应含锁路径: {msg}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_write_new_file() {
let path = temp_path("new", "a.json");
write_file_atomic(&path, "{\"v\":1}").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"v\":1}");
assert!(!path.with_extension("tmp").exists(), "rename 后不应残留临时文件");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn test_overwrite_existing() {
let path = temp_path("overwrite", "b.json");
write_file_atomic(&path, "old").unwrap();
write_file_atomic(&path, "new").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn test_creates_parent_dir() {
let dir = std::env::temp_dir().join(format!("code_repo_wiki_fs_nested_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("deep").join("nested").join("c.json");
write_file_atomic(&path, "x").unwrap();
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}