use anyhow::Result;
use clap::{ArgMatches, Command};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub mod config_format;
pub mod interactive;
mod plugin_base;
mod plugin_builder;
mod plugin_manifest;
pub mod security;
pub mod tui;
pub use config_format::{ConfigFormat, CANONICAL_FILENAME, KNOWN_FILENAMES, LEGACY_FILENAME};
pub use interactive::{
is_interactive, prompt_confirm, prompt_multiselect, prompt_select, prompt_text, prompt_url,
NonInteractiveMode,
};
pub use plugin_base::{
ArgumentInfo, BasePlugin, CommandInfo, HelpFormat, HelpFormatter, JsonHelpFormatter,
MarkdownHelpFormatter, PluginMetadata, TerminalHelpFormatter, YamlHelpFormatter,
};
pub use plugin_builder::{
arg, command, plugin, ArgBuilder, BuiltPlugin, CommandBuilder, PluginBuilder,
};
pub use plugin_manifest::{
ArgValueType, Dependency, Example, ExecutionConfig, ManifestArg, ManifestCommand, PluginConfig,
PluginInfo, PluginManifest,
};
pub use security::{
canonicalize_creatable, ensure_within_base, is_dangerous_env_var, is_supported_git_url,
is_unencrypted_git_scheme, validate_path_segment, validate_project_url, DANGEROUS_ENV_VARS,
};
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,
pub non_interactive: Option<NonInteractiveMode>,
}
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.keys() {
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, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ProjectEntry {
Url(String),
Metadata(ProjectMetadata),
}
#[derive(Debug, Clone, PartialEq, 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>,
#[serde(default)]
pub worktree_init: Option<String>,
#[serde(default)]
pub bare: Option<bool>,
}
#[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>>, #[serde(default)]
pub worktree_init: Option<String>, #[serde(default)]
pub default_bare: Option<bool>, }
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,
worktree_init: None,
default_bare: None,
}
}
}
#[derive(Debug, Clone)]
pub struct DiscoveredConfig {
pub path: PathBuf,
pub format: ConfigFormat,
}
#[derive(Debug)]
pub enum ConfigDiscoveryError {
Multiple { dir: PathBuf, files: Vec<PathBuf> },
Io(std::io::Error),
}
impl std::fmt::Display for ConfigDiscoveryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigDiscoveryError::Multiple { dir, files } => {
writeln!(
f,
"multiple metarepo config files found in {}:",
dir.display()
)?;
for p in files {
writeln!(f, " - {}", p.display())?;
}
write!(
f,
"Pick one of: pass --config <path>, run `meta config migrate` to consolidate, or remove the unwanted file(s)."
)
}
ConfigDiscoveryError::Io(e) => write!(f, "{}", e),
}
}
}
impl std::error::Error for ConfigDiscoveryError {}
impl From<std::io::Error> for ConfigDiscoveryError {
fn from(e: std::io::Error) -> Self {
ConfigDiscoveryError::Io(e)
}
}
impl MetaConfig {
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let format = ConfigFormat::from_path(path).ok_or_else(|| {
anyhow::anyhow!(
"Unrecognized config filename: {}. Expected one of: {}",
path.display(),
KNOWN_FILENAMES.join(", ")
)
})?;
Self::load_from_file_with_format(path, format)
}
pub fn load_from_file_with_format<P: AsRef<Path>>(
path: P,
format: ConfigFormat,
) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref())?;
let mut config: MetaConfig = config_format::deserialize_from_str(&content, format)?;
config.sanitize_after_load();
Ok(config)
}
fn sanitize_after_load(&mut self) {
let bad_keys: Vec<String> = self
.projects
.keys()
.filter(|k| security::validate_path_segment("project key", k).is_err())
.cloned()
.collect();
for k in bad_keys {
eprintln!(
"warning: dropping project '{}' from config (invalid path segment: traversal, null, or absolute path)",
k
);
self.projects.remove(&k);
}
for (project, entry) in self.projects.iter_mut() {
if let ProjectEntry::Metadata(metadata) = entry {
let dangerous: Vec<String> = metadata
.env
.keys()
.filter(|k| security::is_dangerous_env_var(k))
.cloned()
.collect();
for k in dangerous {
eprintln!(
"warning: ignoring env var '{}' for project '{}' (known to subvert subprocesses)",
k, project
);
metadata.env.remove(&k);
}
}
}
}
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let path = path.as_ref();
let format = ConfigFormat::from_path(path).unwrap_or(ConfigFormat::Json);
self.save_to_file_with_format(path, format)
}
pub fn save_to_file_with_format<P: AsRef<Path>>(
&self,
path: P,
format: ConfigFormat,
) -> Result<()> {
let content = config_format::serialize_to_string(self, format)?;
std::fs::write(path.as_ref(), content)?;
Ok(())
}
pub fn discover_from(
start: &Path,
) -> std::result::Result<Option<DiscoveredConfig>, ConfigDiscoveryError> {
let mut current = start.to_path_buf();
loop {
let mut found: Vec<PathBuf> = Vec::new();
for name in KNOWN_FILENAMES {
let candidate = current.join(name);
if candidate.exists() {
found.push(candidate);
}
}
match found.len() {
0 => {
if !current.pop() {
return Ok(None);
}
}
1 => {
let path = found.into_iter().next().unwrap();
let format = ConfigFormat::from_path(&path).unwrap_or(ConfigFormat::Json);
return Ok(Some(DiscoveredConfig { path, format }));
}
_ => {
return Err(ConfigDiscoveryError::Multiple {
dir: current,
files: found,
});
}
}
}
}
pub fn find_meta_file() -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?;
Self::discover_from(&cwd).ok().flatten().map(|d| d.path)
}
pub fn load() -> Result<Self> {
let cwd = std::env::current_dir()?;
match Self::discover_from(&cwd) {
Ok(Some(found)) => Self::load_from_file_with_format(&found.path, found.format),
Ok(None) => Err(anyhow::anyhow!(
"No metarepo config found (looked for: {})",
KNOWN_FILENAMES.join(", ")
)),
Err(e) => Err(anyhow::anyhow!("{}", e)),
}
}
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)
}
pub fn get_worktree_init(&self, project_name: &str) -> Option<String> {
if let Some(ProjectEntry::Metadata(metadata)) = self.projects.get(project_name) {
if let Some(worktree_init) = &metadata.worktree_init {
return Some(worktree_init.clone());
}
}
self.worktree_init.clone()
}
pub fn is_bare_repo(&self, project_name: &str) -> bool {
if let Some(ProjectEntry::Metadata(metadata)) = self.projects.get(project_name) {
return metadata.bare.unwrap_or(false);
}
false
}
}
#[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(), 5);
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.ignore.contains(&".DS_Store".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(),
ProjectEntry::Url("https://github.com/user/repo.git".to_string()),
);
config.projects.insert(
"project2".to_string(),
ProjectEntry::Url("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(&ProjectEntry::Url(
"https://github.com/user/repo.git".to_string()
))
);
assert_eq!(
loaded_config.projects.get("project2"),
Some(&ProjectEntry::Url(
"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 config = MetaConfig {
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,
}),
..Default::default()
};
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!(nested.recursive_import);
assert_eq!(nested.max_depth, 5);
assert!(nested.flatten);
assert!(!nested.cycle_detection);
assert_eq!(nested.ignore_nested, vec!["ignored-project".to_string()]);
assert_eq!(nested.namespace_separator, Some("::".to_string()));
assert!(nested.preserve_structure);
}
#[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!(!nested.recursive_import);
assert_eq!(nested.max_depth, 3);
assert!(!nested.flatten);
assert!(nested.cycle_detection);
assert!(nested.ignore_nested.is_empty());
assert!(nested.namespace_separator.is_none());
assert!(!nested.preserve_structure);
}
#[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,
non_interactive: None,
};
let config_without_meta = RuntimeConfig {
meta_config: MetaConfig::default(),
working_dir: temp_dir.path().to_path_buf(),
meta_file_path: None,
experimental: false,
non_interactive: None,
};
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,
non_interactive: None,
};
assert_eq!(config.meta_root(), Some(temp_dir.path().join("subdir")));
}
#[test]
fn roundtrip_each_format_preserves_projects() {
for (filename, format) in [
(".metarepo", ConfigFormat::Json),
(".metarepo.yaml", ConfigFormat::Yaml),
(".metarepo.toml", ConfigFormat::Toml),
] {
let tmp = tempdir().unwrap();
let path = tmp.path().join(filename);
let mut config = MetaConfig::default();
config.projects.insert(
"alpha".to_string(),
ProjectEntry::Url("https://example.com/alpha.git".to_string()),
);
config
.save_to_file_with_format(&path, format)
.unwrap_or_else(|e| panic!("save {:?} failed: {}", filename, e));
let loaded = MetaConfig::load_from_file(&path).unwrap();
assert!(
loaded.projects.contains_key("alpha"),
"{} roundtrip lost projects",
filename
);
}
}
#[test]
fn discover_finds_canonical_in_cwd() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(".metarepo"), "{}").unwrap();
let found = MetaConfig::discover_from(tmp.path()).unwrap().unwrap();
assert_eq!(found.format, ConfigFormat::Json);
assert_eq!(found.path.file_name().unwrap(), ".metarepo");
}
#[test]
fn discover_walks_up_ancestors() {
let tmp = tempdir().unwrap();
let nested = tmp.path().join("a").join("b").join("c");
fs::create_dir_all(&nested).unwrap();
std::fs::write(tmp.path().join(".metarepo.yaml"), "ignore: []\n").unwrap();
let found = MetaConfig::discover_from(&nested).unwrap().unwrap();
assert_eq!(found.format, ConfigFormat::Yaml);
}
#[test]
fn discover_errors_on_multi_file_conflict() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(".meta"), "{}").unwrap();
std::fs::write(tmp.path().join(".metarepo.yaml"), "ignore: []\n").unwrap();
let err = MetaConfig::discover_from(tmp.path()).expect_err("should error on conflict");
match err {
ConfigDiscoveryError::Multiple { ref files, .. } => {
assert_eq!(files.len(), 2);
}
other => panic!("expected Multiple variant, got {:?}", other),
}
let msg = err.to_string();
assert!(msg.contains(".meta"));
assert!(msg.contains(".metarepo.yaml"));
assert!(msg.contains("--config") || msg.contains("config migrate"));
}
#[test]
fn discover_returns_none_when_no_config_anywhere() {
let tmp = tempdir().unwrap();
let nested = tmp.path().join("deep").join("nested");
fs::create_dir_all(&nested).unwrap();
assert!(MetaConfig::discover_from(&nested).unwrap().is_none());
}
#[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());
}
}