Skip to main content

douyin_cli/
settings.rs

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}