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 config_setting;
pub mod interactive;
mod module_manifest;
mod plugin_base;
mod plugin_builder;
mod plugin_manifest;
pub mod protocol;
pub mod security;
pub mod tui;
pub use config_format::{ConfigFormat, CANONICAL_FILENAME, KNOWN_FILENAMES, LEGACY_FILENAME};
pub use config_setting::{ConfigSetting, ConfigValueType};
pub use interactive::{
is_interactive, prompt_confirm, prompt_multiselect, prompt_select, prompt_text, prompt_url,
NonInteractiveMode,
};
pub use module_manifest::{
MetaModuleManifest, ModuleInfo, ModulePluginRef, ModuleSkillRef, MODULE_MANIFEST_FILENAMES,
};
pub use plugin_base::{
ArgumentInfo, BasePlugin, CommandInfo, HelpFormat, HelpFormatter, JsonHelpFormatter,
MarkdownHelpFormatter, PluginMetadata, TerminalHelpFormatter, YamlHelpFormatter,
};
pub use plugin_builder::{
arg, command, format_help_description, plugin, with_standard_help, ArgBuilder, BuiltPlugin,
CommandBuilder, PluginBuilder,
};
pub use plugin_manifest::{
ArgValueType, Dependency, Example, ExecutionConfig, ManifestArg, ManifestCommand, PluginConfig,
PluginInfo, PluginManifest, MANIFEST_FILENAMES,
};
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
}
fn settings(&self) -> Vec<ConfigSetting> {
Vec::new()
}
fn reported_version(&self) -> Option<&str> {
None
}
}
#[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>,
pub scope_workspace: bool,
pub settings_catalog: Vec<ConfigSetting>,
}
impl RuntimeConfig {
pub fn has_meta_file(&self) -> bool {
self.meta_file_path.is_some()
}
pub fn meta_root(&self) -> Option<PathBuf> {
meta_root_of(self.meta_file_path.as_deref())
}
pub fn is_experimental(&self) -> bool {
self.experimental
}
pub fn plugin_config<T: serde::de::DeserializeOwned>(&self, name: &str) -> Option<T> {
self.meta_config.plugin_settings(name)
}
pub fn current_project(&self) -> Option<String> {
let meta_root = self.meta_root()?;
current_project_of(&self.meta_config, &meta_root, &self.working_dir)
}
pub fn scoped_project_keys(&self) -> Vec<String> {
scoped_keys(
&self.meta_config,
&self.working_dir,
self.meta_file_path.as_deref(),
self.scope_workspace,
)
}
pub fn resolve_project(&self, identifier: &str) -> Option<String> {
self.meta_config.resolve_identifier(identifier)
}
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
}
}
pub fn meta_root_of(meta_file_path: Option<&Path>) -> Option<PathBuf> {
meta_file_path.and_then(|p| p.parent().map(|p| p.to_path_buf()))
}
pub fn current_project_of(
meta_config: &MetaConfig,
meta_root: &Path,
working_dir: &Path,
) -> Option<String> {
if !working_dir.starts_with(meta_root) {
return None;
}
let mut best: Option<&String> = None;
for project_name in meta_config.projects.keys() {
if working_dir.starts_with(meta_root.join(project_name))
&& best.is_none_or(|b| project_name.len() > b.len())
{
best = Some(project_name);
}
}
best.cloned()
}
pub fn projects_in_scope(
meta_root: &Path,
working_dir: &Path,
project_keys: &[String],
current_project: Option<String>,
) -> Vec<String> {
if let Some(project) = current_project {
return vec![project];
}
let Ok(rel) = working_dir.strip_prefix(meta_root) else {
return project_keys.to_vec();
};
if rel.as_os_str().is_empty() {
return project_keys.to_vec();
}
project_keys
.iter()
.filter(|key| Path::new(key).starts_with(rel))
.cloned()
.collect()
}
pub fn scoped_keys(
meta_config: &MetaConfig,
working_dir: &Path,
meta_file_path: Option<&Path>,
scope_workspace: bool,
) -> Vec<String> {
let mut keys: Vec<String> = meta_config.projects.keys().cloned().collect();
keys.sort();
let in_scope = if scope_workspace {
keys
} else if let Some(meta_root) = meta_root_of(meta_file_path) {
let current = current_project_of(meta_config, &meta_root, working_dir);
projects_in_scope(&meta_root, working_dir, &keys, current)
} else {
keys
};
let disabled = meta_config.disabled_project_keys();
in_scope
.into_iter()
.filter(|key| !disabled.contains(key))
.collect()
}
pub fn pattern_matches(text: &str, pattern: &str) -> bool {
if !pattern.contains('*') {
return text == pattern || text.contains(pattern);
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.is_empty() {
return true;
}
let mut current_pos = 0;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 && !pattern.starts_with('*') {
if !text.starts_with(part) {
return false;
}
current_pos = part.len();
} else if i == parts.len() - 1 && !pattern.ends_with('*') {
if !text.ends_with(part) {
return false;
}
} else if let Some(pos) = text[current_pos..].find(part) {
current_pos += pos + part.len();
} else {
return false;
}
}
true
}
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub depth: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetaConfig {
#[serde(default)]
pub ignore: Vec<String>,
#[serde(default)]
pub projects: HashMap<String, ProjectEntry>, #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled: Vec<String>,
#[serde(default)]
pub plugins: Option<HashMap<String, String>>, #[serde(default, skip_serializing_if = "Option::is_none")]
pub modules: 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>, #[serde(rename = "plugins-integrity", default)]
pub plugins_integrity: Option<String>, #[serde(
rename = "allow-version-mismatch",
default,
skip_serializing_if = "Option::is_none"
)]
pub allow_version_mismatch: Option<bool>,
#[serde(
rename = "plugin-allow-any-path",
default,
skip_serializing_if = "Option::is_none"
)]
pub plugin_allow_any_path: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skill: Option<SkillSettings>, #[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpSettings>, #[serde(default, skip_serializing_if = "Option::is_none")]
pub help_descriptions: Option<HashMap<String, String>>,
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct SkillSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dest: Option<String>,
#[serde(
rename = "adapt-command",
default,
skip_serializing_if = "Option::is_none"
)]
pub adapt_command: Option<String>,
#[serde(
rename = "adapt-args",
default,
skip_serializing_if = "Option::is_none"
)]
pub adapt_args: Option<Vec<String>>,
#[serde(
rename = "search-url",
default,
skip_serializing_if = "Option::is_none"
)]
pub search_url: Option<String>,
#[serde(
rename = "detail-url",
default,
skip_serializing_if = "Option::is_none"
)]
pub detail_url: Option<String>,
#[serde(
rename = "search-limit",
default,
skip_serializing_if = "Option::is_none"
)]
pub search_limit: Option<usize>,
#[serde(rename = "api-key", default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(
rename = "audit-patterns",
default,
skip_serializing_if = "Option::is_none"
)]
pub audit_patterns: Option<Vec<AuditPattern>>,
#[serde(
rename = "audit-suppress",
default,
skip_serializing_if = "Option::is_none"
)]
pub audit_suppress: Option<Vec<String>>,
#[serde(
rename = "dest-roots",
default,
skip_serializing_if = "Option::is_none"
)]
pub dest_roots: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuditPattern {
pub severity: String,
pub pattern: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct McpSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serve: Option<McpServeSettings>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct McpServeSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(
rename = "allow-exec",
default,
skip_serializing_if = "Option::is_none"
)]
pub allow_exec: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projects: Option<Vec<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(),
disabled: Vec::new(),
plugins: None,
modules: None,
nested: None,
aliases: None,
scripts: None,
worktree_init: None,
default_bare: None,
plugins_integrity: None,
allow_version_mismatch: None,
plugin_allow_any_path: None,
skill: None,
mcp: None,
help_descriptions: None,
extra: HashMap::new(),
}
}
}
#[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 integrity_required(&self) -> bool {
self.plugins_integrity
.as_deref()
.map(|v| v.eq_ignore_ascii_case("required"))
.unwrap_or(false)
}
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.is_file() {
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 discover_topmost_from(
start: &Path,
) -> std::result::Result<Option<DiscoveredConfig>, ConfigDiscoveryError> {
let mut current = start.to_path_buf();
let mut outermost: Option<DiscoveredConfig> = None;
loop {
let mut found: Vec<PathBuf> = Vec::new();
for name in KNOWN_FILENAMES {
let candidate = current.join(name);
if candidate.is_file() {
found.push(candidate);
}
}
match found.len() {
0 => {}
1 => {
let path = found.into_iter().next().unwrap();
let format = ConfigFormat::from_path(&path).unwrap_or(ConfigFormat::Json);
outermost = Some(DiscoveredConfig { path, format });
}
_ => {
return Err(ConfigDiscoveryError::Multiple {
dir: current,
files: found,
});
}
}
if !current.pop() {
return Ok(outermost);
}
}
}
pub fn discover_chain_from(
start: &Path,
) -> std::result::Result<Vec<DiscoveredConfig>, ConfigDiscoveryError> {
let mut current = start.to_path_buf();
let mut chain: Vec<DiscoveredConfig> = Vec::new();
loop {
let mut found: Vec<PathBuf> = Vec::new();
for name in KNOWN_FILENAMES {
let candidate = current.join(name);
if candidate.is_file() {
found.push(candidate);
}
}
match found.len() {
0 => {}
1 => {
let path = found.into_iter().next().unwrap();
let format = ConfigFormat::from_path(&path).unwrap_or(ConfigFormat::Json);
chain.push(DiscoveredConfig { path, format });
}
_ => {
return Err(ConfigDiscoveryError::Multiple {
dir: current,
files: found,
});
}
}
if !current.pop() {
break;
}
}
chain.reverse();
Ok(chain)
}
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 locate_in(base_path: &Path) -> Result<DiscoveredConfig> {
match Self::discover_from(base_path) {
Ok(Some(found)) => Ok(found),
Ok(None) => Err(anyhow::anyhow!(
"No metarepo config file found. Run 'meta init' first."
)),
Err(e) => Err(anyhow::anyhow!("{}", e)),
}
}
pub fn config_in_dir(dir: &Path) -> Option<DiscoveredConfig> {
for name in KNOWN_FILENAMES {
let candidate = dir.join(name);
if candidate.is_file() {
let format = ConfigFormat::from_path(&candidate).unwrap_or(ConfigFormat::Json);
return Some(DiscoveredConfig {
path: candidate,
format,
});
}
}
None
}
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 resolve_identifier(&self, identifier: &str) -> Option<String> {
if self.projects.contains_key(identifier) {
return Some(identifier.to_string());
}
if let Some(aliases) = &self.aliases {
if let Some(project_path) = aliases.get(identifier) {
return Some(project_path.clone());
}
}
for (project_name, entry) in &self.projects {
if let ProjectEntry::Metadata(metadata) = entry {
if metadata.aliases.contains(&identifier.to_string()) {
return Some(project_name.clone());
}
}
}
for project_name in self.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 disabled_project_keys(&self) -> std::collections::HashSet<String> {
let mut set = std::collections::HashSet::new();
for (key, entry) in &self.projects {
if let ProjectEntry::Metadata(metadata) = entry {
if metadata.enabled == Some(false) {
set.insert(key.clone());
}
}
}
for pattern in &self.disabled {
if pattern.contains('*') {
for key in self.projects.keys() {
if pattern_matches(key, pattern) {
set.insert(key.clone());
}
}
} else if let Some(resolved) = self.resolve_identifier(pattern) {
set.insert(resolved);
}
}
set
}
pub fn is_project_disabled(&self, identifier: &str) -> bool {
let disabled = self.disabled_project_keys();
match self.resolve_identifier(identifier) {
Some(key) => disabled.contains(&key),
None => disabled.contains(identifier),
}
}
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 merge_global_scripts(chain: &[MetaConfig]) -> HashMap<String, String> {
let mut merged = HashMap::new();
for cfg in chain {
if let Some(scripts) = &cfg.scripts {
merged.extend(scripts.clone());
}
}
merged
}
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
}
pub fn get_project_depth(&self, project_name: &str) -> Option<i32> {
if let Some(ProjectEntry::Metadata(metadata)) = self.projects.get(project_name) {
return metadata.depth;
}
None
}
pub fn plugin_settings<T: serde::de::DeserializeOwned>(&self, name: &str) -> Option<T> {
let json = serde_json::to_value(self).ok()?;
let block = json.get(name)?;
if block.is_null() {
return None;
}
serde_json::from_value(block.clone()).ok()
}
pub fn get_dotted(&self, key: &str) -> Option<serde_json::Value> {
let json = serde_json::to_value(self).ok()?;
let mut current = &json;
for part in key.split('.') {
match current.get(part) {
Some(v) if !v.is_null() => current = v,
_ => return None,
}
}
Some(current.clone())
}
pub fn with_dotted_set(&self, key: &str, value: serde_json::Value) -> Result<MetaConfig> {
let mut json = serde_json::to_value(self)?;
let parts: Vec<&str> = key.split('.').collect();
if !json.is_object() {
json = serde_json::Value::Object(serde_json::Map::new());
}
let mut current = &mut json;
for part in &parts[..parts.len() - 1] {
let slot = current
.as_object_mut()
.ok_or_else(|| {
anyhow::anyhow!("Cannot set '{}': '{}' is not an object", key, part)
})?
.entry(part.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if slot.is_null() {
*slot = serde_json::Value::Object(serde_json::Map::new());
}
current = slot;
}
let last = parts[parts.len() - 1];
current
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("Cannot set '{}': parent is not an object", key))?
.insert(last.to_string(), value);
Ok(serde_json::from_value(json)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn keys(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn dotted_set_creates_missing_nested_block() {
let cfg = MetaConfig::default();
assert!(cfg.get_dotted("skill.dest").is_none());
let updated = cfg
.with_dotted_set("skill.dest", serde_json::json!("~/.claude/skills"))
.unwrap();
assert_eq!(
updated.get_dotted("skill.dest"),
Some(serde_json::json!("~/.claude/skills"))
);
assert_eq!(
updated.skill.as_ref().and_then(|s| s.dest.as_deref()),
Some("~/.claude/skills")
);
}
#[test]
fn security_toggles_round_trip_via_dotted_keys() {
let cfg = MetaConfig::default();
assert!(cfg.get_dotted("allow-version-mismatch").is_none());
assert!(cfg.get_dotted("plugin-allow-any-path").is_none());
let updated = cfg
.with_dotted_set("plugin-allow-any-path", serde_json::json!(true))
.unwrap();
assert_eq!(
updated.get_dotted("plugin-allow-any-path"),
Some(serde_json::json!(true))
);
assert_eq!(updated.plugin_allow_any_path, Some(true));
}
#[test]
fn plugin_settings_deserializes_block() {
let cfg = MetaConfig::default()
.with_dotted_set("skill.search-limit", serde_json::json!(50))
.unwrap()
.with_dotted_set("skill.dest", serde_json::json!("~/s"))
.unwrap();
let s: SkillSettings = cfg.plugin_settings("skill").expect("skill block present");
assert_eq!(s.search_limit, Some(50));
assert_eq!(s.dest.as_deref(), Some("~/s"));
let none: Option<SkillSettings> = MetaConfig::default().plugin_settings("skill");
assert!(none.is_none());
}
#[test]
fn external_plugin_block_is_readable_and_survives_a_round_trip() {
#[derive(Debug, serde::Deserialize, PartialEq, Eq)]
struct ExampleSettings {
greeting: Option<String>,
#[serde(rename = "max-projects")]
max_projects: Option<usize>,
}
let raw = r#"{"projects":{},"example":{"greeting":"hi","max-projects":3}}"#;
let cfg: MetaConfig = serde_json::from_str(raw).unwrap();
let s: ExampleSettings = cfg
.plugin_settings("example")
.expect("external plugin block present");
assert_eq!(s.greeting.as_deref(), Some("hi"));
assert_eq!(s.max_projects, Some(3));
let out = serde_json::to_string(&cfg).unwrap();
let back: MetaConfig = serde_json::from_str(&out).unwrap();
assert_eq!(back.plugin_settings::<ExampleSettings>("example"), Some(s));
}
#[test]
fn external_plugin_block_survives_a_toml_round_trip() {
let raw = "ignore = [\".git\"]\n[projects]\n[example]\ngreeting = \"hi\"\n";
let cfg: MetaConfig = toml::from_str(raw).unwrap();
let out = toml::to_string_pretty(&cfg).expect("TOML serialization");
let back: MetaConfig = toml::from_str(&out).unwrap();
assert!(back.extra.contains_key("example"));
}
#[test]
fn dotted_get_returns_none_for_null_segment() {
let cfg = MetaConfig::default();
assert!(cfg.get_dotted("worktree_init").is_none());
}
#[test]
fn dotted_set_rejects_invalid_shape() {
let cfg = MetaConfig::default();
let err = cfg.with_dotted_set("default_bare.x", serde_json::json!(1));
assert!(err.is_err());
}
#[test]
fn scope_inside_a_project_targets_only_that_project() {
let scope = projects_in_scope(
Path::new("/ws"),
Path::new("/ws/app/src"),
&keys(&["app", "api", "plugins/a"]),
Some("app".to_string()),
);
assert_eq!(scope, vec!["app".to_string()]);
}
#[test]
fn scope_at_workspace_root_targets_all_projects() {
let all = keys(&["app", "api", "plugins/a"]);
let scope = projects_in_scope(Path::new("/ws"), Path::new("/ws"), &all, None);
assert_eq!(scope, all);
}
#[test]
fn scope_in_a_subdirectory_targets_projects_beneath_it() {
let scope = projects_in_scope(
Path::new("/ws"),
Path::new("/ws/plugins"),
&keys(&["app", "plugins/a", "plugins/b", "tools/x"]),
None,
);
assert_eq!(
scope,
vec!["plugins/a".to_string(), "plugins/b".to_string()]
);
}
#[test]
fn scope_in_an_empty_subdirectory_is_empty() {
let scope = projects_in_scope(
Path::new("/ws"),
Path::new("/ws/docs"),
&keys(&["app", "plugins/a"]),
None,
);
assert!(scope.is_empty());
}
#[test]
fn scope_outside_the_workspace_targets_all_projects() {
let all = keys(&["app", "api"]);
let scope = projects_in_scope(Path::new("/ws"), Path::new("/elsewhere"), &all, None);
assert_eq!(scope, all);
}
#[test]
fn current_project_of_picks_the_deepest_match() {
let mut cfg = MetaConfig::default();
cfg.projects
.insert("plugins".to_string(), ProjectEntry::Url("u".to_string()));
cfg.projects
.insert("plugins/a".to_string(), ProjectEntry::Url("u".to_string()));
let got = current_project_of(&cfg, Path::new("/ws"), Path::new("/ws/plugins/a/src"));
assert_eq!(got, Some("plugins/a".to_string()));
}
#[test]
fn scoped_keys_workspace_flag_returns_all_sorted() {
let mut cfg = MetaConfig::default();
for k in ["b", "a", "plugins/x"] {
cfg.projects
.insert(k.to_string(), ProjectEntry::Url("u".to_string()));
}
let keys = scoped_keys(
&cfg,
Path::new("/ws/plugins"), Some(Path::new("/ws/.meta")),
true, );
assert_eq!(keys, keys_sorted(&["a", "b", "plugins/x"]));
}
fn keys_sorted(list: &[&str]) -> Vec<String> {
let mut v = keys(list);
v.sort();
v
}
fn metadata_entry(url: &str, aliases: &[&str], enabled: Option<bool>) -> ProjectEntry {
ProjectEntry::Metadata(ProjectMetadata {
url: url.to_string(),
aliases: aliases.iter().map(|s| s.to_string()).collect(),
scripts: HashMap::new(),
env: HashMap::new(),
worktree_init: None,
bare: None,
enabled,
depth: None,
})
}
#[test]
fn disabled_via_enabled_false_flag() {
let mut cfg = MetaConfig::default();
cfg.projects
.insert("a".to_string(), metadata_entry("u", &[], Some(false)));
cfg.projects
.insert("b".to_string(), metadata_entry("u", &[], None));
let disabled = cfg.disabled_project_keys();
assert!(disabled.contains("a"));
assert!(!disabled.contains("b"));
assert!(cfg.is_project_disabled("a"));
}
#[test]
fn disabled_list_resolves_key_alias_and_wildcard() {
let mut cfg = MetaConfig::default();
cfg.projects.insert(
"services/web".to_string(),
metadata_entry("u", &["web"], None),
);
cfg.projects
.insert("services/api".to_string(), metadata_entry("u", &[], None));
cfg.projects
.insert("tools/lint".to_string(), ProjectEntry::Url("u".to_string()));
cfg.disabled = vec!["web".to_string(), "services/*".to_string()];
let disabled = cfg.disabled_project_keys();
assert!(disabled.contains("services/web"));
assert!(disabled.contains("services/api"));
assert!(!disabled.contains("tools/lint"));
}
#[test]
fn disabled_cannot_be_bypassed_by_alias() {
let mut cfg = MetaConfig::default();
cfg.projects.insert(
"old-thing".to_string(),
metadata_entry("u", &["legacy"], None),
);
cfg.disabled = vec!["old-thing".to_string()];
assert!(cfg.is_project_disabled("legacy"));
assert!(cfg.is_project_disabled("old-thing"));
}
#[test]
fn scoped_keys_excludes_disabled_projects() {
let mut cfg = MetaConfig::default();
cfg.projects
.insert("a".to_string(), ProjectEntry::Url("u".to_string()));
cfg.projects
.insert("b".to_string(), metadata_entry("u", &[], Some(false)));
cfg.projects
.insert("c".to_string(), ProjectEntry::Url("u".to_string()));
cfg.disabled = vec!["c".to_string()];
let keys = scoped_keys(&cfg, Path::new("/ws"), Some(Path::new("/ws/.meta")), true);
assert_eq!(keys, keys_sorted(&["a"]));
}
#[test]
fn discover_topmost_returns_outermost_metarepo() {
let tmp = tempdir().unwrap();
let outer = tmp.path();
let inner = outer.join("inner");
fs::create_dir_all(&inner).unwrap();
fs::write(outer.join(".metarepo"), "{}").unwrap();
fs::write(inner.join(".metarepo"), "{}").unwrap();
let nearest = MetaConfig::discover_from(&inner).unwrap().unwrap();
assert_eq!(nearest.path, inner.join(".metarepo"));
let topmost = MetaConfig::discover_topmost_from(&inner).unwrap().unwrap();
assert_eq!(topmost.path, outer.join(".metarepo"));
}
#[test]
fn discover_chain_orders_outermost_to_nearest() {
let tmp = tempdir().unwrap();
let outer = tmp.path();
let mid = outer.join("mid");
let inner = mid.join("inner");
fs::create_dir_all(&inner).unwrap();
fs::write(outer.join(".metarepo"), "{}").unwrap();
fs::write(inner.join(".metarepo"), "{}").unwrap();
let chain = MetaConfig::discover_chain_from(&inner).unwrap();
let paths: Vec<_> = chain.iter().map(|c| c.path.clone()).collect();
assert_eq!(
paths,
vec![outer.join(".metarepo"), inner.join(".metarepo")]
);
}
#[test]
fn merge_global_scripts_nearest_overrides() {
let outer: MetaConfig = serde_json::from_str(
r#"{"projects":{},"scripts":{"build":"outer-build","lint":"outer-lint"}}"#,
)
.unwrap();
let inner: MetaConfig =
serde_json::from_str(r#"{"projects":{},"scripts":{"build":"inner-build"}}"#).unwrap();
let merged = MetaConfig::merge_global_scripts(&[outer, inner]);
assert_eq!(merged.get("build").map(String::as_str), Some("inner-build"));
assert_eq!(merged.get("lint").map(String::as_str), Some("outer-lint"));
}
#[test]
fn merge_global_scripts_handles_missing_scripts() {
let outer: MetaConfig =
serde_json::from_str(r#"{"projects":{},"scripts":{"build":"outer-build"}}"#).unwrap();
let inner: MetaConfig = serde_json::from_str(r#"{"projects":{}}"#).unwrap();
let merged = MetaConfig::merge_global_scripts(&[outer, inner]);
assert_eq!(merged.get("build").map(String::as_str), Some("outer-build"));
assert_eq!(merged.len(), 1);
}
#[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 locate_in_finds_metarepo_config() {
let temp_dir = tempdir().unwrap();
let path = temp_dir.path().join(".metarepo");
MetaConfig::default().save_to_file(&path).unwrap();
let found = MetaConfig::locate_in(temp_dir.path()).unwrap();
assert_eq!(found.path, path);
assert_eq!(found.format, ConfigFormat::Json);
}
#[test]
fn locate_in_errors_when_no_config_present() {
let temp_dir = tempdir().unwrap();
let err = MetaConfig::locate_in(temp_dir.path()).err().unwrap();
assert!(err.to_string().contains("meta init"));
}
#[test]
fn config_in_dir_detects_each_supported_filename() {
for name in [".meta", ".metarepo", ".metarepo.yaml"] {
let temp_dir = tempdir().unwrap();
let body = if name.ends_with(".yaml") {
"{}\n"
} else {
"{}"
};
fs::write(temp_dir.path().join(name), body).unwrap();
let found = MetaConfig::config_in_dir(temp_dir.path());
assert!(found.is_some(), "should detect {name}");
assert_eq!(found.unwrap().path, temp_dir.path().join(name));
}
}
#[test]
fn config_in_dir_does_not_walk_up() {
let temp_dir = tempdir().unwrap();
let child = temp_dir.path().join("child");
fs::create_dir_all(&child).unwrap();
MetaConfig::default()
.save_to_file(temp_dir.path().join(".metarepo"))
.unwrap();
assert!(MetaConfig::config_in_dir(&child).is_none());
}
#[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,
scope_workspace: false,
settings_catalog: Vec::new(),
};
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,
scope_workspace: false,
settings_catalog: Vec::new(),
};
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,
scope_workspace: false,
settings_catalog: Vec::new(),
};
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 skill_block_roundtrips_and_is_optional() {
let tmp = tempdir().unwrap();
let bare = tmp.path().join(".metarepo");
MetaConfig::default()
.save_to_file_with_format(&bare, ConfigFormat::Json)
.unwrap();
assert!(MetaConfig::load_from_file(&bare).unwrap().skill.is_none());
assert!(!std::fs::read_to_string(&bare).unwrap().contains("skill"));
for (filename, format) in [
(".metarepo", ConfigFormat::Json),
(".metarepo.yaml", ConfigFormat::Yaml),
(".metarepo.toml", ConfigFormat::Toml),
] {
let path = tmp.path().join(filename);
let config = MetaConfig {
skill: Some(SkillSettings {
dest: Some("~/.config/agent-skills".into()),
adapt_command: Some("codex".into()),
adapt_args: Some(vec!["exec".into(), "{prompt}".into()]),
..Default::default()
}),
..Default::default()
};
config.save_to_file_with_format(&path, format).unwrap();
let loaded = MetaConfig::load_from_file(&path).unwrap();
assert_eq!(loaded.skill, config.skill, "{filename} lost [skill]");
}
}
#[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());
}
#[test]
fn project_metadata_depth_roundtrips_from_json() {
let json = r#"{
"projects": {
"shallow-project": {
"url": "https://example.com/shallow-project.git",
"depth": 1
}
}
}"#;
let config: MetaConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.get_project_depth("shallow-project"), Some(1));
match config.projects.get("shallow-project") {
Some(ProjectEntry::Metadata(metadata)) => assert_eq!(metadata.depth, Some(1)),
other => panic!("expected metadata entry, got {other:?}"),
}
}
#[test]
fn project_metadata_depth_none_is_omitted_from_serialized_json() {
let metadata = ProjectMetadata {
url: "https://example.com/full-project.git".to_string(),
aliases: Vec::new(),
scripts: HashMap::new(),
env: HashMap::new(),
worktree_init: None,
bare: None,
enabled: None,
depth: None,
};
let json = serde_json::to_string(&metadata).unwrap();
assert!(
!json.contains("depth"),
"expected `depth` to be omitted when None, got: {json}"
);
let mut config = MetaConfig::default();
config
.projects
.insert("full-project".to_string(), ProjectEntry::Metadata(metadata));
assert_eq!(config.get_project_depth("full-project"), None);
}
}