Skip to main content

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