use crate::constants::{
ALIAS_EXISTS_SECTIONS, ALIAS_PATH_SECTIONS, ALL_SECTIONS, APP_NAME, AUTHOR, CONFIG_FILE,
DATA_DIR, DATA_PATH_ENV, DEFAULT_SEARCH_ENGINE, EMAIL, NOTEBOOK_DIR, REPORT_DEFAULT_FILE,
REPORT_DIR, SCRIPTS_DIR, VERSION, config_key, section,
};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct YamlConfig {
#[serde(default)]
pub path: BTreeMap<String, String>,
#[serde(default)]
pub inner_url: BTreeMap<String, String>,
#[serde(default)]
pub outer_url: BTreeMap<String, String>,
#[serde(default)]
pub editor: BTreeMap<String, String>,
#[serde(default)]
pub browser: BTreeMap<String, String>,
#[serde(default)]
pub vpn: BTreeMap<String, String>,
#[serde(default)]
pub script: BTreeMap<String, String>,
#[serde(default)]
pub version: BTreeMap<String, String>,
#[serde(default)]
pub setting: BTreeMap<String, String>,
#[serde(default)]
pub log: BTreeMap<String, String>,
#[serde(default)]
pub report: BTreeMap<String, String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_yaml::Value>,
}
impl YamlConfig {
pub fn data_dir() -> PathBuf {
if let Ok(path) = std::env::var(DATA_PATH_ENV) {
return PathBuf::from(path);
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(DATA_DIR)
}
fn config_path() -> PathBuf {
Self::data_dir().join(CONFIG_FILE)
}
pub fn scripts_dir() -> PathBuf {
let dir = Self::data_dir().join(SCRIPTS_DIR);
let _ = fs::create_dir_all(&dir);
dir
}
pub fn report_dir() -> PathBuf {
let dir = Self::data_dir().join(REPORT_DIR);
let _ = fs::create_dir_all(&dir);
dir
}
pub fn notebook_dir() -> PathBuf {
let dir = Self::data_dir().join(NOTEBOOK_DIR);
let _ = fs::create_dir_all(&dir);
dir
}
pub fn report_file_path(&self) -> PathBuf {
if let Some(custom_path) = self.get_property(section::REPORT, config_key::WEEK_REPORT)
&& !custom_path.is_empty()
{
return Self::expand_tilde(custom_path);
}
Self::report_dir().join(REPORT_DEFAULT_FILE)
}
fn expand_tilde(path: &str) -> PathBuf {
if path.starts_with('~')
&& let Some(home) = dirs::home_dir()
{
if path == "~" {
return home;
} else if let Some(stripped) = path.strip_prefix("~/") {
return home.join(stripped);
}
}
PathBuf::from(path)
}
pub fn load() -> Self {
let path = Self::config_path();
if !path.exists() {
let config = Self::default_config();
eprintln!("[INFO] 创建默认配置文件: {:?}", path);
config.save_direct();
return config;
}
let content = fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("[ERROR] 读取配置文件失败: {}, 路径: {:?}", e, path);
String::new()
});
serde_yaml::from_str(&content).unwrap_or_else(|e| {
eprintln!("[ERROR] 解析配置文件失败: {}, 路径: {:?}", e, path);
Self::default_config()
})
}
fn save_direct(&self) {
let path = Self::config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap_or_else(|e| {
eprintln!("[ERROR] 创建配置目录失败: {}", e);
});
}
let content = serde_yaml::to_string(self).unwrap_or_else(|e| {
eprintln!("[ERROR] 序列化配置失败: {}", e);
String::new()
});
fs::write(&path, content).unwrap_or_else(|e| {
eprintln!("[ERROR] 保存配置文件失败: {}, 路径: {:?}", e, path);
});
}
pub fn save(&mut self) {
let path = Self::config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap_or_else(|e| {
eprintln!("[ERROR] 创建配置目录失败: {}", e);
});
}
let lock_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.unwrap_or_else(|e| {
eprintln!("[ERROR] 打开配置文件失败: {}", e);
panic!("无法打开配置文件");
});
lock_file.lock_exclusive().unwrap_or_else(|e| {
eprintln!("[ERROR] 获取文件锁失败: {}", e);
});
let content = fs::read_to_string(&path).unwrap_or_default();
let mut fresh: YamlConfig = serde_yaml::from_str(&content).unwrap_or_default();
fresh.path = self.path.clone();
fresh.inner_url = self.inner_url.clone();
fresh.outer_url = self.outer_url.clone();
fresh.editor = self.editor.clone();
fresh.browser = self.browser.clone();
fresh.vpn = self.vpn.clone();
fresh.script = self.script.clone();
fresh.version = self.version.clone();
fresh.setting = self.setting.clone();
fresh.log = self.log.clone();
fresh.report = self.report.clone();
let output = serde_yaml::to_string(&fresh).unwrap_or_else(|e| {
eprintln!("[ERROR] 序列化配置失败: {}", e);
String::new()
});
fs::write(&path, output).unwrap_or_else(|e| {
eprintln!("[ERROR] 保存配置文件失败: {}, 路径: {:?}", e, path);
});
self.extra = fresh.extra;
}
fn default_config() -> Self {
let mut config = Self::default();
config.version.insert("name".into(), APP_NAME.into());
config.version.insert("version".into(), VERSION.into());
config.version.insert("author".into(), AUTHOR.into());
config.version.insert("email".into(), EMAIL.into());
config
.log
.insert(config_key::MODE.into(), config_key::CONCISE.into());
config.setting.insert(
config_key::SEARCH_ENGINE.into(),
DEFAULT_SEARCH_ENGINE.into(),
);
config
}
pub fn is_verbose(&self) -> bool {
self.log
.get(config_key::MODE)
.is_some_and(|m| m == config_key::VERBOSE)
}
pub fn get_section(&self, s: &str) -> Option<&BTreeMap<String, String>> {
match s {
section::PATH => Some(&self.path),
section::INNER_URL => Some(&self.inner_url),
section::OUTER_URL => Some(&self.outer_url),
section::EDITOR => Some(&self.editor),
section::BROWSER => Some(&self.browser),
section::VPN => Some(&self.vpn),
section::SCRIPT => Some(&self.script),
section::VERSION => Some(&self.version),
section::SETTING => Some(&self.setting),
section::LOG => Some(&self.log),
section::REPORT => Some(&self.report),
_ => None,
}
}
pub fn get_section_mut(&mut self, s: &str) -> Option<&mut BTreeMap<String, String>> {
match s {
section::PATH => Some(&mut self.path),
section::INNER_URL => Some(&mut self.inner_url),
section::OUTER_URL => Some(&mut self.outer_url),
section::EDITOR => Some(&mut self.editor),
section::BROWSER => Some(&mut self.browser),
section::VPN => Some(&mut self.vpn),
section::SCRIPT => Some(&mut self.script),
section::VERSION => Some(&mut self.version),
section::SETTING => Some(&mut self.setting),
section::LOG => Some(&mut self.log),
section::REPORT => Some(&mut self.report),
_ => None,
}
}
pub fn contains(&self, section: &str, key: &str) -> bool {
self.get_section(section)
.is_some_and(|m| m.contains_key(key))
}
pub fn get_property(&self, section: &str, key: &str) -> Option<&String> {
self.get_section(section).and_then(|m| m.get(key))
}
pub fn set_property(&mut self, section: &str, key: &str, value: &str) {
if let Some(map) = self.get_section_mut(section) {
map.insert(key.to_string(), value.to_string());
self.save();
}
}
pub fn remove_property(&mut self, section: &str, key: &str) {
if let Some(map) = self.get_section_mut(section) {
map.remove(key);
self.save();
}
}
pub fn rename_property(&mut self, section: &str, old_key: &str, new_key: &str) {
if let Some(map) = self.get_section_mut(section)
&& let Some(value) = map.remove(old_key)
{
map.insert(new_key.to_string(), value);
self.save();
}
}
pub fn all_section_names(&self) -> &'static [&'static str] {
ALL_SECTIONS
}
pub fn alias_exists(&self, alias: &str) -> bool {
ALIAS_EXISTS_SECTIONS
.iter()
.any(|s| self.contains(s, alias))
}
pub fn get_path_by_alias(&self, alias: &str) -> Option<&String> {
ALIAS_PATH_SECTIONS
.iter()
.find_map(|s| self.get_property(s, alias))
}
pub fn collect_alias_envs(&self) -> Vec<(String, String)> {
let sections = &[
section::PATH,
section::INNER_URL,
section::OUTER_URL,
section::SCRIPT,
];
let mut envs = Vec::new();
let mut seen = std::collections::HashSet::new();
for &sec in sections {
if let Some(map) = self.get_section(sec) {
for (alias, value) in map {
let env_key = format!("J_{}", alias.replace('-', "_").to_uppercase());
if seen.insert(env_key.clone()) {
envs.push((env_key, value.clone()));
}
}
}
}
envs
}
}