metarepo-core 0.5.2

Core interfaces and types for the metarepo multi-project management tool
Documentation
use anyhow::Result;
use clap::{ArgMatches, Command};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

// New plugin system modules
mod plugin_base;
mod plugin_builder;
mod plugin_manifest;

pub use plugin_base::{
    BasePlugin, PluginMetadata, HelpFormat, HelpFormatter,
    TerminalHelpFormatter, JsonHelpFormatter, YamlHelpFormatter, MarkdownHelpFormatter,
    CommandInfo, ArgumentInfo,
};
pub use plugin_builder::{
    PluginBuilder, BuiltPlugin, CommandBuilder, ArgBuilder,
    plugin, command, arg,
};
pub use plugin_manifest::{
    PluginManifest, PluginInfo, ManifestCommand, ManifestArg,
    ArgValueType, Example, PluginConfig, ExecutionConfig, Dependency,
};

/// Trait that all meta plugins must implement
pub trait MetaPlugin: Send + Sync {
    /// Returns the plugin name (used for command routing)
    fn name(&self) -> &str;
    
    /// Register CLI commands for this plugin
    fn register_commands(&self, app: Command) -> Command;
    
    /// Handle a command for this plugin
    fn handle_command(&self, matches: &ArgMatches, config: &RuntimeConfig) -> Result<()>;
    
    /// Returns true if this plugin is experimental (default: false)
    fn is_experimental(&self) -> bool {
        false
    }
}

/// Runtime configuration available to all plugins
#[derive(Debug)]
pub struct RuntimeConfig {
    pub meta_config: MetaConfig,
    pub working_dir: PathBuf,
    pub meta_file_path: Option<PathBuf>,
    pub experimental: bool,
}

impl RuntimeConfig {
    pub fn has_meta_file(&self) -> bool {
        self.meta_file_path.is_some()
    }
    
    pub fn meta_root(&self) -> Option<PathBuf> {
        self.meta_file_path.as_ref().and_then(|p| p.parent().map(|p| p.to_path_buf()))
    }
    
    pub fn is_experimental(&self) -> bool {
        self.experimental
    }
}

/// Configuration for nested repository handling
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NestedConfig {
    #[serde(default = "default_recursive_import")]
    pub recursive_import: bool,
    #[serde(default = "default_max_depth")]
    pub max_depth: usize,
    #[serde(default)]
    pub flatten: bool,
    #[serde(default = "default_cycle_detection")]
    pub cycle_detection: bool,
    #[serde(default)]
    pub ignore_nested: Vec<String>,
    #[serde(default)]
    pub namespace_separator: Option<String>,
    #[serde(default)]
    pub preserve_structure: bool,
}

fn default_recursive_import() -> bool { false }
fn default_max_depth() -> usize { 3 }
fn default_cycle_detection() -> bool { true }

impl Default for NestedConfig {
    fn default() -> Self {
        Self {
            recursive_import: default_recursive_import(),
            max_depth: default_max_depth(),
            flatten: false,
            cycle_detection: default_cycle_detection(),
            ignore_nested: Vec::new(),
            namespace_separator: None,
            preserve_structure: false,
        }
    }
}

/// The .meta file configuration format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetaConfig {
    #[serde(default)]
    pub ignore: Vec<String>,
    #[serde(default)]
    pub projects: HashMap<String, String>, // path -> repo_url
    #[serde(default)]
    pub plugins: Option<HashMap<String, String>>, // name -> version/path
    #[serde(default)]
    pub nested: Option<NestedConfig>, // nested repository configuration
}

impl Default for MetaConfig {
    fn default() -> Self {
        Self {
            ignore: vec![
                ".git".to_string(),
                ".vscode".to_string(),
                "node_modules".to_string(),
                "target".to_string(),
            ],
            projects: HashMap::new(),
            plugins: None,
            nested: None,
        }
    }
}

impl MetaConfig {
    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
        let content = std::fs::read_to_string(path)?;
        let config: MetaConfig = serde_json::from_str(&content)?;
        Ok(config)
    }
    
    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
        let content = serde_json::to_string_pretty(self)?;
        std::fs::write(path, content)?;
        Ok(())
    }
    
    pub fn find_meta_file() -> Option<PathBuf> {
        let mut current = std::env::current_dir().ok()?;
        
        loop {
            let meta_file = current.join(".meta");
            if meta_file.exists() {
                return Some(meta_file);
            }
            
            if !current.pop() {
                break;
            }
        }
        
        None
    }
    
    pub fn load() -> Result<Self> {
        if let Some(meta_file) = Self::find_meta_file() {
            Self::load_from_file(meta_file)
        } else {
            Err(anyhow::anyhow!("No .meta file found"))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;
    
    #[test]
    fn test_meta_config_default() {
        let config = MetaConfig::default();
        assert_eq!(config.ignore.len(), 4);
        assert!(config.ignore.contains(&".git".to_string()));
        assert!(config.ignore.contains(&".vscode".to_string()));
        assert!(config.ignore.contains(&"node_modules".to_string()));
        assert!(config.ignore.contains(&"target".to_string()));
        assert!(config.projects.is_empty());
        assert!(config.plugins.is_none());
        assert!(config.nested.is_none());
    }
    
    #[test]
    fn test_meta_config_save_and_load() {
        let temp_dir = tempdir().unwrap();
        let meta_file = temp_dir.path().join(".meta");
        
        // Create a config with some data
        let mut config = MetaConfig::default();
        config.projects.insert("project1".to_string(), "https://github.com/user/repo.git".to_string());
        config.projects.insert("project2".to_string(), "https://github.com/user/repo2.git".to_string());
        
        // Save the config
        config.save_to_file(&meta_file).unwrap();
        
        // Load the config back
        let loaded_config = MetaConfig::load_from_file(&meta_file).unwrap();
        
        // Verify the loaded config matches
        assert_eq!(loaded_config.projects.len(), 2);
        assert_eq!(loaded_config.projects.get("project1"), Some(&"https://github.com/user/repo.git".to_string()));
        assert_eq!(loaded_config.projects.get("project2"), Some(&"https://github.com/user/repo2.git".to_string()));
        assert_eq!(loaded_config.ignore, config.ignore);
    }
    
    #[test]
    fn test_meta_config_with_nested() {
        let temp_dir = tempdir().unwrap();
        let meta_file = temp_dir.path().join(".meta");
        
        // Create a config with nested configuration
        let mut config = MetaConfig::default();
        config.nested = Some(NestedConfig {
            recursive_import: true,
            max_depth: 5,
            flatten: true,
            cycle_detection: false,
            ignore_nested: vec!["ignored-project".to_string()],
            namespace_separator: Some("::".to_string()),
            preserve_structure: true,
        });
        
        // Save and load
        config.save_to_file(&meta_file).unwrap();
        let loaded_config = MetaConfig::load_from_file(&meta_file).unwrap();
        
        // Verify nested configuration
        assert!(loaded_config.nested.is_some());
        let nested = loaded_config.nested.unwrap();
        assert_eq!(nested.recursive_import, true);
        assert_eq!(nested.max_depth, 5);
        assert_eq!(nested.flatten, true);
        assert_eq!(nested.cycle_detection, false);
        assert_eq!(nested.ignore_nested, vec!["ignored-project".to_string()]);
        assert_eq!(nested.namespace_separator, Some("::".to_string()));
        assert_eq!(nested.preserve_structure, true);
    }
    
    #[test]
    fn test_find_meta_file() {
        let temp_dir = tempdir().unwrap();
        let nested_dir = temp_dir.path().join("nested").join("deep");
        fs::create_dir_all(&nested_dir).unwrap();
        
        // Create .meta file in temp_dir
        let meta_file = temp_dir.path().join(".meta");
        let config = MetaConfig::default();
        config.save_to_file(&meta_file).unwrap();
        
        // Change to nested directory
        let original_dir = std::env::current_dir().unwrap();
        std::env::set_current_dir(&nested_dir).unwrap();
        
        // Find meta file should traverse up
        let found_file = MetaConfig::find_meta_file();
        assert!(found_file.is_some());
        // Compare canonical paths to handle symlinks like /private/var vs /var on macOS
        assert_eq!(found_file.unwrap().canonicalize().unwrap(), meta_file.canonicalize().unwrap());
        
        // Restore original directory
        std::env::set_current_dir(original_dir).unwrap();
    }
    
    #[test]
    fn test_nested_config_default() {
        let nested = NestedConfig::default();
        assert_eq!(nested.recursive_import, false);
        assert_eq!(nested.max_depth, 3);
        assert_eq!(nested.flatten, false);
        assert_eq!(nested.cycle_detection, true);
        assert!(nested.ignore_nested.is_empty());
        assert!(nested.namespace_separator.is_none());
        assert_eq!(nested.preserve_structure, false);
    }
    
    #[test]
    fn test_runtime_config_has_meta_file() {
        let temp_dir = tempdir().unwrap();
        let meta_file = temp_dir.path().join(".meta");
        
        let config_with_meta = RuntimeConfig {
            meta_config: MetaConfig::default(),
            working_dir: temp_dir.path().to_path_buf(),
            meta_file_path: Some(meta_file.clone()),
            experimental: false,
        };
        
        let config_without_meta = RuntimeConfig {
            meta_config: MetaConfig::default(),
            working_dir: temp_dir.path().to_path_buf(),
            meta_file_path: None,
            experimental: false,
        };
        
        assert!(config_with_meta.has_meta_file());
        assert!(!config_without_meta.has_meta_file());
    }
    
    #[test]
    fn test_runtime_config_meta_root() {
        let temp_dir = tempdir().unwrap();
        let meta_file = temp_dir.path().join("subdir").join(".meta");
        fs::create_dir_all(meta_file.parent().unwrap()).unwrap();
        
        let config = RuntimeConfig {
            meta_config: MetaConfig::default(),
            working_dir: temp_dir.path().to_path_buf(),
            meta_file_path: Some(meta_file.clone()),
            experimental: false,
        };
        
        assert_eq!(config.meta_root(), Some(temp_dir.path().join("subdir")));
    }
    
    #[test]
    fn test_load_invalid_json() {
        let temp_dir = tempdir().unwrap();
        let meta_file = temp_dir.path().join(".meta");
        
        // Write invalid JSON
        fs::write(&meta_file, "{ invalid json }").unwrap();
        
        // Should return an error
        let result = MetaConfig::load_from_file(&meta_file);
        assert!(result.is_err());
    }
}