use anyhow::Result;
use clap::{ArgMatches, Command};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
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,
};
pub trait MetaPlugin: Send + Sync {
fn name(&self) -> &str;
fn register_commands(&self, app: Command) -> Command;
fn handle_command(&self, matches: &ArgMatches, config: &RuntimeConfig) -> Result<()>;
fn is_experimental(&self) -> bool {
false
}
}
#[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
}
pub fn current_project(&self) -> Option<String> {
let meta_root = self.meta_root()?;
let cwd = &self.working_dir;
if !cwd.starts_with(&meta_root) {
return None;
}
let _relative = cwd.strip_prefix(&meta_root).ok()?;
for (project_name, _) in &self.meta_config.projects {
let project_path = meta_root.join(project_name);
if cwd.starts_with(&project_path) {
return Some(project_name.clone());
}
}
None
}
pub fn resolve_project(&self, identifier: &str) -> Option<String> {
if self.meta_config.projects.contains_key(identifier) {
return Some(identifier.to_string());
}
if let Some(aliases) = &self.meta_config.aliases {
if let Some(project_path) = aliases.get(identifier) {
return Some(project_path.clone());
}
}
for (project_name, entry) in &self.meta_config.projects {
if let ProjectEntry::Metadata(metadata) = entry {
if metadata.aliases.contains(&identifier.to_string()) {
return Some(project_name.clone());
}
}
}
for project_name in self.meta_config.projects.keys() {
if let Some(basename) = std::path::Path::new(project_name).file_name() {
if basename.to_string_lossy() == identifier {
return Some(project_name.clone());
}
}
}
None
}
pub fn project_identifiers(&self, project_name: &str) -> Vec<String> {
let mut identifiers = vec![project_name.to_string()];
if let Some(basename) = std::path::Path::new(project_name).file_name() {
let basename_str = basename.to_string_lossy().to_string();
if basename_str != project_name {
identifiers.push(basename_str);
}
}
identifiers
}
}
#[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,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ProjectEntry {
Url(String),
Metadata(ProjectMetadata),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMetadata {
pub url: String,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub scripts: HashMap<String, String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetaConfig {
#[serde(default)]
pub ignore: Vec<String>,
#[serde(default)]
pub projects: HashMap<String, ProjectEntry>, #[serde(default)]
pub plugins: Option<HashMap<String, String>>, #[serde(default)]
pub nested: Option<NestedConfig>, #[serde(default)]
pub aliases: Option<HashMap<String, String>>, #[serde(default)]
pub scripts: Option<HashMap<String, String>>, }
impl Default for MetaConfig {
fn default() -> Self {
Self {
ignore: vec![
".git".to_string(),
".vscode".to_string(),
"node_modules".to_string(),
"target".to_string(),
".DS_Store".to_string(),
],
projects: HashMap::new(),
plugins: None,
nested: None,
aliases: None,
scripts: 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"))
}
}
pub fn get_project_url(&self, project_name: &str) -> Option<String> {
self.projects.get(project_name).map(|entry| match entry {
ProjectEntry::Url(url) => url.clone(),
ProjectEntry::Metadata(metadata) => metadata.url.clone(),
})
}
pub fn get_project_scripts(&self, project_name: &str) -> Option<HashMap<String, String>> {
self.projects.get(project_name).and_then(|entry| match entry {
ProjectEntry::Url(_) => None,
ProjectEntry::Metadata(metadata) => {
if metadata.scripts.is_empty() {
None
} else {
Some(metadata.scripts.clone())
}
}
})
}
pub fn get_all_scripts(&self, project_name: Option<&str>) -> HashMap<String, String> {
let mut scripts = HashMap::new();
if let Some(global_scripts) = &self.scripts {
scripts.extend(global_scripts.clone());
}
if let Some(project) = project_name {
if let Some(project_scripts) = self.get_project_scripts(project) {
scripts.extend(project_scripts);
}
}
scripts
}
pub fn project_exists(&self, project_name: &str) -> bool {
self.projects.contains_key(project_name)
}
}
#[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");
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());
config.save_to_file(&meta_file).unwrap();
let loaded_config = MetaConfig::load_from_file(&meta_file).unwrap();
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");
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,
});
config.save_to_file(&meta_file).unwrap();
let loaded_config = MetaConfig::load_from_file(&meta_file).unwrap();
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();
let meta_file = temp_dir.path().join(".meta");
let config = MetaConfig::default();
config.save_to_file(&meta_file).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&nested_dir).unwrap();
let found_file = MetaConfig::find_meta_file();
assert!(found_file.is_some());
assert_eq!(found_file.unwrap().canonicalize().unwrap(), meta_file.canonicalize().unwrap());
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");
fs::write(&meta_file, "{ invalid json }").unwrap();
let result = MetaConfig::load_from_file(&meta_file);
assert!(result.is_err());
}
}