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