use crate::{ConfigError, ConfigSource};
use async_trait::async_trait;
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
pub struct FileConfigSource {
path: PathBuf,
watcher: Mutex<Option<RecommendedWatcher>>,
}
impl FileConfigSource {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
watcher: Mutex::new(None),
}
}
fn read_file(&self) -> Result<HashMap<String, Value>, ConfigError> {
let content = std::fs::read_to_string(&self.path)
.map_err(|e| ConfigError::Source(format!("读取文件失败 {}: {}", self.path.display(), e)))?;
let ext = self
.path
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let value = match ext {
"json" => serde_json::from_str::<Value>(&content)
.map_err(|e| ConfigError::Parse(format!("解析 JSON 失败: {}", e)))?,
"toml" => {
let toml_value: toml::Value = toml::from_str(&content)
.map_err(|e| ConfigError::Parse(format!("解析 TOML 失败: {}", e)))?;
serde_json::to_value(toml_value)
.map_err(|e| ConfigError::Parse(format!("TOML 转 JSON 失败: {}", e)))?
}
other => {
return Err(ConfigError::Source(format!(
"不支持的配置文件格式: .{}(仅支持 .json / .toml)",
other
)));
}
};
let mut map = HashMap::new();
flatten_json("", value, &mut map);
Ok(map)
}
}#[async_trait]
impl ConfigSource for FileConfigSource {
async fn load(&self) -> Result<HashMap<String, Value>, ConfigError> {
self.read_file()
}
async fn watch(
&self,
callback: Box<dyn Fn(HashMap<String, Value>) + Send + Sync>,
) -> Result<(), ConfigError> {
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let _ = tx.send(res);
})
.map_err(|e| ConfigError::Watch(format!("创建文件监听器失败: {}", e)))?;
let watch_dir = self
.path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
watcher
.watch(&watch_dir, RecursiveMode::NonRecursive)
.map_err(|e| ConfigError::Watch(format!("注册监听失败: {}", e)))?;
if let Ok(mut guard) = self.watcher.lock() {
*guard = Some(watcher);
}
let path = self.path.clone();
std::thread::spawn(move || {
for res in rx {
let Ok(event) = res else { continue };
let relevant = matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
);
if !relevant {
continue;
}
if !event.paths.iter().any(|p| *p == path) {
continue;
}
let tmp = FileConfigSource::new(path.clone());
if let Ok(data) = tmp.read_file() {
callback(data);
}
}
});
Ok(())
}
}
pub(crate) fn flatten_json(prefix: &str, value: Value, out: &mut HashMap<String, Value>) {
match value {
Value::Object(map) => {
for (k, v) in map {
let new_prefix = if prefix.is_empty() {
k
} else {
format!("{}.{}", prefix, k)
};
flatten_json(&new_prefix, v, out);
}
}
other => {
let key = if prefix.is_empty() {
"value".to_string()
} else {
prefix.to_string()
};
out.insert(key, other);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn tmp_path(ext: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir().join(format!(
"sz-orm-config-file-{}-{}.{}",
std::process::id(),
n,
ext
))
}
fn write_file(path: &PathBuf, content: &str) {
std::fs::write(path, content).unwrap();
}
#[tokio::test]
async fn test_load_json_flatten() {
let path = tmp_path("json");
write_file(&path, r#"{"db":{"host":"localhost","port":3306},"debug":true}"#);
let src = FileConfigSource::new(&path);
let data = src.load().await.unwrap();
assert_eq!(data.get("db.host"), Some(&Value::from("localhost")));
assert_eq!(data.get("db.port"), Some(&Value::from(3306)));
assert_eq!(data.get("debug"), Some(&Value::from(true)));
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_load_toml_flatten() {
let path = tmp_path("toml");
write_file(&path, "debug = true\n\n[db]\nhost = \"localhost\"\nport = 3306\n");
let src = FileConfigSource::new(&path);
let data = src.load().await.unwrap();
assert_eq!(data.get("db.host"), Some(&Value::from("localhost")));
assert_eq!(data.get("db.port"), Some(&Value::from(3306)));
assert_eq!(data.get("debug"), Some(&Value::from(true)));
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_load_unsupported_format_errors() {
let path = tmp_path("yaml");
write_file(&path, "key: value\n");
let src = FileConfigSource::new(&path);
let err = src.load().await.unwrap_err();
assert!(matches!(err, ConfigError::Source(_)), "got: {:?}", err);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
async fn test_load_missing_file_errors() {
let path = tmp_path("json");
let src = FileConfigSource::new(&path);
let err = src.load().await.unwrap_err();
assert!(matches!(err, ConfigError::Source(_)), "got: {:?}", err);
}
#[tokio::test]
async fn test_load_invalid_json_errors() {
let path = tmp_path("json");
write_file(&path, "{not json");
let src = FileConfigSource::new(&path);
let err = src.load().await.unwrap_err();
assert!(matches!(err, ConfigError::Parse(_)), "got: {:?}", err);
let _ = std::fs::remove_file(&path);
}
#[tokio::test]
#[ignore = "依赖文件系统事件,在某些平台/编辑器下可能不稳定"]
async fn test_watch_detects_modify() {
let path = tmp_path("json");
write_file(&path, r#"{"k":"v1"}"#);
let src = FileConfigSource::new(&path);
let received = std::sync::Arc::new(std::sync::Mutex::new(None));
let r = received.clone();
src.watch(Box::new(move |m| { *r.lock().unwrap() = Some(m); }))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
write_file(&path, r#"{"k":"v2"}"#);
for _ in 0..20 {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
if received.lock().unwrap().is_some() {
break;
}
}
let guard = received.lock().unwrap();
let data = guard.as_ref().expect("应收到变更回调");
assert_eq!(data.get("k"), Some(&Value::from("v2")));
let _ = std::fs::remove_file(&path);
}
}