Skip to main content

douyin_cli/
settings.rs

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}