ricecoder_storage/markdown_config/
loader.rs

1//! Configuration loader for discovering and loading markdown configurations
2//!
3//! This module provides the [`ConfigurationLoader`] which discovers and loads
4//! markdown configuration files from standard locations.
5//!
6//! # Discovery Locations
7//!
8//! Configuration files are discovered in the following locations (in priority order):
9//!
10//! 1. **Project-level**: `projects/ricecoder/.agent/`
11//! 2. **User-level**: `~/.ricecoder/agents/`, `~/.ricecoder/modes/`, `~/.ricecoder/commands/`
12//! 3. **System-level**: `/etc/ricecoder/agents/` (Linux/macOS)
13//!
14//! # File Patterns
15//!
16//! - `*.agent.md` - Agent configurations
17//! - `*.mode.md` - Mode configurations
18//! - `*.command.md` - Command configurations
19//!
20//! # Usage
21//!
22//! ```ignore
23//! use ricecoder_storage::markdown_config::{ConfigurationLoader, ConfigRegistry};
24//! use std::sync::Arc;
25//! use std::path::PathBuf;
26//!
27//! #[tokio::main]
28//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
29//!     let registry = Arc::new(ConfigRegistry::new());
30//!     let loader = ConfigurationLoader::new(registry.clone());
31//!
32//!     // Discover and load configurations
33//!     let paths = vec![
34//!         PathBuf::from("~/.ricecoder/agents"),
35//!         PathBuf::from("projects/ricecoder/.agent"),
36//!     ];
37//!
38//!     loader.load_all(&paths).await?;
39//!
40//!     // Query loaded configurations
41//!     if let Some(agent) = registry.get_agent("code-review") {
42//!         println!("Found agent: {}", agent.name);
43//!     }
44//!
45//!     Ok(())
46//! }
47//! ```
48
49use crate::markdown_config::error::{MarkdownConfigError, MarkdownConfigResult};
50use crate::markdown_config::parser::MarkdownParser;
51use crate::markdown_config::registry::ConfigRegistry;
52use crate::markdown_config::types::{AgentConfig, CommandConfig, ModeConfig};
53use std::path::{Path, PathBuf};
54use std::sync::Arc;
55use tracing::{debug, warn};
56
57/// Configuration file type
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ConfigFileType {
60    /// Agent configuration file (*.agent.md)
61    Agent,
62    /// Mode configuration file (*.mode.md)
63    Mode,
64    /// Command configuration file (*.command.md)
65    Command,
66}
67
68impl ConfigFileType {
69    /// Get the file pattern for this configuration type
70    pub fn pattern(&self) -> &'static str {
71        match self {
72            ConfigFileType::Agent => "*.agent.md",
73            ConfigFileType::Mode => "*.mode.md",
74            ConfigFileType::Command => "*.command.md",
75        }
76    }
77
78    /// Detect configuration type from file path
79    pub fn from_path(path: &Path) -> Option<Self> {
80        let file_name = path.file_name()?.to_str()?;
81
82        if file_name.ends_with(".agent.md") {
83            Some(ConfigFileType::Agent)
84        } else if file_name.ends_with(".mode.md") {
85            Some(ConfigFileType::Mode)
86        } else if file_name.ends_with(".command.md") {
87            Some(ConfigFileType::Command)
88        } else {
89            None
90        }
91    }
92}
93
94/// Discovered configuration file
95#[derive(Debug, Clone)]
96pub struct ConfigFile {
97    /// Path to the configuration file
98    pub path: PathBuf,
99    /// Type of configuration
100    pub config_type: ConfigFileType,
101}
102
103impl ConfigFile {
104    /// Create a new configuration file reference
105    pub fn new(path: PathBuf, config_type: ConfigFileType) -> Self {
106        Self { path, config_type }
107    }
108}
109
110/// Configuration loader for discovering and loading markdown configurations
111#[derive(Debug)]
112pub struct ConfigurationLoader {
113    parser: MarkdownParser,
114    registry: Arc<ConfigRegistry>,
115}
116
117impl ConfigurationLoader {
118    /// Create a new configuration loader
119    pub fn new(registry: Arc<ConfigRegistry>) -> Self {
120        Self {
121            parser: MarkdownParser::new(),
122            registry,
123        }
124    }
125
126    /// Discover configuration files in the given paths
127    ///
128    /// # Arguments
129    /// * `paths` - Directories to search for configuration files
130    ///
131    /// # Returns
132    /// A vector of discovered configuration files
133    pub fn discover(&self, paths: &[PathBuf]) -> MarkdownConfigResult<Vec<ConfigFile>> {
134        let mut discovered = Vec::new();
135
136        for path in paths {
137            if !path.exists() {
138                debug!("Configuration path does not exist: {}", path.display());
139                continue;
140            }
141
142            if !path.is_dir() {
143                debug!("Configuration path is not a directory: {}", path.display());
144                continue;
145            }
146
147            self.discover_in_directory(path, &mut discovered)?;
148        }
149
150        debug!("Discovered {} configuration files", discovered.len());
151        Ok(discovered)
152    }
153
154    /// Discover configuration files in a specific directory
155    fn discover_in_directory(
156        &self,
157        dir: &Path,
158        discovered: &mut Vec<ConfigFile>,
159    ) -> MarkdownConfigResult<()> {
160        match std::fs::read_dir(dir) {
161            Ok(entries) => {
162                for entry in entries {
163                    match entry {
164                        Ok(entry) => {
165                            let path = entry.path();
166
167                            // Skip directories
168                            if path.is_dir() {
169                                continue;
170                            }
171
172                            // Check if file matches any configuration pattern
173                            if let Some(config_type) = ConfigFileType::from_path(&path) {
174                                discovered.push(ConfigFile::new(path, config_type));
175                            }
176                        }
177                        Err(e) => {
178                            warn!("Failed to read directory entry: {}", e);
179                        }
180                    }
181                }
182                Ok(())
183            }
184            Err(e) => {
185                warn!(
186                    "Failed to read configuration directory {}: {}",
187                    dir.display(),
188                    e
189                );
190                Ok(())
191            }
192        }
193    }
194
195    /// Load a configuration file
196    ///
197    /// # Arguments
198    /// * `file` - The configuration file to load
199    ///
200    /// # Returns
201    /// The loaded configuration (as an enum to support different types)
202    pub async fn load(&self, file: &ConfigFile) -> MarkdownConfigResult<LoadedConfig> {
203        // Read file content
204        let content = tokio::fs::read_to_string(&file.path)
205            .await
206            .map_err(|e| {
207                MarkdownConfigError::load_error(
208                    &file.path,
209                    format!("Failed to read file: {}", e),
210                )
211            })?;
212
213        // Parse markdown
214        let parsed = self
215            .parser
216            .parse_with_context(&content, Some(&file.path))?;
217
218        // Extract frontmatter
219        let frontmatter = parsed.frontmatter.ok_or_else(|| {
220            MarkdownConfigError::load_error(
221                &file.path,
222                "Configuration file must have YAML frontmatter",
223            )
224        })?;
225
226        // Parse YAML frontmatter
227        let yaml_value: serde_yaml::Value = serde_yaml::from_str(&frontmatter).map_err(|e| {
228            MarkdownConfigError::load_error(
229                &file.path,
230                format!("Failed to parse YAML frontmatter: {}", e),
231            )
232        })?;
233
234        // Load configuration based on type
235        let config = match file.config_type {
236            ConfigFileType::Agent => {
237                let mut agent_config: AgentConfig =
238                    serde_yaml::from_value(yaml_value).map_err(|e| {
239                        MarkdownConfigError::load_error(
240                            &file.path,
241                            format!("Failed to deserialize agent configuration: {}", e),
242                        )
243                    })?;
244
245                // Use markdown body as prompt if not provided in frontmatter
246                if agent_config.prompt.is_empty() {
247                    agent_config.prompt = parsed.content;
248                }
249
250                LoadedConfig::Agent(agent_config)
251            }
252            ConfigFileType::Mode => {
253                let mut mode_config: ModeConfig =
254                    serde_yaml::from_value(yaml_value).map_err(|e| {
255                        MarkdownConfigError::load_error(
256                            &file.path,
257                            format!("Failed to deserialize mode configuration: {}", e),
258                        )
259                    })?;
260
261                // Use markdown body as prompt if not provided in frontmatter
262                if mode_config.prompt.is_empty() {
263                    mode_config.prompt = parsed.content;
264                }
265
266                LoadedConfig::Mode(mode_config)
267            }
268            ConfigFileType::Command => {
269                let mut command_config: CommandConfig =
270                    serde_yaml::from_value(yaml_value).map_err(|e| {
271                        MarkdownConfigError::load_error(
272                            &file.path,
273                            format!("Failed to deserialize command configuration: {}", e),
274                        )
275                    })?;
276
277                // Use markdown body as template if not provided in frontmatter
278                if command_config.template.is_empty() {
279                    command_config.template = parsed.content;
280                }
281
282                LoadedConfig::Command(command_config)
283            }
284        };
285
286        Ok(config)
287    }
288
289    /// Register a loaded configuration with the registry
290    ///
291    /// # Arguments
292    /// * `config` - The loaded configuration to register
293    pub fn register(&self, config: LoadedConfig) -> MarkdownConfigResult<()> {
294        match config {
295            LoadedConfig::Agent(agent) => self.registry.register_agent(agent),
296            LoadedConfig::Mode(mode) => self.registry.register_mode(mode),
297            LoadedConfig::Command(command) => self.registry.register_command(command),
298        }
299    }
300
301    /// Load and register all configurations from the given paths
302    ///
303    /// # Arguments
304    /// * `paths` - Directories to search for configuration files
305    ///
306    /// # Returns
307    /// A tuple of (successful_count, error_count, errors)
308    pub async fn load_all(
309        &self,
310        paths: &[PathBuf],
311    ) -> MarkdownConfigResult<(usize, usize, Vec<(PathBuf, String)>)> {
312        let files = self.discover(paths)?;
313
314        let mut success_count = 0;
315        let mut error_count = 0;
316        let mut errors = Vec::new();
317
318        for file in files {
319            match self.load(&file).await {
320                Ok(config) => {
321                    match self.register(config) {
322                        Ok(_) => {
323                            success_count += 1;
324                            debug!("Registered configuration from {}", file.path.display());
325                        }
326                        Err(e) => {
327                            error_count += 1;
328                            let error_msg = e.to_string();
329                            warn!(
330                                "Failed to register configuration from {}: {}",
331                                file.path.display(),
332                                error_msg
333                            );
334                            errors.push((file.path, error_msg));
335                        }
336                    }
337                }
338                Err(e) => {
339                    error_count += 1;
340                    let error_msg = e.to_string();
341                    warn!(
342                        "Failed to load configuration from {}: {}",
343                        file.path.display(),
344                        error_msg
345                    );
346                    errors.push((file.path, error_msg));
347                }
348            }
349        }
350
351        debug!(
352            "Configuration loading complete: {} successful, {} failed",
353            success_count, error_count
354        );
355
356        Ok((success_count, error_count, errors))
357    }
358
359    /// Get the registry
360    pub fn registry(&self) -> Arc<ConfigRegistry> {
361        self.registry.clone()
362    }
363}
364
365/// Loaded configuration (can be any of the three types)
366#[derive(Debug)]
367pub enum LoadedConfig {
368    /// Loaded agent configuration
369    Agent(AgentConfig),
370    /// Loaded mode configuration
371    Mode(ModeConfig),
372    /// Loaded command configuration
373    Command(CommandConfig),
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use std::fs;
380    use tempfile::TempDir;
381
382    fn create_test_agent_file(dir: &Path, name: &str, content: &str) -> PathBuf {
383        let path = dir.join(format!("{}.agent.md", name));
384        fs::write(&path, content).unwrap();
385        path
386    }
387
388    fn create_test_mode_file(dir: &Path, name: &str, content: &str) -> PathBuf {
389        let path = dir.join(format!("{}.mode.md", name));
390        fs::write(&path, content).unwrap();
391        path
392    }
393
394    fn create_test_command_file(dir: &Path, name: &str, content: &str) -> PathBuf {
395        let path = dir.join(format!("{}.command.md", name));
396        fs::write(&path, content).unwrap();
397        path
398    }
399
400    #[test]
401    fn test_config_file_type_detection() {
402        let agent_path = PathBuf::from("test.agent.md");
403        assert_eq!(ConfigFileType::from_path(&agent_path), Some(ConfigFileType::Agent));
404
405        let mode_path = PathBuf::from("test.mode.md");
406        assert_eq!(ConfigFileType::from_path(&mode_path), Some(ConfigFileType::Mode));
407
408        let command_path = PathBuf::from("test.command.md");
409        assert_eq!(
410            ConfigFileType::from_path(&command_path),
411            Some(ConfigFileType::Command)
412        );
413
414        let other_path = PathBuf::from("test.md");
415        assert_eq!(ConfigFileType::from_path(&other_path), None);
416    }
417
418    #[test]
419    fn test_discover_configuration_files() {
420        let temp_dir = TempDir::new().unwrap();
421        let dir_path = temp_dir.path();
422
423        // Create test files
424        create_test_agent_file(dir_path, "agent1", "---\nname: agent1\n---\nTest");
425        create_test_mode_file(dir_path, "mode1", "---\nname: mode1\n---\nTest");
426        create_test_command_file(dir_path, "command1", "---\nname: command1\n---\nTest");
427        fs::write(dir_path.join("other.md"), "Not a config").unwrap();
428
429        let registry = Arc::new(ConfigRegistry::new());
430        let loader = ConfigurationLoader::new(registry);
431
432        let discovered = loader.discover(&[dir_path.to_path_buf()]).unwrap();
433
434        assert_eq!(discovered.len(), 3);
435        assert!(discovered.iter().any(|f| f.config_type == ConfigFileType::Agent));
436        assert!(discovered.iter().any(|f| f.config_type == ConfigFileType::Mode));
437        assert!(discovered.iter().any(|f| f.config_type == ConfigFileType::Command));
438    }
439
440    #[test]
441    fn test_discover_nonexistent_directory() {
442        let registry = Arc::new(ConfigRegistry::new());
443        let loader = ConfigurationLoader::new(registry);
444
445        let nonexistent = PathBuf::from("/nonexistent/path");
446        let discovered = loader.discover(&[nonexistent]).unwrap();
447
448        assert_eq!(discovered.len(), 0);
449    }
450
451    #[tokio::test]
452    async fn test_load_agent_configuration() {
453        let temp_dir = TempDir::new().unwrap();
454        let dir_path = temp_dir.path();
455
456        let content = r#"---
457name: test-agent
458description: A test agent
459model: gpt-4
460temperature: 0.7
461max_tokens: 2000
462---
463You are a helpful assistant"#;
464
465        create_test_agent_file(dir_path, "test-agent", content);
466
467        let registry = Arc::new(ConfigRegistry::new());
468        let loader = ConfigurationLoader::new(registry);
469
470        let file = ConfigFile::new(
471            dir_path.join("test-agent.agent.md"),
472            ConfigFileType::Agent,
473        );
474
475        let loaded = loader.load(&file).await.unwrap();
476
477        match loaded {
478            LoadedConfig::Agent(agent) => {
479                assert_eq!(agent.name, "test-agent");
480                assert_eq!(agent.description, Some("A test agent".to_string()));
481                assert_eq!(agent.model, Some("gpt-4".to_string()));
482                assert_eq!(agent.temperature, Some(0.7));
483                assert_eq!(agent.max_tokens, Some(2000));
484                assert_eq!(agent.prompt, "You are a helpful assistant");
485            }
486            _ => panic!("Expected agent configuration"),
487        }
488    }
489
490    #[tokio::test]
491    async fn test_load_mode_configuration() {
492        let temp_dir = TempDir::new().unwrap();
493        let dir_path = temp_dir.path();
494
495        let content = r#"---
496name: focus-mode
497description: Focus mode
498keybinding: C-f
499enabled: true
500---
501Focus on the task at hand"#;
502
503        create_test_mode_file(dir_path, "focus-mode", content);
504
505        let registry = Arc::new(ConfigRegistry::new());
506        let loader = ConfigurationLoader::new(registry);
507
508        let file = ConfigFile::new(
509            dir_path.join("focus-mode.mode.md"),
510            ConfigFileType::Mode,
511        );
512
513        let loaded = loader.load(&file).await.unwrap();
514
515        match loaded {
516            LoadedConfig::Mode(mode) => {
517                assert_eq!(mode.name, "focus-mode");
518                assert_eq!(mode.description, Some("Focus mode".to_string()));
519                assert_eq!(mode.keybinding, Some("C-f".to_string()));
520                assert!(mode.enabled);
521                assert_eq!(mode.prompt, "Focus on the task at hand");
522            }
523            _ => panic!("Expected mode configuration"),
524        }
525    }
526
527    #[tokio::test]
528    async fn test_load_command_configuration() {
529        let temp_dir = TempDir::new().unwrap();
530        let dir_path = temp_dir.path();
531
532        let content = r#"---
533name: test-command
534description: A test command
535parameters:
536  - name: message
537    description: Message to echo
538    required: true
539keybinding: C-t
540---
541echo {{message}}"#;
542
543        create_test_command_file(dir_path, "test-command", content);
544
545        let registry = Arc::new(ConfigRegistry::new());
546        let loader = ConfigurationLoader::new(registry);
547
548        let file = ConfigFile::new(
549            dir_path.join("test-command.command.md"),
550            ConfigFileType::Command,
551        );
552
553        let loaded = loader.load(&file).await.unwrap();
554
555        match loaded {
556            LoadedConfig::Command(command) => {
557                assert_eq!(command.name, "test-command");
558                assert_eq!(command.description, Some("A test command".to_string()));
559                assert_eq!(command.template, "echo {{message}}");
560                assert_eq!(command.parameters.len(), 1);
561                assert_eq!(command.parameters[0].name, "message");
562                assert!(command.parameters[0].required);
563            }
564            _ => panic!("Expected command configuration"),
565        }
566    }
567
568    #[tokio::test]
569    async fn test_load_missing_frontmatter() {
570        let temp_dir = TempDir::new().unwrap();
571        let dir_path = temp_dir.path();
572
573        let content = "# No frontmatter\nJust markdown";
574
575        create_test_agent_file(dir_path, "no-frontmatter", content);
576
577        let registry = Arc::new(ConfigRegistry::new());
578        let loader = ConfigurationLoader::new(registry);
579
580        let file = ConfigFile::new(
581            dir_path.join("no-frontmatter.agent.md"),
582            ConfigFileType::Agent,
583        );
584
585        let result = loader.load(&file).await;
586        assert!(result.is_err());
587    }
588
589    #[tokio::test]
590    async fn test_load_all_configurations() {
591        let temp_dir = TempDir::new().unwrap();
592        let dir_path = temp_dir.path();
593
594        let agent_content = r#"---
595name: agent1
596---
597Agent prompt"#;
598
599        let mode_content = r#"---
600name: mode1
601---
602Mode prompt"#;
603
604        create_test_agent_file(dir_path, "agent1", agent_content);
605        create_test_mode_file(dir_path, "mode1", mode_content);
606
607        let registry = Arc::new(ConfigRegistry::new());
608        let loader = ConfigurationLoader::new(registry.clone());
609
610        let (success, errors, error_list) = loader.load_all(&[dir_path.to_path_buf()]).await.unwrap();
611
612        assert_eq!(success, 2);
613        assert_eq!(errors, 0);
614        assert_eq!(error_list.len(), 0);
615
616        // Verify configurations were registered
617        assert!(registry.has_agent("agent1").unwrap());
618        assert!(registry.has_mode("mode1").unwrap());
619    }
620}