ricecoder_cli/commands/
custom_storage.rs

1// Storage integration for custom commands
2// Handles loading and saving custom commands to ricecoder-storage
3
4use crate::error::{CliError, CliResult};
5use ricecoder_commands::{CommandDefinition, CommandRegistry, ConfigManager};
6use ricecoder_storage::PathResolver;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Custom commands storage manager
11pub struct CustomCommandsStorage {
12    global_path: PathBuf,
13    project_path: Option<PathBuf>,
14}
15
16impl CustomCommandsStorage {
17    /// Create a new custom commands storage manager
18    pub fn new() -> CliResult<Self> {
19        let global_path =
20            PathResolver::resolve_global_path().map_err(|e| CliError::Internal(e.to_string()))?;
21
22        let project_path = if PathResolver::resolve_project_path().exists() {
23            Some(PathResolver::resolve_project_path())
24        } else {
25            None
26        };
27
28        Ok(Self {
29            global_path,
30            project_path,
31        })
32    }
33
34    /// Get the commands directory path
35    fn commands_dir(&self, use_project: bool) -> PathBuf {
36        if use_project {
37            if let Some(project_path) = &self.project_path {
38                return project_path.join("commands");
39            }
40        }
41        self.global_path.join("commands")
42    }
43
44    /// Load all custom commands from storage
45    pub fn load_all(&self) -> CliResult<CommandRegistry> {
46        let mut registry = CommandRegistry::new();
47
48        // Load from global storage first
49        let global_commands_dir = self.commands_dir(false);
50        if global_commands_dir.exists() {
51            self.load_from_directory(&global_commands_dir, &mut registry)?;
52        }
53
54        // Load from project storage (overrides global)
55        if let Some(project_path) = &self.project_path {
56            let project_commands_dir = project_path.join("commands");
57            if project_commands_dir.exists() {
58                self.load_from_directory(&project_commands_dir, &mut registry)?;
59            }
60        }
61
62        Ok(registry)
63    }
64
65    /// Load commands from a specific directory
66    fn load_from_directory(&self, dir: &Path, registry: &mut CommandRegistry) -> CliResult<()> {
67        if !dir.is_dir() {
68            return Ok(());
69        }
70
71        for entry in fs::read_dir(dir).map_err(CliError::Io)? {
72            let entry = entry.map_err(CliError::Io)?;
73            let path = entry.path();
74
75            if path.is_file() {
76                let file_name = path.file_name().unwrap().to_string_lossy();
77
78                // Try to load as JSON or YAML
79                if file_name.ends_with(".json")
80                    || file_name.ends_with(".yaml")
81                    || file_name.ends_with(".yml")
82                {
83                    match ConfigManager::load_from_file(&path) {
84                        Ok(loaded_registry) => {
85                            // Merge loaded commands into registry
86                            for cmd in loaded_registry.list_all() {
87                                // Ignore duplicates (project overrides global)
88                                let _ = registry.register(cmd);
89                            }
90                        }
91                        Err(e) => {
92                            // Log warning but continue loading other files
93                            eprintln!(
94                                "Warning: Failed to load commands from {}: {}",
95                                path.display(),
96                                e
97                            );
98                        }
99                    }
100                }
101            }
102        }
103
104        Ok(())
105    }
106
107    /// Save a command to storage
108    pub fn save_command(&self, cmd: &CommandDefinition) -> CliResult<PathBuf> {
109        // Determine target directory (prefer project if available)
110        let use_project = self.project_path.is_some();
111        let target_dir = self.commands_dir(use_project);
112
113        // Create directory if it doesn't exist
114        fs::create_dir_all(&target_dir).map_err(CliError::Io)?;
115
116        // Save as JSON with commands wrapper
117        let file_name = format!("{}.json", cmd.id);
118        let file_path = target_dir.join(&file_name);
119
120        // Create a wrapper with commands array
121        let config = serde_json::json!({
122            "commands": [cmd]
123        });
124
125        // Serialize to JSON
126        let json_str =
127            serde_json::to_string_pretty(&config).map_err(|e| CliError::Internal(e.to_string()))?;
128
129        // Write file
130        fs::write(&file_path, json_str).map_err(CliError::Io)?;
131
132        Ok(file_path)
133    }
134
135    /// Delete a command from storage
136    pub fn delete_command(&self, command_id: &str) -> CliResult<()> {
137        // Try project storage first
138        if let Some(project_path) = &self.project_path {
139            let project_commands_dir = project_path.join("commands");
140            let file_path = project_commands_dir.join(format!("{}.json", command_id));
141            if file_path.exists() {
142                fs::remove_file(&file_path).map_err(CliError::Io)?;
143                return Ok(());
144            }
145        }
146
147        // Try global storage
148        let global_commands_dir = self.commands_dir(false);
149        let file_path = global_commands_dir.join(format!("{}.json", command_id));
150        if file_path.exists() {
151            fs::remove_file(&file_path).map_err(CliError::Io)?;
152            return Ok(());
153        }
154
155        Err(CliError::InvalidArgument {
156            message: format!("Command '{}' not found in storage", command_id),
157        })
158    }
159
160    /// Get the global storage path
161    pub fn global_path(&self) -> &PathBuf {
162        &self.global_path
163    }
164
165    /// Get the project storage path if available
166    pub fn project_path(&self) -> Option<&PathBuf> {
167        self.project_path.as_ref()
168    }
169}
170
171impl Default for CustomCommandsStorage {
172    fn default() -> Self {
173        Self::new().unwrap_or_else(|_| {
174            // Fallback if storage initialization fails
175            Self {
176                global_path: PathBuf::from("."),
177                project_path: None,
178            }
179        })
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use tempfile::TempDir;
187
188    #[test]
189    fn test_storage_creation() {
190        let storage = CustomCommandsStorage::new();
191        assert!(storage.is_ok());
192    }
193
194    #[test]
195    fn test_load_empty_storage() {
196        let temp_dir = TempDir::new().unwrap();
197        let storage = CustomCommandsStorage {
198            global_path: temp_dir.path().to_path_buf(),
199            project_path: None,
200        };
201
202        let registry = storage.load_all().unwrap();
203        assert_eq!(registry.list_all().len(), 0);
204    }
205
206    #[test]
207    fn test_save_and_load_command() {
208        let temp_dir = TempDir::new().unwrap();
209        let storage = CustomCommandsStorage {
210            global_path: temp_dir.path().to_path_buf(),
211            project_path: None,
212        };
213
214        // Create a command
215        let cmd = CommandDefinition::new("test-cmd", "Test Command", "echo hello")
216            .with_description("A test command");
217
218        // Save it
219        let saved_path = storage.save_command(&cmd).unwrap();
220        assert!(saved_path.exists());
221
222        // Load it back
223        let registry = storage.load_all().unwrap();
224        let commands = registry.list_all();
225        assert_eq!(commands.len(), 1);
226        assert_eq!(commands[0].id, "test-cmd");
227    }
228
229    #[test]
230    fn test_delete_command() {
231        let temp_dir = TempDir::new().unwrap();
232        let storage = CustomCommandsStorage {
233            global_path: temp_dir.path().to_path_buf(),
234            project_path: None,
235        };
236
237        // Create and save a command
238        let cmd = CommandDefinition::new("test-cmd", "Test Command", "echo hello");
239        storage.save_command(&cmd).unwrap();
240
241        // Verify it exists
242        let registry = storage.load_all().unwrap();
243        assert_eq!(registry.list_all().len(), 1);
244
245        // Delete it
246        storage.delete_command("test-cmd").unwrap();
247
248        // Verify it's gone
249        let registry = storage.load_all().unwrap();
250        assert_eq!(registry.list_all().len(), 0);
251    }
252
253    #[test]
254    fn test_delete_nonexistent_command() {
255        let temp_dir = TempDir::new().unwrap();
256        let storage = CustomCommandsStorage {
257            global_path: temp_dir.path().to_path_buf(),
258            project_path: None,
259        };
260
261        let result = storage.delete_command("nonexistent");
262        assert!(result.is_err());
263    }
264}