1use std::fs;
8use std::path::PathBuf;
9
10use anyhow::Context;
11use serde::{Deserialize, Serialize};
12
13use crate::APP_NAME;
14
15fn default_download_dir() -> PathBuf {
16 dirs::download_dir().unwrap_or_else(|| dirs::home_dir().expect("home dir"))
17}
18
19fn default_state_dir() -> PathBuf {
20 dirs::data_local_dir()
21 .unwrap_or_else(|| dirs::home_dir().expect("home dir"))
22 .join(APP_NAME)
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(default)]
27pub struct Config {
28 pub download_dir: PathBuf,
30 pub state_dir: PathBuf,
32 pub trackers: Vec<String>,
34 pub upload_bps: Option<u32>,
36 pub download_bps: Option<u32>,
38 pub socks_proxy: Option<String>,
40 pub auth_token: String,
42 pub api_port: u16,
44 pub watch_dirs: Vec<PathBuf>,
46 pub library_dirs: Vec<PathBuf>,
50 pub schedule: Vec<ScheduleEntry>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ScheduleEntry {
56 pub start: String,
58 pub end: String,
60 pub upload_bps: Option<u32>,
61 pub download_bps: Option<u32>,
62}
63
64impl Default for Config {
65 fn default() -> Self {
66 Self {
67 download_dir: default_download_dir(),
68 state_dir: default_state_dir(),
69 trackers: Vec::new(),
70 upload_bps: None,
71 download_bps: None,
72 socks_proxy: None,
73 auth_token: generate_token(),
74 api_port: 8170,
75 watch_dirs: Vec::new(),
76 library_dirs: Vec::new(),
77 schedule: Vec::new(),
78 }
79 }
80}
81
82impl Config {
83 pub fn config_file() -> PathBuf {
84 dirs::config_dir()
85 .unwrap_or_else(|| dirs::home_dir().expect("home dir"))
86 .join(APP_NAME)
87 .join("config.toml")
88 }
89
90 pub fn load() -> anyhow::Result<Self> {
93 let path = Self::config_file();
94 let cfg = match fs::read_to_string(&path) {
95 Ok(raw) => toml::from_str::<Self>(&raw)
96 .with_context(|| format!("parsing {}", path.display()))?,
97 Err(_) => {
98 let cfg = Self::default();
99 cfg.save()?;
100 cfg
101 }
102 };
103 fs::create_dir_all(&cfg.state_dir)
104 .with_context(|| format!("creating state dir {}", cfg.state_dir.display()))?;
105 Ok(cfg)
106 }
107
108 pub fn save(&self) -> anyhow::Result<()> {
109 let path = Self::config_file();
110 fs::create_dir_all(path.parent().expect("config parent"))
111 .with_context(|| format!("creating config dir {}", path.display()))?;
112 let raw = toml::to_string_pretty(self).context("serializing config")?;
113 fs::write(&path, raw).with_context(|| format!("writing {}", path.display()))
114 }
115}
116
117fn generate_token() -> String {
118 let mut bytes = [0u8; 16];
119 getrandom::getrandom(&mut bytes).expect("OS randomness");
120 bytes.iter().map(|b| format!("{b:02x}")).collect()
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn default_roundtrips() {
129 let cfg = Config::default();
130 let raw = toml::to_string(&cfg).unwrap();
131 let back: Config = toml::from_str(&raw).unwrap();
132 assert_eq!(back.auth_token, cfg.auth_token);
133 assert_eq!(back.download_dir, cfg.download_dir);
134 }
135
136 #[test]
137 fn missing_token_is_generated() {
138 let raw = "download_dir = \"/tmp/dl\"\n";
139 let cfg: Config = toml::from_str(raw).unwrap();
140 assert_eq!(cfg.auth_token.len(), 32);
141 assert_ne!(cfg.auth_token, Config::default().auth_token);
142 }
143}