1use std::env;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6use serde_json::{Map, Value, json};
7
8pub fn config_root() -> PathBuf {
9 if let Some(path) = env::var_os("DOUYIN_HOME").filter(|value| !value.is_empty()) {
10 return PathBuf::from(path);
11 }
12
13 if cfg!(windows)
14 && let Some(path) = env::var_os("APPDATA").filter(|value| !value.is_empty())
15 {
16 return PathBuf::from(path).join("douyin-cli");
17 }
18
19 if let Some(path) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
20 return PathBuf::from(path).join("douyin-cli");
21 }
22
23 home_dir().join(".config").join("douyin-cli")
24}
25
26pub fn settings_file() -> PathBuf {
27 config_root().join("config").join("settings.json")
28}
29
30pub fn load() -> io::Result<Value> {
31 let path = settings_file();
32 let mut settings = defaults();
33 match fs::read_to_string(&path) {
34 Ok(text) => {
35 let stored: Value = serde_json::from_str(&text).map_err(|error| {
36 io::Error::new(
37 io::ErrorKind::InvalidData,
38 format!("配置文件不是合法 JSON({}): {error}", path.display()),
39 )
40 })?;
41 merge(&mut settings, stored);
42 Ok(settings)
43 }
44 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(settings),
45 Err(error) => Err(error),
46 }
47}
48
49pub fn save(settings: &Value) -> io::Result<()> {
50 let path = settings_file();
51 let parent = path
52 .parent()
53 .ok_or_else(|| io::Error::other("配置文件路径缺少父目录"))?;
54 fs::create_dir_all(parent)?;
55
56 let temporary = temporary_path(&path);
57 let bytes = serde_json::to_vec_pretty(settings)
58 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
59 fs::write(&temporary, bytes)?;
60 if cfg!(windows) && path.exists() {
61 fs::remove_file(&path)?;
62 }
63 fs::rename(temporary, path)
64}
65
66pub fn openapi(settings: &Value) -> Map<String, Value> {
67 settings
68 .get("openapi")
69 .and_then(Value::as_object)
70 .cloned()
71 .unwrap_or_default()
72}
73
74pub fn defaults() -> Value {
75 json!({
76 "cookie": "",
77 "openapi": {
78 "clientKey": "",
79 "clientSecret": "",
80 "redirectUri": "",
81 "scopes": [],
82 "accessToken": "",
83 "refreshToken": "",
84 "openId": "",
85 "expiresIn": 0
86 },
87 "userAgent": "",
88 "downloadPath": home_dir().join("Downloads").join("douyin").to_string_lossy(),
89 "enableIncrementalFetch": true,
90 "enableDownloadTitle": false,
91 "enableDownloadCover": false,
92 "filenameFields": ["id", "title"],
93 "filenameSeparator": "_"
94 })
95}
96
97fn home_dir() -> PathBuf {
98 env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
99 .map(PathBuf::from)
100 .unwrap_or_else(|| PathBuf::from("."))
101}
102
103fn merge(base: &mut Value, overlay: Value) {
104 match (base, overlay) {
105 (Value::Object(base), Value::Object(overlay)) => {
106 for (key, value) in overlay {
107 if let Some(existing) = base.get_mut(&key) {
108 merge(existing, value);
109 } else {
110 base.insert(key, value);
111 }
112 }
113 }
114 (base, overlay) => *base = overlay,
115 }
116}
117
118fn temporary_path(path: &Path) -> PathBuf {
119 let name = path
120 .file_name()
121 .and_then(|name| name.to_str())
122 .unwrap_or("settings.json");
123 path.with_file_name(format!(".{name}.tmp-{}", std::process::id()))
124}
125
126#[cfg(test)]
127mod tests {
128 use super::merge;
129 use serde_json::json;
130
131 #[test]
132 fn merge_preserves_defaults_and_unknown_fields() {
133 let mut base = json!({"cookie": "", "openapi": {"openId": ""}});
134 merge(
135 &mut base,
136 json!({"cookie": "sessionid=x", "openapi": {"custom": true}}),
137 );
138 assert_eq!(base["cookie"], "sessionid=x");
139 assert_eq!(base["openapi"]["openId"], "");
140 assert_eq!(base["openapi"]["custom"], true);
141 }
142}