1use anyhow::Result;
2use clap::{ArgMatches, Command};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7pub 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
30pub trait MetaPlugin: Send + Sync {
32 fn name(&self) -> &str;
34
35 fn register_commands(&self, app: Command) -> Command;
37
38 fn handle_command(&self, matches: &ArgMatches, config: &RuntimeConfig) -> Result<()>;
40
41 fn is_experimental(&self) -> bool {
43 false
44 }
45}
46
47#[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 pub fn current_project(&self) -> Option<String> {
74 let meta_root = self.meta_root()?;
75 let cwd = &self.working_dir;
76
77 if !cwd.starts_with(&meta_root) {
79 return None;
80 }
81
82 let _relative = cwd.strip_prefix(&meta_root).ok()?;
84
85 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 pub fn resolve_project(&self, identifier: &str) -> Option<String> {
98 if self.meta_config.projects.contains_key(identifier) {
100 return Some(identifier.to_string());
101 }
102
103 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 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 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 pub fn project_identifiers(&self, project_name: &str) -> Vec<String> {
133 let mut identifiers = vec![project_name.to_string()];
134
135 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 identifiers
146 }
147}
148
149#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194#[serde(untagged)]
195pub enum ProjectEntry {
196 Url(String),
198 Metadata(ProjectMetadata),
200}
201
202#[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#[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>, #[serde(default)]
226 pub plugins: Option<HashMap<String, String>>, #[serde(default)]
228 pub nested: Option<NestedConfig>, #[serde(default)]
230 pub aliases: Option<HashMap<String, String>>, #[serde(default)]
232 pub scripts: Option<HashMap<String, String>>, #[serde(default)]
234 pub worktree_init: Option<String>, #[serde(default)]
236 pub default_bare: Option<bool>, }
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 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 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 pub fn get_all_scripts(&self, project_name: Option<&str>) -> HashMap<String, String> {
324 let mut scripts = HashMap::new();
325
326 if let Some(global_scripts) = &self.scripts {
328 scripts.extend(global_scripts.clone());
329 }
330
331 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 pub fn project_exists(&self, project_name: &str) -> bool {
343 self.projects.contains_key(project_name)
344 }
345
346 pub fn get_worktree_init(&self, project_name: &str) -> Option<String> {
348 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 self.worktree_init.clone()
357 }
358
359 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 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 config.save_to_file(&meta_file).unwrap();
406
407 let loaded_config = MetaConfig::load_from_file(&meta_file).unwrap();
409
410 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 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 config.save_to_file(&meta_file).unwrap();
448 let loaded_config = MetaConfig::load_from_file(&meta_file).unwrap();
449
450 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 let meta_file = temp_dir.path().join(".meta");
470 let config = MetaConfig::default();
471 config.save_to_file(&meta_file).unwrap();
472
473 let original_dir = std::env::current_dir().unwrap();
475 std::env::set_current_dir(&nested_dir).unwrap();
476
477 let found_file = MetaConfig::find_meta_file();
479 assert!(found_file.is_some());
480 assert_eq!(
482 found_file.unwrap().canonicalize().unwrap(),
483 meta_file.canonicalize().unwrap()
484 );
485
486 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 fs::write(&meta_file, "{ invalid json }").unwrap();
551
552 let result = MetaConfig::load_from_file(&meta_file);
554 assert!(result.is_err());
555 }
556}