Skip to main content

torq_core/
config.rs

1//! Persistent daemon configuration.
2//!
3//! Plain TOML at `~/.config/torq/config.toml` (macOS: `~/Library/Application
4//! Support/torq/config.toml`). The auth token is generated on first run and
5//! lives here so local clients (TUI/CLI) can read it without user setup.
6
7use 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    /// Where finished downloads land.
29    pub download_dir: PathBuf,
30    /// Where the engine session, queue, and library index live.
31    pub state_dir: PathBuf,
32    /// Extra tracker URLs announced for every torrent.
33    pub trackers: Vec<String>,
34    /// Global upload ceiling in bytes/sec (None = unlimited).
35    pub upload_bps: Option<u32>,
36    /// Global download ceiling in bytes/sec (None = unlimited).
37    pub download_bps: Option<u32>,
38    /// SOCKS5 proxy URL for all outbound traffic (trackers, peers, sources).
39    pub socks_proxy: Option<String>,
40    /// Bearer token for the REST API. Generated on first run.
41    pub auth_token: String,
42    /// Port for the REST API (bound to 127.0.0.1 only).
43    pub api_port: u16,
44    /// Folders watched for dropped .torrent / magnet files.
45    pub watch_dirs: Vec<PathBuf>,
46    /// Directories scanned for .torrent files whose data is already on disk;
47    /// re-adding a matching infohash cross-seeds from the existing files
48    /// (convention: torrent files live next to their data).
49    pub library_dirs: Vec<PathBuf>,
50    /// Time-of-day bandwidth schedule; empty = always use the flat limits.
51    pub schedule: Vec<ScheduleEntry>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ScheduleEntry {
56    /// "HH:MM" 24h, inclusive.
57    pub start: String,
58    /// "HH:MM" 24h, exclusive. Crossing midnight is allowed.
59    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    /// Load config, creating and persisting defaults (with a fresh auth token)
91    /// if the file is absent — clients need the token on disk.
92    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}