1use anyhow::Result;
2use clap::{ArgMatches, Command};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7mod 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
26pub trait MetaPlugin: Send + Sync {
28 fn name(&self) -> &str;
30
31 fn register_commands(&self, app: Command) -> Command;
33
34 fn handle_command(&self, matches: &ArgMatches, config: &RuntimeConfig) -> Result<()>;
36
37 fn is_experimental(&self) -> bool {
39 false
40 }
41}
42
43#[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 pub fn current_project(&self) -> Option<String> {
67 let meta_root = self.meta_root()?;
68 let cwd = &self.working_dir;
69
70 if !cwd.starts_with(&meta_root) {
72 return None;
73 }
74
75 let _relative = cwd.strip_prefix(&meta_root).ok()?;
77
78 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 pub fn resolve_project(&self, identifier: &str) -> Option<String> {
91 if self.meta_config.projects.contains_key(identifier) {
93 return Some(identifier.to_string());
94 }
95
96 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 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 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 pub fn project_identifiers(&self, project_name: &str) -> Vec<String> {
126 let mut identifiers = vec![project_name.to_string()];
127
128 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 identifiers
139 }
140}
141
142#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(untagged)]
182pub enum ProjectEntry {
183 Url(String),
185 Metadata(ProjectMetadata),
187}
188
189#[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#[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>, #[serde(default)]
209 pub plugins: Option<HashMap<String, String>>, #[serde(default)]
211 pub nested: Option<NestedConfig>, #[serde(default)]
213 pub aliases: Option<HashMap<String, String>>, #[serde(default)]
215 pub scripts: Option<HashMap<String, String>>, }
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 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 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 pub fn get_all_scripts(&self, project_name: Option<&str>) -> HashMap<String, String> {
299 let mut scripts = HashMap::new();
300
301 if let Some(global_scripts) = &self.scripts {
303 scripts.extend(global_scripts.clone());
304 }
305
306 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 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 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 config.save_to_file(&meta_file).unwrap();
354
355 let loaded_config = MetaConfig::load_from_file(&meta_file).unwrap();
357
358 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 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 config.save_to_file(&meta_file).unwrap();
384 let loaded_config = MetaConfig::load_from_file(&meta_file).unwrap();
385
386 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 let meta_file = temp_dir.path().join(".meta");
406 let config = MetaConfig::default();
407 config.save_to_file(&meta_file).unwrap();
408
409 let original_dir = std::env::current_dir().unwrap();
411 std::env::set_current_dir(&nested_dir).unwrap();
412
413 let found_file = MetaConfig::find_meta_file();
415 assert!(found_file.is_some());
416 assert_eq!(found_file.unwrap().canonicalize().unwrap(), meta_file.canonicalize().unwrap());
418
419 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 fs::write(&meta_file, "{ invalid json }").unwrap();
481
482 let result = MetaConfig::load_from_file(&meta_file);
484 assert!(result.is_err());
485 }
486}