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    /// Video player for `torq play` / the TUI `P` key: a player name (vlc,
53    /// iina, mpv, ffplay), a path to an executable, or "browser". None =
54    /// auto-detect the best installed player (VLC > IINA > mpv > ffplay,
55    /// then the platform opener as a last resort).
56    pub player: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ScheduleEntry {
61    /// "HH:MM" 24h, inclusive.
62    pub start: String,
63    /// "HH:MM" 24h, exclusive. Crossing midnight is allowed.
64    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    /// Load config, creating and persisting defaults (with a fresh auth token)
97    /// if the file is absent — clients need the token on disk.
98    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}