| 1 | //! TOML configuration for garshot. |
| 2 | //! |
| 3 | //! Fallback configuration when not using Lua (gar integration). |
| 4 | |
| 5 | use std::path::PathBuf; |
| 6 | |
| 7 | use serde::{Deserialize, Serialize}; |
| 8 | |
| 9 | use crate::error::{GarshotError, Result}; |
| 10 | |
| 11 | /// Main configuration structure. |
| 12 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 13 | #[serde(default)] |
| 14 | pub struct Config { |
| 15 | /// General settings. |
| 16 | pub general: GeneralSettings, |
| 17 | /// Selection overlay settings. |
| 18 | pub selection: SelectionSettings, |
| 19 | /// Naming settings. |
| 20 | pub naming: NamingSettings, |
| 21 | } |
| 22 | |
| 23 | impl Default for Config { |
| 24 | fn default() -> Self { |
| 25 | Self { |
| 26 | general: GeneralSettings::default(), |
| 27 | selection: SelectionSettings::default(), |
| 28 | naming: NamingSettings::default(), |
| 29 | } |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | /// General screenshot settings. |
| 34 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 35 | #[serde(default)] |
| 36 | pub struct GeneralSettings { |
| 37 | /// Directory to save screenshots. |
| 38 | pub save_dir: PathBuf, |
| 39 | /// Default output format (png, jpeg, webp, ppm). |
| 40 | pub format: String, |
| 41 | /// JPEG/WebP quality (1-100). |
| 42 | pub quality: u8, |
| 43 | /// Include cursor in screenshots by default. |
| 44 | pub include_cursor: bool, |
| 45 | } |
| 46 | |
| 47 | impl Default for GeneralSettings { |
| 48 | fn default() -> Self { |
| 49 | let save_dir = dirs::picture_dir() |
| 50 | .unwrap_or_else(|| PathBuf::from(".")) |
| 51 | .join("Screenshots"); |
| 52 | |
| 53 | Self { |
| 54 | save_dir, |
| 55 | format: "png".to_string(), |
| 56 | quality: 90, |
| 57 | include_cursor: false, |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | /// Selection overlay settings. |
| 63 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 64 | #[serde(default)] |
| 65 | pub struct SelectionSettings { |
| 66 | /// Blur radius in pixels. |
| 67 | pub blur_radius: usize, |
| 68 | /// Selection line color (hex format: #RRGGBB). |
| 69 | pub line_color: String, |
| 70 | /// Selection line width in pixels. |
| 71 | pub line_width: u32, |
| 72 | } |
| 73 | |
| 74 | impl Default for SelectionSettings { |
| 75 | fn default() -> Self { |
| 76 | Self { |
| 77 | blur_radius: 15, |
| 78 | line_color: "#ff6600".to_string(), |
| 79 | line_width: 2, |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | impl SelectionSettings { |
| 85 | /// Parse the line color as a u32 RGB value. |
| 86 | pub fn line_color_u32(&self) -> u32 { |
| 87 | parse_color(&self.line_color).unwrap_or(0xFF6600) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | /// Naming settings for screenshot filenames. |
| 92 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 93 | #[serde(default)] |
| 94 | pub struct NamingSettings { |
| 95 | /// Filename pattern with strftime and custom tokens. |
| 96 | /// Supports: %Y, %m, %d, %H, %M, %S (strftime) |
| 97 | /// Custom: %n (daily counter), %N (global counter), %w (window name) |
| 98 | pub pattern: String, |
| 99 | } |
| 100 | |
| 101 | impl Default for NamingSettings { |
| 102 | fn default() -> Self { |
| 103 | Self { |
| 104 | pattern: "screenshot-%Y%m%d-%H%M%S".to_string(), |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// Load configuration from file. |
| 110 | /// |
| 111 | /// Tries to load from: |
| 112 | /// 1. `~/.config/garshot/config.toml` |
| 113 | /// 2. `~/.config/gar/init.lua` (extracts gar.shot table) - TODO |
| 114 | /// 3. Default configuration |
| 115 | pub fn load_config() -> Result<Config> { |
| 116 | let config_path = config_path(); |
| 117 | |
| 118 | if config_path.exists() { |
| 119 | tracing::debug!("Loading config from {}", config_path.display()); |
| 120 | let content = std::fs::read_to_string(&config_path).map_err(|e| { |
| 121 | GarshotError::ConfigError(format!("Failed to read config file: {}", e)) |
| 122 | })?; |
| 123 | |
| 124 | let mut config: Config = toml::from_str(&content).map_err(|e| { |
| 125 | GarshotError::ConfigError(format!("Failed to parse config file: {}", e)) |
| 126 | })?; |
| 127 | |
| 128 | // Expand tilde in save_dir |
| 129 | config.general.save_dir = expand_tilde(&config.general.save_dir); |
| 130 | |
| 131 | Ok(config) |
| 132 | } else { |
| 133 | tracing::debug!("No config file found, using defaults"); |
| 134 | Ok(Config::default()) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | /// Expand leading `~` in a path to the user's home directory. |
| 139 | fn expand_tilde(path: &PathBuf) -> PathBuf { |
| 140 | let path_str = path.to_string_lossy(); |
| 141 | if path_str == "~" { |
| 142 | dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) |
| 143 | } else if let Some(rest) = path_str.strip_prefix("~/") { |
| 144 | dirs::home_dir() |
| 145 | .unwrap_or_else(|| PathBuf::from(".")) |
| 146 | .join(rest) |
| 147 | } else { |
| 148 | path.clone() |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | /// Get the config file path. |
| 153 | pub fn config_path() -> PathBuf { |
| 154 | dirs::config_dir() |
| 155 | .unwrap_or_else(|| PathBuf::from(".")) |
| 156 | .join("garshot") |
| 157 | .join("config.toml") |
| 158 | } |
| 159 | |
| 160 | /// Parse a color string (#RRGGBB or RRGGBB) to u32. |
| 161 | fn parse_color(s: &str) -> Option<u32> { |
| 162 | let s = s.trim_start_matches('#'); |
| 163 | if s.len() != 6 { |
| 164 | return None; |
| 165 | } |
| 166 | u32::from_str_radix(s, 16).ok() |
| 167 | } |
| 168 | |
| 169 | #[cfg(test)] |
| 170 | mod tests { |
| 171 | use super::*; |
| 172 | |
| 173 | #[test] |
| 174 | fn test_parse_color() { |
| 175 | assert_eq!(parse_color("#ff6600"), Some(0xFF6600)); |
| 176 | assert_eq!(parse_color("ff6600"), Some(0xFF6600)); |
| 177 | assert_eq!(parse_color("#000000"), Some(0x000000)); |
| 178 | assert_eq!(parse_color("#ffffff"), Some(0xFFFFFF)); |
| 179 | assert_eq!(parse_color("invalid"), None); |
| 180 | } |
| 181 | |
| 182 | #[test] |
| 183 | fn test_default_config() { |
| 184 | let config = Config::default(); |
| 185 | assert_eq!(config.general.format, "png"); |
| 186 | assert_eq!(config.selection.blur_radius, 15); |
| 187 | assert!(config.naming.pattern.contains("screenshot")); |
| 188 | } |
| 189 | |
| 190 | #[test] |
| 191 | fn test_deserialize_config() { |
| 192 | let toml_str = concat!( |
| 193 | "[general]\n", |
| 194 | "format = \"jpeg\"\n", |
| 195 | "quality = 85\n", |
| 196 | "include_cursor = true\n", |
| 197 | "\n", |
| 198 | "[selection]\n", |
| 199 | "blur_radius = 20\n", |
| 200 | "line_color = \"#00ff00\"\n", |
| 201 | "line_width = 3\n", |
| 202 | "\n", |
| 203 | "[naming]\n", |
| 204 | "pattern = \"shot-date\"\n", |
| 205 | ); |
| 206 | let config: Config = toml::from_str(toml_str).unwrap(); |
| 207 | assert_eq!(config.general.format, "jpeg"); |
| 208 | assert_eq!(config.general.quality, 85); |
| 209 | assert!(config.general.include_cursor); |
| 210 | assert_eq!(config.selection.blur_radius, 20); |
| 211 | assert_eq!(config.selection.line_color, "#00ff00"); |
| 212 | assert_eq!(config.naming.pattern, "shot-date"); |
| 213 | } |
| 214 | |
| 215 | #[test] |
| 216 | fn test_expand_tilde() { |
| 217 | let home = dirs::home_dir().unwrap(); |
| 218 | |
| 219 | // ~/foo/bar should expand |
| 220 | let path = PathBuf::from("~/Pictures/Screenshots"); |
| 221 | let expanded = expand_tilde(&path); |
| 222 | assert_eq!(expanded, home.join("Pictures/Screenshots")); |
| 223 | |
| 224 | // Just ~ should expand to home |
| 225 | let path = PathBuf::from("~"); |
| 226 | let expanded = expand_tilde(&path); |
| 227 | assert_eq!(expanded, home); |
| 228 | |
| 229 | // Absolute paths should not change |
| 230 | let path = PathBuf::from("/tmp/screenshots"); |
| 231 | let expanded = expand_tilde(&path); |
| 232 | assert_eq!(expanded, PathBuf::from("/tmp/screenshots")); |
| 233 | |
| 234 | // Relative paths should not change |
| 235 | let path = PathBuf::from("screenshots"); |
| 236 | let expanded = expand_tilde(&path); |
| 237 | assert_eq!(expanded, PathBuf::from("screenshots")); |
| 238 | |
| 239 | // Tilde in middle should not expand |
| 240 | let path = PathBuf::from("/foo/~/bar"); |
| 241 | let expanded = expand_tilde(&path); |
| 242 | assert_eq!(expanded, PathBuf::from("/foo/~/bar")); |
| 243 | } |
| 244 | } |
| 245 |