metarepo_core/
lib.rs

1use anyhow::Result;
2use clap::{ArgMatches, Command};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7// New plugin system modules
8mod plugin_base;
9mod plugin_builder;
10mod plugin_manifest;
11
12pub use plugin_base::{
13    BasePlugin, PluginMetadata, HelpFormat, HelpFormatter,
14    TerminalHelpFormatter, JsonHelpFormatter, YamlHelpFormatter, MarkdownHelpFormatter,
15    CommandInfo, ArgumentInfo,
16};
17pub use plugin_builder::{
18    PluginBuilder, BuiltPlugin, CommandBuilder, ArgBuilder,
19    plugin, command, arg,
20};
21pub use plugin_manifest::{
22    PluginManifest, PluginInfo, ManifestCommand, ManifestArg,
23    ArgValueType, Example, PluginConfig, ExecutionConfig, Dependency,
24};
25
26/// Trait that all meta plugins must implement
27pub trait MetaPlugin: Send + Sync {
28    /// Returns the plugin name (used for command routing)
29    fn name(&self) -> &str;
30    
31    /// Register CLI commands for this plugin
32    fn register_commands(&self, app: Command) -> Command;
33    
34    /// Handle a command for this plugin
35    fn handle_command(&self, matches: &ArgMatches, config: &RuntimeConfig) -> Result<()>;
36    
37    /// Returns true if this plugin is experimental (default: false)
38    fn is_experimental(&self) -> bool {
39        false
40    }
41}
42
43/// Runtime configuration available to all plugins
44#[derive(Debug)]
45pub struct RuntimeConfig {
46    pub meta_config: MetaConfig,
47    pub working_dir: PathBuf,
48    pub meta_file_path: Option<PathBuf>,
49    pub experimental: bool,
50}
51
52impl RuntimeConfig {
53    pub fn has_meta_file(&self) -> bool {
54        self.meta_file_path.is_some()
55    }
56    
57    pub fn meta_root(&self) -> Option<PathBuf> {
58        self.meta_file_path.as_ref().and_then(|p| p.parent().map(|p| p.to_path_buf()))
59    }
60    
61    pub fn is_experimental(&self) -> bool {
62        self.experimental
63    }
64    
65    /// Detect if we're currently inside a project directory and return its name
66    pub fn current_project(&self) -> Option<String> {
67        let meta_root = self.meta_root()?;
68        let cwd = &self.working_dir;
69        
70        // Check if we're inside the meta root
71        if !cwd.starts_with(&meta_root) {
72            return None;
73        }
74        
75        // Get relative path from meta root
76        let _relative = cwd.strip_prefix(&meta_root).ok()?;
77        
78        // Check each project to see if we're inside it
79        for (project_name, _) in &self.meta_config.projects {
80            let project_path = meta_root.join(project_name);
81            if cwd.starts_with(&project_path) {
82                return Some(project_name.clone());
83            }
84        }
85        
86        None
87    }
88    
89    /// Resolve a project identifier (could be full name, basename, or alias)
90    pub fn resolve_project(&self, identifier: &str) -> Option<String> {
91        // First, check if it's a full project name
92        if self.meta_config.projects.contains_key(identifier) {
93            return Some(identifier.to_string());
94        }
95        
96        // Check global aliases
97        if let Some(aliases) = &self.meta_config.aliases {
98            if let Some(project_path) = aliases.get(identifier) {
99                return Some(project_path.clone());
100            }
101        }
102        
103        // Check project-specific aliases
104        for (project_name, entry) in &self.meta_config.projects {
105            if let ProjectEntry::Metadata(metadata) = entry {
106                if metadata.aliases.contains(&identifier.to_string()) {
107                    return Some(project_name.clone());
108                }
109            }
110        }
111        
112        // Check if it's a basename match
113        for project_name in self.meta_config.projects.keys() {
114            if let Some(basename) = std::path::Path::new(project_name).file_name() {
115                if basename.to_string_lossy() == identifier {
116                    return Some(project_name.clone());
117                }
118            }
119        }
120        
121        None
122    }
123    
124    /// Get all valid identifiers for a project (full name, basename, aliases)
125    pub fn project_identifiers(&self, project_name: &str) -> Vec<String> {
126        let mut identifiers = vec![project_name.to_string()];
127        
128        // Add basename if different from full name
129        if let Some(basename) = std::path::Path::new(project_name).file_name() {
130            let basename_str = basename.to_string_lossy().to_string();
131            if basename_str != project_name {
132                identifiers.push(basename_str);
133            }
134        }
135        
136        // TODO: Add custom aliases when implemented
137        
138        identifiers
139    }
140}
141
142/// Configuration for nested repository handling
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct NestedConfig {
145    #[serde(default = "default_recursive_import")]
146    pub recursive_import: bool,
147    #[serde(default = "default_max_depth")]
148    pub max_depth: usize,
149    #[serde(default)]
150    pub flatten: bool,
151    #[serde(default = "default_cycle_detection")]
152    pub cycle_detection: bool,
153    #[serde(default)]
154    pub ignore_nested: Vec<String>,
155    #[serde(default)]
156    pub namespace_separator: Option<String>,
157    #[serde(default)]
158    pub preserve_structure: bool,
159}
160
161fn default_recursive_import() -> bool { false }
162fn default_max_depth() -> usize { 3 }
163fn default_cycle_detection() -> bool { true }
164
165impl Default for NestedConfig {
166    fn default() -> Self {
167        Self {
168            recursive_import: default_recursive_import(),
169            max_depth: default_max_depth(),
170            flatten: false,
171            cycle_detection: default_cycle_detection(),
172            ignore_nested: Vec::new(),
173            namespace_separator: None,
174            preserve_structure: false,
175        }
176    }
177}
178
179/// Project metadata including scripts and configuration
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(untagged)]
182pub enum ProjectEntry {
183    /// Simple string format (backwards compatible)
184    Url(String),
185    /// Full metadata format with scripts
186    Metadata(ProjectMetadata),
187}
188
189/// Detailed project metadata
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191pub struct ProjectMetadata {
192    pub url: String,
193    #[serde(default)]
194    pub aliases: Vec<String>,
195    #[serde(default)]
196    pub scripts: HashMap<String, String>,
197    #[serde(default)]
198    pub env: HashMap<String, String>,
199}
200
201/// The .meta file configuration format
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct MetaConfig {
204    #[serde(default)]
205    pub ignore: Vec<String>,
206    #[serde(default)]
207    pub projects: HashMap<String, ProjectEntry>, // Now supports both String and ProjectMetadata
208    #[serde(default)]
209    pub plugins: Option<HashMap<String, String>>, // name -> version/path
210    #[serde(default)]
211    pub nested: Option<NestedConfig>, // nested repository configuration
212    #[serde(default)]
213    pub aliases: Option<HashMap<String, String>>, // Global aliases: alias -> project_path
214    #[serde(default)]
215    pub scripts: Option<HashMap<String, String>>, // Global scripts
216}
217
218impl Default for MetaConfig {
219    fn default() -> Self {
220        Self {
221            ignore: vec![
222                ".git".to_string(),
223                ".vscode".to_string(),
224                "node_modules".to_string(),
225                "target".to_string(),
226                ".DS_Store".to_string(),
227            ],
228            projects: HashMap::new(),
229            plugins: None,
230            nested: None,
231            aliases: None,
232            scripts: None,
233        }
234    }
235}
236
237impl MetaConfig {
238    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
239        let content = std::fs::read_to_string(path)?;
240        let config: MetaConfig = serde_json::from_str(&content)?;
241        Ok(config)
242    }
243    
244    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
245        let content = serde_json::to_string_pretty(self)?;
246        std::fs::write(path, content)?;
247        Ok(())
248    }
249    
250    pub fn find_meta_file() -> Option<PathBuf> {
251        let mut current = std::env::current_dir().ok()?;
252        
253        loop {
254            let meta_file = current.join(".meta");
255            if meta_file.exists() {
256                return Some(meta_file);
257            }
258            
259            if !current.pop() {
260                break;
261            }
262        }
263        
264        None
265    }
266    
267    pub fn load() -> Result<Self> {
268        if let Some(meta_file) = Self::find_meta_file() {
269            Self::load_from_file(meta_file)
270        } else {
271            Err(anyhow::anyhow!("No .meta file found"))
272        }
273    }
274    
275    /// Get the URL for a project (handles both string and metadata formats)
276    pub fn get_project_url(&self, project_name: &str) -> Option<String> {
277        self.projects.get(project_name).map(|entry| match entry {
278            ProjectEntry::Url(url) => url.clone(),
279            ProjectEntry::Metadata(metadata) => metadata.url.clone(),
280        })
281    }
282    
283    /// Get scripts for a specific project
284    pub fn get_project_scripts(&self, project_name: &str) -> Option<HashMap<String, String>> {
285        self.projects.get(project_name).and_then(|entry| match entry {
286            ProjectEntry::Url(_) => None,
287            ProjectEntry::Metadata(metadata) => {
288                if metadata.scripts.is_empty() {
289                    None
290                } else {
291                    Some(metadata.scripts.clone())
292                }
293            }
294        })
295    }
296    
297    /// Get all available scripts (project-specific and global)
298    pub fn get_all_scripts(&self, project_name: Option<&str>) -> HashMap<String, String> {
299        let mut scripts = HashMap::new();
300        
301        // Add global scripts first
302        if let Some(global_scripts) = &self.scripts {
303            scripts.extend(global_scripts.clone());
304        }
305        
306        // Add project-specific scripts (overrides global)
307        if let Some(project) = project_name {
308            if let Some(project_scripts) = self.get_project_scripts(project) {
309                scripts.extend(project_scripts);
310            }
311        }
312        
313        scripts
314    }
315    
316    /// Check if a project exists (for backwards compatibility)
317    pub fn project_exists(&self, project_name: &str) -> bool {
318        self.projects.contains_key(project_name)
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use std::fs;
326    use tempfile::tempdir;
327    
328    #[test]
329    fn test_meta_config_default() {
330        let config = MetaConfig::default();
331        assert_eq!(config.ignore.len(), 5);
332        assert!(config.ignore.contains(&".git".to_string()));
333        assert!(config.ignore.contains(&".vscode".to_string()));
334        assert!(config.ignore.contains(&"node_modules".to_string()));
335        assert!(config.ignore.contains(&"target".to_string()));
336        assert!(config.ignore.contains(&".DS_Store".to_string()));
337        assert!(config.projects.is_empty());
338        assert!(config.plugins.is_none());
339        assert!(config.nested.is_none());
340    }
341    
342    #[test]
343    fn test_meta_config_save_and_load() {
344        let temp_dir = tempdir().unwrap();
345        let meta_file = temp_dir.path().join(".meta");
346        
347        // Create a config with some data
348        let mut config = MetaConfig::default();
349        config.projects.insert("project1".to_string(), ProjectEntry::Url("https://github.com/user/repo.git".to_string()));
350        config.projects.insert("project2".to_string(), ProjectEntry::Url("https://github.com/user/repo2.git".to_string()));
351        
352        // Save the config
353        config.save_to_file(&meta_file).unwrap();
354        
355        // Load the config back
356        let loaded_config = MetaConfig::load_from_file(&meta_file).unwrap();
357        
358        // Verify the loaded config matches
359        assert_eq!(loaded_config.projects.len(), 2);
360        assert_eq!(loaded_config.projects.get("project1"), Some(&ProjectEntry::Url("https://github.com/user/repo.git".to_string())));
361        assert_eq!(loaded_config.projects.get("project2"), Some(&ProjectEntry::Url("https://github.com/user/repo2.git".to_string())));
362        assert_eq!(loaded_config.ignore, config.ignore);
363    }
364    
365    #[test]
366    fn test_meta_config_with_nested() {
367        let temp_dir = tempdir().unwrap();
368        let meta_file = temp_dir.path().join(".meta");
369        
370        // Create a config with nested configuration
371        let mut config = MetaConfig::default();
372        config.nested = Some(NestedConfig {
373            recursive_import: true,
374            max_depth: 5,
375            flatten: true,
376            cycle_detection: false,
377            ignore_nested: vec!["ignored-project".to_string()],
378            namespace_separator: Some("::".to_string()),
379            preserve_structure: true,
380        });
381        
382        // Save and load
383        config.save_to_file(&meta_file).unwrap();
384        let loaded_config = MetaConfig::load_from_file(&meta_file).unwrap();
385        
386        // Verify nested configuration
387        assert!(loaded_config.nested.is_some());
388        let nested = loaded_config.nested.unwrap();
389        assert_eq!(nested.recursive_import, true);
390        assert_eq!(nested.max_depth, 5);
391        assert_eq!(nested.flatten, true);
392        assert_eq!(nested.cycle_detection, false);
393        assert_eq!(nested.ignore_nested, vec!["ignored-project".to_string()]);
394        assert_eq!(nested.namespace_separator, Some("::".to_string()));
395        assert_eq!(nested.preserve_structure, true);
396    }
397    
398    #[test]
399    fn test_find_meta_file() {
400        let temp_dir = tempdir().unwrap();
401        let nested_dir = temp_dir.path().join("nested").join("deep");
402        fs::create_dir_all(&nested_dir).unwrap();
403        
404        // Create .meta file in temp_dir
405        let meta_file = temp_dir.path().join(".meta");
406        let config = MetaConfig::default();
407        config.save_to_file(&meta_file).unwrap();
408        
409        // Change to nested directory
410        let original_dir = std::env::current_dir().unwrap();
411        std::env::set_current_dir(&nested_dir).unwrap();
412        
413        // Find meta file should traverse up
414        let found_file = MetaConfig::find_meta_file();
415        assert!(found_file.is_some());
416        // Compare canonical paths to handle symlinks like /private/var vs /var on macOS
417        assert_eq!(found_file.unwrap().canonicalize().unwrap(), meta_file.canonicalize().unwrap());
418        
419        // Restore original directory
420        std::env::set_current_dir(original_dir).unwrap();
421    }
422    
423    #[test]
424    fn test_nested_config_default() {
425        let nested = NestedConfig::default();
426        assert_eq!(nested.recursive_import, false);
427        assert_eq!(nested.max_depth, 3);
428        assert_eq!(nested.flatten, false);
429        assert_eq!(nested.cycle_detection, true);
430        assert!(nested.ignore_nested.is_empty());
431        assert!(nested.namespace_separator.is_none());
432        assert_eq!(nested.preserve_structure, false);
433    }
434    
435    #[test]
436    fn test_runtime_config_has_meta_file() {
437        let temp_dir = tempdir().unwrap();
438        let meta_file = temp_dir.path().join(".meta");
439        
440        let config_with_meta = RuntimeConfig {
441            meta_config: MetaConfig::default(),
442            working_dir: temp_dir.path().to_path_buf(),
443            meta_file_path: Some(meta_file.clone()),
444            experimental: false,
445        };
446        
447        let config_without_meta = RuntimeConfig {
448            meta_config: MetaConfig::default(),
449            working_dir: temp_dir.path().to_path_buf(),
450            meta_file_path: None,
451            experimental: false,
452        };
453        
454        assert!(config_with_meta.has_meta_file());
455        assert!(!config_without_meta.has_meta_file());
456    }
457    
458    #[test]
459    fn test_runtime_config_meta_root() {
460        let temp_dir = tempdir().unwrap();
461        let meta_file = temp_dir.path().join("subdir").join(".meta");
462        fs::create_dir_all(meta_file.parent().unwrap()).unwrap();
463        
464        let config = RuntimeConfig {
465            meta_config: MetaConfig::default(),
466            working_dir: temp_dir.path().to_path_buf(),
467            meta_file_path: Some(meta_file.clone()),
468            experimental: false,
469        };
470        
471        assert_eq!(config.meta_root(), Some(temp_dir.path().join("subdir")));
472    }
473    
474    #[test]
475    fn test_load_invalid_json() {
476        let temp_dir = tempdir().unwrap();
477        let meta_file = temp_dir.path().join(".meta");
478        
479        // Write invalid JSON
480        fs::write(&meta_file, "{ invalid json }").unwrap();
481        
482        // Should return an error
483        let result = MetaConfig::load_from_file(&meta_file);
484        assert!(result.is_err());
485    }
486}