ricecoder_storage/markdown_config/
loader.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ConfigFileType {
60 Agent,
62 Mode,
64 Command,
66}
67
68impl ConfigFileType {
69 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 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#[derive(Debug, Clone)]
96pub struct ConfigFile {
97 pub path: PathBuf,
99 pub config_type: ConfigFileType,
101}
102
103impl ConfigFile {
104 pub fn new(path: PathBuf, config_type: ConfigFileType) -> Self {
106 Self { path, config_type }
107 }
108}
109
110#[derive(Debug)]
112pub struct ConfigurationLoader {
113 parser: MarkdownParser,
114 registry: Arc<ConfigRegistry>,
115}
116
117impl ConfigurationLoader {
118 pub fn new(registry: Arc<ConfigRegistry>) -> Self {
120 Self {
121 parser: MarkdownParser::new(),
122 registry,
123 }
124 }
125
126 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 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 if path.is_dir() {
169 continue;
170 }
171
172 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 pub async fn load(&self, file: &ConfigFile) -> MarkdownConfigResult<LoadedConfig> {
203 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 let parsed = self
215 .parser
216 .parse_with_context(&content, Some(&file.path))?;
217
218 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 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 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 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 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 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 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 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 pub fn registry(&self) -> Arc<ConfigRegistry> {
361 self.registry.clone()
362 }
363}
364
365#[derive(Debug)]
367pub enum LoadedConfig {
368 Agent(AgentConfig),
370 Mode(ModeConfig),
372 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_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 assert!(registry.has_agent("agent1").unwrap());
618 assert!(registry.has_mode("mode1").unwrap());
619 }
620}