shortcuts_tui/config/
mod.rs

1//! Configuration module for handling TOML and JSON configuration files.
2//!
3//! This module provides structures and functions for loading, parsing, and validating
4//! configuration files that define tools and shortcuts to be displayed in the TUI.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::path::Path;
10
11pub mod loader;
12
13/// Represents the main configuration structure
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct Config {
16    /// Application metadata
17    #[serde(default)]
18    pub metadata: Metadata,
19    /// List of tools and shortcuts
20    pub tools: Vec<Tool>,
21    /// Optional categories for organizing tools
22    #[serde(default)]
23    pub categories: Vec<Category>,
24}
25
26/// Application metadata
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct Metadata {
29    /// Title of the tool collection
30    #[serde(default = "default_title")]
31    pub title: String,
32    /// Description of the tool collection
33    #[serde(default)]
34    pub description: Option<String>,
35    /// Version of the configuration format
36    #[serde(default = "default_version")]
37    pub version: String,
38    /// Author information
39    #[serde(default)]
40    pub author: Option<String>,
41}
42
43/// Represents a tool or shortcut
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct Tool {
46    /// Unique identifier for the tool
47    pub id: String,
48    /// Display name of the tool
49    pub name: String,
50    /// Optional description
51    #[serde(default)]
52    pub description: Option<String>,
53    /// Command to execute (optional)
54    #[serde(default)]
55    pub command: Option<String>,
56    /// Keyboard shortcut (optional)
57    #[serde(default)]
58    pub shortcut: Option<String>,
59    /// Category this tool belongs to (optional)
60    #[serde(default)]
61    pub category: Option<String>,
62    /// Tags for filtering and searching
63    #[serde(default)]
64    pub tags: Vec<String>,
65    /// Additional metadata as key-value pairs
66    #[serde(default)]
67    pub metadata: HashMap<String, String>,
68    /// Whether the tool is enabled/visible
69    #[serde(default = "default_enabled")]
70    pub enabled: bool,
71    /// Priority for sorting (higher = more important)
72    #[serde(default)]
73    pub priority: i32,
74}
75
76/// Category for organizing tools
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
78pub struct Category {
79    /// Unique identifier for the category
80    pub id: String,
81    /// Display name of the category
82    pub name: String,
83    /// Optional description
84    #[serde(default)]
85    pub description: Option<String>,
86    /// Optional icon or symbol
87    #[serde(default)]
88    pub icon: Option<String>,
89    /// Color theme for the category
90    #[serde(default)]
91    pub color: Option<String>,
92}
93
94/// Configuration file format
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub enum ConfigFormat {
97    /// TOML format
98    Toml,
99    /// JSON format
100    Json,
101}
102
103impl ConfigFormat {
104    /// Detect format from file extension
105    pub fn from_path<P: AsRef<Path>>(path: P) -> Option<Self> {
106        path.as_ref()
107            .extension()
108            .and_then(|ext| ext.to_str())
109            .map(|ext| match ext.to_lowercase().as_str() {
110                "toml" => ConfigFormat::Toml,
111                "json" => ConfigFormat::Json,
112                _ => ConfigFormat::Toml, // Default to TOML
113            })
114    }
115}
116
117impl fmt::Display for ConfigFormat {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        match self {
120            ConfigFormat::Toml => write!(f, "TOML"),
121            ConfigFormat::Json => write!(f, "JSON"),
122        }
123    }
124}
125
126impl Default for Metadata {
127    fn default() -> Self {
128        Self {
129            title: default_title(),
130            description: None,
131            version: default_version(),
132            author: None,
133        }
134    }
135}
136
137impl Default for Config {
138    fn default() -> Self {
139        Self {
140            metadata: Metadata::default(),
141            tools: Vec::new(),
142            categories: Vec::new(),
143        }
144    }
145}
146
147impl Config {
148    /// Create a new empty configuration
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Add a tool to the configuration
154    pub fn add_tool(&mut self, tool: Tool) {
155        self.tools.push(tool);
156    }
157
158    /// Add a category to the configuration
159    pub fn add_category(&mut self, category: Category) {
160        self.categories.push(category);
161    }
162
163    /// Get tools by category
164    pub fn tools_by_category(&self, category_id: &str) -> Vec<&Tool> {
165        self.tools
166            .iter()
167            .filter(|tool| {
168                tool.category
169                    .as_ref()
170                    .map_or(false, |cat| cat == category_id)
171            })
172            .collect()
173    }
174
175    /// Get tools by tag
176    pub fn tools_by_tag(&self, tag: &str) -> Vec<&Tool> {
177        self.tools
178            .iter()
179            .filter(|tool| tool.tags.contains(&tag.to_string()))
180            .collect()
181    }
182
183    /// Get enabled tools sorted by priority
184    pub fn enabled_tools(&self) -> Vec<&Tool> {
185        let mut tools: Vec<&Tool> = self.tools.iter().filter(|tool| tool.enabled).collect();
186        tools.sort_by(|a, b| b.priority.cmp(&a.priority));
187        tools
188    }
189
190    /// Validate configuration
191    pub fn validate(&self) -> Result<(), Vec<String>> {
192        let mut errors = Vec::new();
193
194        // Check for duplicate tool IDs
195        let mut tool_ids = std::collections::HashSet::new();
196        for tool in &self.tools {
197            if !tool_ids.insert(&tool.id) {
198                errors.push(format!("Duplicate tool ID: {}", tool.id));
199            }
200        }
201
202        // Check for duplicate category IDs
203        let mut category_ids = std::collections::HashSet::new();
204        for category in &self.categories {
205            if !category_ids.insert(&category.id) {
206                errors.push(format!("Duplicate category ID: {}", category.id));
207            }
208        }
209
210        // Check that referenced categories exist
211        let valid_categories: std::collections::HashSet<_> =
212            self.categories.iter().map(|c| &c.id).collect();
213        for tool in &self.tools {
214            if let Some(ref category) = tool.category {
215                if !valid_categories.contains(category) {
216                    errors.push(format!(
217                        "Tool '{}' references non-existent category '{}'",
218                        tool.id, category
219                    ));
220                }
221            }
222        }
223
224        if errors.is_empty() {
225            Ok(())
226        } else {
227            Err(errors)
228        }
229    }
230}
231
232impl Tool {
233    /// Create a new tool with required fields
234    pub fn new<S: Into<String>>(id: S, name: S) -> Self {
235        Self {
236            id: id.into(),
237            name: name.into(),
238            description: None,
239            command: None,
240            shortcut: None,
241            category: None,
242            tags: Vec::new(),
243            metadata: HashMap::new(),
244            enabled: true,
245            priority: 0,
246        }
247    }
248
249    /// Builder pattern: set description
250    pub fn with_description<S: Into<String>>(mut self, description: S) -> Self {
251        self.description = Some(description.into());
252        self
253    }
254
255    /// Builder pattern: set command
256    pub fn with_command<S: Into<String>>(mut self, command: S) -> Self {
257        self.command = Some(command.into());
258        self
259    }
260
261    /// Builder pattern: set shortcut
262    pub fn with_shortcut<S: Into<String>>(mut self, shortcut: S) -> Self {
263        self.shortcut = Some(shortcut.into());
264        self
265    }
266
267    /// Builder pattern: set category
268    pub fn with_category<S: Into<String>>(mut self, category: S) -> Self {
269        self.category = Some(category.into());
270        self
271    }
272
273    /// Builder pattern: add tag
274    pub fn with_tag<S: Into<String>>(mut self, tag: S) -> Self {
275        self.tags.push(tag.into());
276        self
277    }
278
279    /// Builder pattern: set priority
280    pub fn with_priority(mut self, priority: i32) -> Self {
281        self.priority = priority;
282        self
283    }
284
285    /// Builder pattern: set enabled status
286    pub fn with_enabled(mut self, enabled: bool) -> Self {
287        self.enabled = enabled;
288        self
289    }
290
291    /// Add metadata entry
292    pub fn add_metadata<K, V>(&mut self, key: K, value: V)
293    where
294        K: Into<String>,
295        V: Into<String>,
296    {
297        self.metadata.insert(key.into(), value.into());
298    }
299}
300
301impl Category {
302    /// Create a new category with required fields
303    pub fn new<S: Into<String>>(id: S, name: S) -> Self {
304        Self {
305            id: id.into(),
306            name: name.into(),
307            description: None,
308            icon: None,
309            color: None,
310        }
311    }
312
313    /// Builder pattern: set description
314    pub fn with_description<S: Into<String>>(mut self, description: S) -> Self {
315        self.description = Some(description.into());
316        self
317    }
318
319    /// Builder pattern: set icon
320    pub fn with_icon<S: Into<String>>(mut self, icon: S) -> Self {
321        self.icon = Some(icon.into());
322        self
323    }
324
325    /// Builder pattern: set color
326    pub fn with_color<S: Into<String>>(mut self, color: S) -> Self {
327        self.color = Some(color.into());
328        self
329    }
330}
331
332fn default_title() -> String {
333    "Tools and Shortcuts".to_string()
334}
335
336fn default_version() -> String {
337    "1.0".to_string()
338}
339
340fn default_enabled() -> bool {
341    true
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_config_default() {
350        let config = Config::default();
351        assert_eq!(config.metadata.title, "Tools and Shortcuts");
352        assert_eq!(config.metadata.version, "1.0");
353        assert!(config.tools.is_empty());
354        assert!(config.categories.is_empty());
355    }
356
357    #[test]
358    fn test_tool_builder() {
359        let tool = Tool::new("test", "Test Tool")
360            .with_description("A test tool")
361            .with_command("test-cmd")
362            .with_shortcut("Ctrl+T")
363            .with_category("testing")
364            .with_tag("test")
365            .with_tag("development")
366            .with_priority(10);
367
368        assert_eq!(tool.id, "test");
369        assert_eq!(tool.name, "Test Tool");
370        assert_eq!(tool.description, Some("A test tool".to_string()));
371        assert_eq!(tool.command, Some("test-cmd".to_string()));
372        assert_eq!(tool.shortcut, Some("Ctrl+T".to_string()));
373        assert_eq!(tool.category, Some("testing".to_string()));
374        assert_eq!(tool.tags, vec!["test", "development"]);
375        assert_eq!(tool.priority, 10);
376        assert!(tool.enabled);
377    }
378
379    #[test]
380    fn test_category_builder() {
381        let category = Category::new("dev", "Development")
382            .with_description("Development tools")
383            .with_icon("🔧")
384            .with_color("blue");
385
386        assert_eq!(category.id, "dev");
387        assert_eq!(category.name, "Development");
388        assert_eq!(category.description, Some("Development tools".to_string()));
389        assert_eq!(category.icon, Some("🔧".to_string()));
390        assert_eq!(category.color, Some("blue".to_string()));
391    }
392
393    #[test]
394    fn test_config_validation() {
395        let mut config = Config::new();
396        
397        // Add categories
398        config.add_category(Category::new("dev", "Development"));
399        config.add_category(Category::new("utils", "Utilities"));
400        
401        // Add valid tools
402        config.add_tool(Tool::new("git", "Git").with_category("dev"));
403        config.add_tool(Tool::new("ls", "List Files").with_category("utils"));
404        
405        assert!(config.validate().is_ok());
406        
407        // Add duplicate tool ID
408        config.add_tool(Tool::new("git", "Another Git Tool"));
409        let errors = config.validate().unwrap_err();
410        assert!(errors.iter().any(|e| e.contains("Duplicate tool ID: git")));
411        
412        // Remove duplicate and add tool with invalid category
413        config.tools.pop();
414        config.add_tool(Tool::new("vim", "Vim Editor").with_category("invalid"));
415        let errors = config.validate().unwrap_err();
416        assert!(errors.iter().any(|e| e.contains("non-existent category 'invalid'")));
417    }
418
419    #[test]
420    fn test_config_format_detection() {
421        assert_eq!(ConfigFormat::from_path("config.toml"), Some(ConfigFormat::Toml));
422        assert_eq!(ConfigFormat::from_path("config.json"), Some(ConfigFormat::Json));
423        assert_eq!(ConfigFormat::from_path("config.txt"), Some(ConfigFormat::Toml)); // Default
424    }
425
426    #[test]
427    fn test_tools_by_category() {
428        let mut config = Config::new();
429        config.add_tool(Tool::new("git", "Git").with_category("dev"));
430        config.add_tool(Tool::new("vim", "Vim").with_category("dev"));
431        config.add_tool(Tool::new("ls", "List").with_category("utils"));
432        
433        let dev_tools = config.tools_by_category("dev");
434        assert_eq!(dev_tools.len(), 2);
435        assert!(dev_tools.iter().any(|t| t.id == "git"));
436        assert!(dev_tools.iter().any(|t| t.id == "vim"));
437    }
438
439    #[test]
440    fn test_enabled_tools_sorting() {
441        let mut config = Config::new();
442        config.add_tool(Tool::new("low", "Low Priority").with_priority(1));
443        config.add_tool(Tool::new("high", "High Priority").with_priority(10));
444        config.add_tool(Tool::new("disabled", "Disabled").with_enabled(false));
445        config.add_tool(Tool::new("medium", "Medium Priority").with_priority(5));
446        
447        let enabled = config.enabled_tools();
448        assert_eq!(enabled.len(), 3);
449        assert_eq!(enabled[0].id, "high");
450        assert_eq!(enabled[1].id, "medium");
451        assert_eq!(enabled[2].id, "low");
452    }
453}