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 pub player: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ScheduleEntry {
61 pub start: String,
63 pub end: String,
65 pub upload_bps: Option<u32>,
66 pub download_bps: Option<u32>,
67}
68
69impl Default for Config {
70 fn default() -> Self {
71 Self {
72 download_dir: default_download_dir(),
73 state_dir: default_state_dir(),
74 trackers: Vec::new(),
75 upload_bps: None,
76 download_bps: None,
77 socks_proxy: None,
78 auth_token: generate_token(),
79 api_port: 8170,
80 watch_dirs: Vec::new(),
81 library_dirs: Vec::new(),
82 schedule: Vec::new(),
83 player: None,
84 }
85 }
86}
87
88impl Config {
89 pub fn config_file() -> PathBuf {
90 dirs::config_dir()
91 .unwrap_or_else(|| dirs::home_dir().expect("home dir"))
92 .join(APP_NAME)
93 .join("config.toml")
94 }
95
96 pub fn load() -> anyhow::Result<Self> {
99 let path = Self::config_file();
100 let cfg = match fs::read_to_string(&path) {
101 Ok(raw) => toml::from_str::<Self>(&raw)
102 .with_context(|| format!("parsing {}", path.display()))?,
103 Err(_) => {
104 let cfg = Self::default();
105 cfg.save()?;
106 cfg
107 }
108 };
109 fs::create_dir_all(&cfg.state_dir)
110 .with_context(|| format!("creating state dir {}", cfg.state_dir.display()))?;
111 Ok(cfg)
112 }
113
114 pub fn save(&self) -> anyhow::Result<()> {
115 let path = Self::config_file();
116 fs::create_dir_all(path.parent().expect("config parent"))
117 .with_context(|| format!("creating config dir {}", path.display()))?;
118 let raw = toml::to_string_pretty(self).context("serializing config")?;
119 fs::write(&path, raw).with_context(|| format!("writing {}", path.display()))
120 }
121}
122
123fn generate_token() -> String {
124 let mut bytes = [0u8; 16];
125 getrandom::getrandom(&mut bytes).expect("OS randomness");
126 bytes.iter().map(|b| format!("{b:02x}")).collect()
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn default_roundtrips() {
135 let cfg = Config::default();
136 let raw = toml::to_string(&cfg).unwrap();
137 let back: Config = toml::from_str(&raw).unwrap();
138 assert_eq!(back.auth_token, cfg.auth_token);
139 assert_eq!(back.download_dir, cfg.download_dir);
140 }
141
142 #[test]
143 fn missing_token_is_generated() {
144 let raw = "download_dir = \"/tmp/dl\"\n";
145 let cfg: Config = toml::from_str(raw).unwrap();
146 assert_eq!(cfg.auth_token.len(), 32);
147 assert_ne!(cfg.auth_token, Config::default().auth_token);
148 }
149}