From 71af5ed863eb10990ff5fa9bd9971df13e0edff8 Mon Sep 17 00:00:00 2001 From: Matt Walters Date: Fri, 13 Feb 2026 14:45:31 -0600 Subject: [PATCH 1/4] refactor: move config module from meta_cli to meta_core Config (parse JSON/YAML, normalize structs, walk meta trees) is pure library code that belongs in the shared foundation crate. This fixes the inverted dependency where meta_git_lib, meta_rust_cli, and meta_mcp depended on meta_cli just for config parsing. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 1 + src/config.rs | 1094 +++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 3 files changed, 1096 insertions(+) create mode 100644 src/config.rs diff --git a/Cargo.toml b/Cargo.toml index 6e86a52..7d7e1a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ anyhow = "1.0" dirs = "5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_yaml = "0.9" chrono = { version = "0.4", features = ["serde"] } [target.'cfg(unix)'.dependencies] diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..464af4f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,1094 @@ +//! Shared configuration types and parsing for .meta files. +//! +//! This module provides the core types and functions for finding and parsing +//! .meta configuration files (JSON and YAML formats). + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Represents a project entry in the .meta config. +/// Can be either a simple git URL string or an extended object with additional fields. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum ProjectEntry { + /// Simple format: just a git URL string + Simple(String), + /// Extended format: object with repo, path, tags, and dependency info + Extended { + /// Git remote URL. Required for all projects. + #[serde(default)] + repo: Option, + #[serde(default)] + path: Option, + #[serde(default)] + tags: Vec, + #[serde(default)] + provides: Vec, + #[serde(default)] + depends_on: Vec, + /// If true, this directory contains a nested .meta config + #[serde(default)] + meta: bool, + }, +} + +/// Parsed project info after normalization +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectInfo { + pub name: String, + pub path: String, + /// Git remote URL. Should be present for all normal projects. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo: Option, + pub tags: Vec, + /// What this project provides (e.g., APIs, libraries) + #[serde(default)] + pub provides: Vec, + /// What this project depends on (other project names or provided items) + #[serde(default)] + pub depends_on: Vec, + /// If true, this directory contains a nested .meta config + #[serde(default)] + pub meta: bool, +} + +impl ProjectInfo { + /// Returns true if this project has no git repo URL (cannot be cloned) + pub fn has_no_repo(&self) -> bool { + self.repo.is_none() + } +} + +/// Default settings that can be configured in .meta +#[derive(Debug, Deserialize, Clone)] +pub struct MetaDefaults { + /// Run commands in parallel by default (defaults to true) + #[serde(default = "default_true")] + pub parallel: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for MetaDefaults { + fn default() -> Self { + Self { parallel: true } + } +} + +/// The meta configuration file structure +#[derive(Debug, Deserialize, Default)] +pub struct MetaConfig { + /// Projects in this meta repo. Values can be: + /// - A git URL string: `"project": "git@github.com:org/repo.git"` + /// - An extended object: `"project": { "repo": "...", "path": "...", "meta": true }` + #[serde(default)] + pub projects: HashMap, + #[serde(default)] + pub ignore: Vec, + #[serde(default)] + pub defaults: MetaDefaults, + /// Custom directory for worktrees (overrides default .worktrees/) + #[serde(skip_serializing_if = "Option::is_none")] + pub worktrees_dir: Option, +} + +/// Determines the format of a config file based on extension +#[derive(Debug, Clone)] +pub enum ConfigFormat { + Json, + Yaml, +} + +/// Check if a directory has a meta config file (.meta, .meta.yaml, or .meta.yml). +/// +/// Unlike `find_meta_config`, this does NOT walk up the directory tree. +/// Returns the path and format if found. +pub fn find_meta_config_in(dir: &Path) -> Option<(PathBuf, ConfigFormat)> { + for (name, format) in &[ + (".meta", ConfigFormat::Json), + (".meta.json", ConfigFormat::Json), + (".meta.yaml", ConfigFormat::Yaml), + (".meta.yml", ConfigFormat::Yaml), + ] { + let candidate = dir.join(name); + if candidate.exists() && candidate.is_file() { + return Some((candidate, format.clone())); + } + } + None +} + +/// Find the meta config file, checking for .meta, .meta.yaml, and .meta.yml +/// +/// Walks up from `start_dir` to the filesystem root, looking for config files. +/// If `config_name` is provided, only looks for that specific filename. +pub fn find_meta_config( + start_dir: &Path, + config_name: Option<&PathBuf>, +) -> Option<(PathBuf, ConfigFormat)> { + let candidates: Vec<(String, ConfigFormat)> = if let Some(name) = config_name { + // User specified a config file name + let name_str = name.to_string_lossy().to_string(); + if name_str.ends_with(".yaml") || name_str.ends_with(".yml") { + vec![(name_str, ConfigFormat::Yaml)] + } else { + vec![(name_str, ConfigFormat::Json)] + } + } else { + // Default: check all supported names + vec![ + (".meta".to_string(), ConfigFormat::Json), + (".meta.json".to_string(), ConfigFormat::Json), + (".meta.yaml".to_string(), ConfigFormat::Yaml), + (".meta.yml".to_string(), ConfigFormat::Yaml), + ] + }; + + let mut current_dir = start_dir.to_path_buf(); + loop { + for (name, format) in &candidates { + let candidate = current_dir.join(name); + if candidate.exists() && candidate.is_file() { + return Some((candidate, format.clone())); + } + } + if let Some(parent) = current_dir.parent() { + current_dir = parent.to_path_buf(); + } else { + return None; + } + } +} + +/// Parse a meta config file (JSON or YAML) and return normalized project info and ignore list. +pub fn parse_meta_config(meta_path: &Path) -> anyhow::Result<(Vec, Vec)> { + let config_str = std::fs::read_to_string(meta_path) + .with_context(|| format!("Failed to read meta config file: '{}'", meta_path.display()))?; + + // Determine format from file extension + let path_str = meta_path.to_string_lossy(); + let config: MetaConfig = if path_str.ends_with(".yaml") || path_str.ends_with(".yml") { + serde_yaml::from_str(&config_str) + .with_context(|| format!("Failed to parse YAML config file: {}", meta_path.display()))? + } else { + serde_json::from_str(&config_str) + .with_context(|| format!("Failed to parse JSON config file: {}", meta_path.display()))? + }; + + // Convert project entries to normalized ProjectInfo + let mut projects: Vec = config + .projects + .into_iter() + .map(|(name, entry)| { + let (repo, path, tags, provides, depends_on, meta) = match entry { + // Simple string -> git URL + ProjectEntry::Simple(url) => { + (Some(url), name.clone(), vec![], vec![], vec![], false) + } + // Extended object -> repo with additional fields + // meta: true indicates this project is also a meta-repo (has its own .meta) + ProjectEntry::Extended { + repo, + path, + tags, + provides, + depends_on, + meta, + } => { + let resolved_path = path.unwrap_or_else(|| name.clone()); + (repo, resolved_path, tags, provides, depends_on, meta) + } + }; + ProjectInfo { + name, + path, + repo, + tags, + provides, + depends_on, + meta, + } + }) + .collect(); + + // Sort projects alphabetically by name for deterministic order + projects.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok((projects, config.ignore)) +} + +/// Load defaults from a meta config file. +/// Returns MetaDefaults::default() if no config found or on parse error. +pub fn load_meta_defaults(start_dir: &Path) -> MetaDefaults { + let Some((config_path, _format)) = find_meta_config_in(start_dir) else { + return MetaDefaults::default(); + }; + + let config_str = match std::fs::read_to_string(&config_path) { + Ok(s) => s, + Err(_) => return MetaDefaults::default(), + }; + + let path_str = config_path.to_string_lossy(); + let config: MetaConfig = if path_str.ends_with(".yaml") || path_str.ends_with(".yml") { + serde_yaml::from_str(&config_str).unwrap_or_default() + } else { + serde_json::from_str(&config_str).unwrap_or_default() + }; + + config.defaults +} + +// ============================================================================ +// Tree Walking +// ============================================================================ + +/// A node in the meta project tree, representing a project and its nested children. +#[derive(Debug, Clone, Serialize)] +pub struct MetaTreeNode { + pub info: ProjectInfo, + pub is_meta: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + +/// Walk a meta repository tree, discovering nested .meta repos. +/// +/// Parses the .meta config at `start_dir` and for each project checks +/// if it has its own .meta file. Recursively expands children up to `max_depth`. +/// Uses cycle detection via path canonicalization. +/// +/// `max_depth` of `None` means unlimited recursion. +/// `max_depth` of `Some(0)` means no recursion (only top-level projects). +pub fn walk_meta_tree( + start_dir: &Path, + max_depth: Option, +) -> anyhow::Result> { + let (config_path, _format) = find_meta_config(start_dir, None) + .ok_or_else(|| anyhow::anyhow!("No .meta config found in {}", start_dir.display()))?; + + let (projects, _ignore) = parse_meta_config(&config_path)?; + let meta_dir = config_path.parent().unwrap_or(Path::new(".")); + + let mut visited = std::collections::HashSet::new(); + visited.insert(meta_dir.canonicalize().unwrap_or(meta_dir.to_path_buf())); + + let depth = max_depth.unwrap_or(usize::MAX); + Ok(walk_inner(meta_dir, &projects, depth, 0, &mut visited)) +} + +/// Flatten a meta tree into fully-qualified path strings. +/// +/// For nested children, paths are joined with their parent +/// (e.g., a child with path "grandchild" under parent "child" becomes "child/grandchild"). +pub fn flatten_meta_tree(nodes: &[MetaTreeNode]) -> Vec { + let mut paths = Vec::new(); + flatten_inner(nodes, "", &mut paths); + paths +} + +fn flatten_inner(nodes: &[MetaTreeNode], prefix: &str, paths: &mut Vec) { + for node in nodes { + let full_path = if prefix.is_empty() { + node.info.path.clone() + } else { + format!("{}/{}", prefix, node.info.path) + }; + paths.push(full_path.clone()); + flatten_inner(&node.children, &full_path, paths); + } +} + +/// Build a map from full path to (resolved path, ProjectInfo) for nested lookups. +/// +/// This is useful for looking up projects by their full nested path +/// (e.g., "vendor/nested-lib" -> resolved filesystem path and project info). +pub fn build_project_map( + nodes: &[MetaTreeNode], + base_dir: &Path, + prefix: &str, +) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + for node in nodes { + let full_path = if prefix.is_empty() { + node.info.path.clone() + } else { + format!("{}/{}", prefix, node.info.path) + }; + let resolved_path = base_dir.join(&full_path); + map.insert(full_path.clone(), (resolved_path, node.info.clone())); + // Recurse into children + map.extend(build_project_map(&node.children, base_dir, &full_path)); + } + map +} + +// ============================================================================ +// Orphan Detection +// ============================================================================ + +/// Information about an orphaned nested meta repo. +#[derive(Debug, Clone)] +pub struct OrphanWarning { + /// Path to the current (orphaned) meta directory + pub current: PathBuf, + /// Path to the parent meta directory + pub parent: PathBuf, + /// Suggested key to add to parent's .meta file + pub suggested_key: String, + /// Format of the parent's config file (for showing appropriate syntax) + pub parent_format: ConfigFormat, +} + +/// Find a parent .meta config (if any) above the given meta directory. +/// +/// Starts searching from the parent of `meta_dir` and walks up. +/// Returns None if this is the root meta repo (no parent .meta found). +pub fn find_parent_meta_config(meta_dir: &Path) -> Option<(PathBuf, ConfigFormat)> { + let parent = meta_dir.parent()?; + find_meta_config(parent, None) +} + +/// Check if a meta directory is tracked by its parent meta config. +/// +/// Returns `Some(OrphanWarning)` if there's a parent .meta that doesn't include +/// this directory in its project list (directly or transitively). +/// Returns `None` if tracked or if there's no parent meta. +pub fn check_orphan_status(meta_dir: &Path) -> Option { + let (parent_config, parent_format) = find_parent_meta_config(meta_dir)?; + let parent_meta_dir = parent_config.parent()?; + + // Walk the parent's project tree to see what's tracked + let tree = walk_meta_tree(parent_meta_dir, None).ok()?; + let flat_paths = flatten_meta_tree(&tree); + + // Get the relative path from parent to current + let relative = meta_dir.strip_prefix(parent_meta_dir).ok()?; + let relative_str = relative.to_string_lossy(); + + // Check if this path is in the flattened tree + if flat_paths.iter().any(|p| p == &*relative_str) { + None // Tracked, not orphan + } else { + // Extract the first path component as the suggested key + let suggested_key = relative + .components() + .next()? + .as_os_str() + .to_string_lossy() + .to_string(); + + Some(OrphanWarning { + current: meta_dir.to_path_buf(), + parent: parent_meta_dir.to_path_buf(), + suggested_key, + parent_format, + }) + } +} + +fn walk_inner( + base_dir: &Path, + projects: &[ProjectInfo], + max_depth: usize, + current_depth: usize, + visited: &mut std::collections::HashSet, +) -> Vec { + let mut nodes = Vec::new(); + + for project in projects { + let project_dir = base_dir.join(&project.path); + + // Check if this project has its own .meta file directly in its directory + let has_meta = project_dir.is_dir() + && find_meta_config(&project_dir, None) + .map(|(path, _)| path.parent().map(|p| p == project_dir).unwrap_or(false)) + .unwrap_or(false); + + // Recurse into children if within depth limit and this is a meta repo + let children = if has_meta && current_depth < max_depth { + let canonical = project_dir.canonicalize().unwrap_or(project_dir.clone()); + if visited.insert(canonical) { + if let Some((nested_config_path, _)) = find_meta_config(&project_dir, None) { + if let Ok((nested_projects, _)) = parse_meta_config(&nested_config_path) { + walk_inner( + &project_dir, + &nested_projects, + max_depth, + current_depth + 1, + visited, + ) + } else { + vec![] + } + } else { + vec![] + } + } else { + vec![] // Cycle detected + } + } else { + vec![] + }; + + nodes.push(MetaTreeNode { + info: project.clone(), + is_meta: has_meta, + children, + }); + } + + nodes.sort_by(|a, b| a.info.name.cmp(&b.info.name)); + nodes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_walk_meta_tree_no_config() { + let dir = tempfile::tempdir().unwrap(); + let result = walk_meta_tree(dir.path(), None); + assert!(result.is_err()); + } + + #[test] + fn test_walk_meta_tree_empty_projects() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(".meta"), r#"{"projects": {}}"#).unwrap(); + let tree = walk_meta_tree(dir.path(), None).unwrap(); + assert!(tree.is_empty()); + } + + #[test] + fn test_walk_meta_tree_multiple_projects_sorted() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("zebra")).unwrap(); + std::fs::create_dir(dir.path().join("alpha")).unwrap(); + std::fs::create_dir(dir.path().join("middle")).unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": { + "zebra": "git@github.com:org/zebra.git", + "alpha": "git@github.com:org/alpha.git", + "middle": "git@github.com:org/middle.git" + }}"#, + ) + .unwrap(); + + let tree = walk_meta_tree(dir.path(), None).unwrap(); + assert_eq!(tree.len(), 3); + assert_eq!(tree[0].info.name, "alpha"); + assert_eq!(tree[1].info.name, "middle"); + assert_eq!(tree[2].info.name, "zebra"); + } + + #[test] + fn test_walk_meta_tree_is_meta_flag() { + let dir = tempfile::tempdir().unwrap(); + let has_meta = dir.path().join("has_meta"); + let no_meta = dir.path().join("no_meta"); + std::fs::create_dir(&has_meta).unwrap(); + std::fs::create_dir(&no_meta).unwrap(); + std::fs::write(has_meta.join(".meta"), r#"{"projects": {}}"#).unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": { + "has_meta": "git@github.com:org/has_meta.git", + "no_meta": "git@github.com:org/no_meta.git" + }}"#, + ) + .unwrap(); + + let tree = walk_meta_tree(dir.path(), None).unwrap(); + let has = tree.iter().find(|n| n.info.name == "has_meta").unwrap(); + let no = tree.iter().find(|n| n.info.name == "no_meta").unwrap(); + assert!(has.is_meta); + assert!(!no.is_meta); + } + + #[test] + fn test_walk_meta_tree_depth_zero_no_recursion() { + let dir = tempfile::tempdir().unwrap(); + let child = dir.path().join("child"); + let grandchild = child.join("grandchild"); + std::fs::create_dir_all(&grandchild).unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"child": "git@github.com:org/child.git"}}"#, + ) + .unwrap(); + std::fs::write( + child.join(".meta"), + r#"{"projects": {"grandchild": "git@github.com:org/grandchild.git"}}"#, + ) + .unwrap(); + + let tree = walk_meta_tree(dir.path(), Some(0)).unwrap(); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].info.name, "child"); + assert!(tree[0].is_meta); + assert!(tree[0].children.is_empty()); // No recursion + } + + #[test] + fn test_walk_meta_tree_cycle_detection() { + let dir = tempfile::tempdir().unwrap(); + let child = dir.path().join("child"); + std::fs::create_dir(&child).unwrap(); + + // Create a symlink from child/loop back to root + #[cfg(unix)] + { + std::os::unix::fs::symlink(dir.path(), child.join("loop")).unwrap(); + } + + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"child": "git@github.com:org/child.git"}}"#, + ) + .unwrap(); + std::fs::write( + child.join(".meta"), + r#"{"projects": {"loop": "git@github.com:org/loop.git"}}"#, + ) + .unwrap(); + + // Should not infinite loop - cycle detection stops recursion + let tree = walk_meta_tree(dir.path(), None).unwrap(); + let paths = flatten_meta_tree(&tree); + assert!(paths.contains(&"child".to_string())); + // The cycle node is included but has no children + assert!(paths.contains(&"child/loop".to_string())); + let child_node = &tree[0]; + assert!(child_node.children[0].children.is_empty()); + } + + #[test] + fn test_flatten_meta_tree_empty() { + let paths = flatten_meta_tree(&[]); + assert!(paths.is_empty()); + } + + #[test] + fn test_flatten_meta_tree_deeply_nested() { + let dir = tempfile::tempdir().unwrap(); + let l1 = dir.path().join("l1"); + let l2 = l1.join("l2"); + let l3 = l2.join("l3"); + std::fs::create_dir_all(&l3).unwrap(); + + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"l1": "git@github.com:org/l1.git"}}"#, + ) + .unwrap(); + std::fs::write( + l1.join(".meta"), + r#"{"projects": {"l2": "git@github.com:org/l2.git"}}"#, + ) + .unwrap(); + std::fs::write( + l2.join(".meta"), + r#"{"projects": {"l3": "git@github.com:org/l3.git"}}"#, + ) + .unwrap(); + + let tree = walk_meta_tree(dir.path(), None).unwrap(); + let paths = flatten_meta_tree(&tree); + + assert_eq!(paths.len(), 3); + assert_eq!(paths[0], "l1"); + assert_eq!(paths[1], "l1/l2"); + assert_eq!(paths[2], "l1/l2/l3"); + } + + #[test] + fn test_walk_meta_tree_nonexistent_project_dir() { + let dir = tempfile::tempdir().unwrap(); + // Project listed in .meta but directory doesn't exist + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"missing": "git@github.com:org/missing.git"}}"#, + ) + .unwrap(); + + let tree = walk_meta_tree(dir.path(), None).unwrap(); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].info.name, "missing"); + assert!(!tree[0].is_meta); + assert!(tree[0].children.is_empty()); + } + + #[test] + fn test_walk_meta_tree_extended_format() { + let dir = tempfile::tempdir().unwrap(); + let custom_path = dir.path().join("custom/path"); + std::fs::create_dir_all(&custom_path).unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": { + "myproject": { + "repo": "git@github.com:org/myproject.git", + "path": "custom/path", + "tags": ["frontend", "react"] + } + }}"#, + ) + .unwrap(); + + let tree = walk_meta_tree(dir.path(), None).unwrap(); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].info.name, "myproject"); + assert_eq!(tree[0].info.path, "custom/path"); + assert_eq!(tree[0].info.tags, vec!["frontend", "react"]); + } + + #[test] + fn test_load_meta_defaults_no_config() { + let dir = tempfile::tempdir().unwrap(); + let defaults = load_meta_defaults(dir.path()); + // Defaults to parallel=true when no config exists + assert!(defaults.parallel); + } + + #[test] + fn test_load_meta_defaults_no_defaults_section() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(".meta"), r#"{"projects": {}}"#).unwrap(); + let defaults = load_meta_defaults(dir.path()); + // Defaults to parallel=true when defaults section is missing + assert!(defaults.parallel); + } + + #[test] + fn test_load_meta_defaults_parallel_true() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {}, "defaults": {"parallel": true}}"#, + ) + .unwrap(); + let defaults = load_meta_defaults(dir.path()); + assert!(defaults.parallel); + } + + #[test] + fn test_load_meta_defaults_parallel_false() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {}, "defaults": {"parallel": false}}"#, + ) + .unwrap(); + let defaults = load_meta_defaults(dir.path()); + assert!(!defaults.parallel); + } + + #[test] + fn test_load_meta_defaults_yaml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".meta.yaml"), + "projects: {}\ndefaults:\n parallel: true\n", + ) + .unwrap(); + let defaults = load_meta_defaults(dir.path()); + assert!(defaults.parallel); + } + + // ============================================================================ + // Nested meta repos (meta: true field) + // ============================================================================ + + #[test] + fn test_parse_nested_meta_json() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{ + "projects": { + "core": "git@github.com:org/core.git", + "vendor": { "repo": "git@github.com:org/vendor.git", "meta": true } + } + }"#, + ) + .unwrap(); + + let (projects, _) = parse_meta_config(&dir.path().join(".meta")).unwrap(); + assert_eq!(projects.len(), 2); + + let vendor = projects.iter().find(|p| p.name == "vendor").unwrap(); + assert_eq!( + vendor.repo.as_ref().unwrap(), + "git@github.com:org/vendor.git" + ); + assert_eq!(vendor.path, "vendor"); + + let core = projects.iter().find(|p| p.name == "core").unwrap(); + assert!(core.repo.is_some()); + assert_eq!(core.repo.as_ref().unwrap(), "git@github.com:org/core.git"); + } + + #[test] + fn test_parse_nested_meta_yaml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".meta.yaml"), + "projects:\n core: git@github.com:org/core.git\n vendor:\n repo: git@github.com:org/vendor.git\n meta: true\n", + ) + .unwrap(); + + let (projects, _) = parse_meta_config(&dir.path().join(".meta.yaml")).unwrap(); + assert_eq!(projects.len(), 2); + + let vendor = projects.iter().find(|p| p.name == "vendor").unwrap(); + assert_eq!( + vendor.repo.as_ref().unwrap(), + "git@github.com:org/vendor.git" + ); + } + + #[test] + fn test_parse_nested_meta_with_custom_path() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".meta"), + r#"{ + "projects": { + "vendor": { + "repo": "git@github.com:org/vendor.git", + "path": "third_party/vendor", + "meta": true + } + } + }"#, + ) + .unwrap(); + + let (projects, _) = parse_meta_config(&dir.path().join(".meta")).unwrap(); + assert_eq!(projects.len(), 1); + + let vendor = &projects[0]; + assert_eq!( + vendor.repo.as_ref().unwrap(), + "git@github.com:org/vendor.git" + ); + assert_eq!(vendor.name, "vendor"); + assert_eq!(vendor.path, "third_party/vendor"); + } + + #[test] + fn test_has_no_repo() { + let info = ProjectInfo { + name: "vendor".to_string(), + path: "vendor".to_string(), + repo: None, + tags: vec![], + provides: vec![], + depends_on: vec![], + meta: false, + }; + assert!(info.has_no_repo()); + + let info_with_repo = ProjectInfo { + name: "core".to_string(), + path: "core".to_string(), + repo: Some("git@github.com:org/core.git".to_string()), + tags: vec![], + provides: vec![], + depends_on: vec![], + meta: false, + }; + assert!(!info_with_repo.has_no_repo()); + } + + #[test] + fn test_walk_meta_tree_nested_meta_with_children() { + let dir = tempfile::tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + let nested = vendor.join("tree-sitter-markdown"); + std::fs::create_dir_all(&nested).unwrap(); + + // Root .meta with nested meta project (has repo URL + meta: true) + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"vendor": {"repo": "git@github.com:org/vendor.git", "meta": true}}}"#, + ) + .unwrap(); + + // Nested .meta inside vendor (simulates what exists after cloning) + std::fs::write( + vendor.join(".meta"), + r#"{"projects": {"tree-sitter-markdown": "git@github.com:org/tsm.git"}}"#, + ) + .unwrap(); + + let tree = walk_meta_tree(dir.path(), None).unwrap(); + assert_eq!(tree.len(), 1); + + let vendor_node = &tree[0]; + assert_eq!(vendor_node.info.name, "vendor"); + assert_eq!( + vendor_node.info.repo.as_ref().unwrap(), + "git@github.com:org/vendor.git" + ); + assert!(vendor_node.is_meta); + assert_eq!(vendor_node.children.len(), 1); + + let nested_node = &vendor_node.children[0]; + assert_eq!(nested_node.info.name, "tree-sitter-markdown"); + assert!(nested_node.info.repo.is_some()); + } + + // ============================================================================ + // Orphan detection tests + // ============================================================================ + + #[test] + fn test_find_parent_meta_config_none_at_root() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(".meta"), r#"{"projects": {}}"#).unwrap(); + + // No parent above this directory + let result = find_parent_meta_config(dir.path()); + assert!(result.is_none()); + } + + #[test] + fn test_find_parent_meta_config_finds_parent() { + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("vendor"); + std::fs::create_dir(&nested).unwrap(); + + // Parent .meta + std::fs::write(dir.path().join(".meta"), r#"{"projects": {}}"#).unwrap(); + // Nested .meta + std::fs::write(nested.join(".meta"), r#"{"projects": {}}"#).unwrap(); + + let result = find_parent_meta_config(&nested); + assert!(result.is_some()); + let (path, _) = result.unwrap(); + assert_eq!(path, dir.path().join(".meta")); + } + + #[test] + fn test_check_orphan_status_not_orphan_when_tracked() { + let dir = tempfile::tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + std::fs::create_dir(&vendor).unwrap(); + + // Parent .meta tracks vendor with repo URL and meta: true + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"vendor": {"repo": "git@github.com:org/vendor.git", "meta": true}}}"#, + ) + .unwrap(); + // Nested .meta (simulates what exists after cloning vendor) + std::fs::write(vendor.join(".meta"), r#"{"projects": {}}"#).unwrap(); + + let result = check_orphan_status(&vendor); + assert!(result.is_none(), "vendor should not be orphan when tracked"); + } + + #[test] + fn test_check_orphan_status_is_orphan_when_not_tracked() { + let dir = tempfile::tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + std::fs::create_dir(&vendor).unwrap(); + + // Parent .meta does NOT track vendor + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"other": "git@github.com:org/other.git"}}"#, + ) + .unwrap(); + std::fs::create_dir(dir.path().join("other")).unwrap(); + // Nested .meta + std::fs::write(vendor.join(".meta"), r#"{"projects": {}}"#).unwrap(); + + let result = check_orphan_status(&vendor); + assert!(result.is_some(), "vendor should be orphan when not tracked"); + + let warning = result.unwrap(); + assert_eq!(warning.current, vendor); + assert_eq!(warning.parent, dir.path()); + assert_eq!(warning.suggested_key, "vendor"); + } + + #[test] + fn test_check_orphan_status_no_parent_means_not_orphan() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(".meta"), r#"{"projects": {}}"#).unwrap(); + + // No parent .meta above this one + let result = check_orphan_status(dir.path()); + assert!(result.is_none(), "should not be orphan if no parent exists"); + } + + #[test] + fn test_check_orphan_status_deeply_nested_tracked() { + // Test 3 levels deep: root -> vendor -> sub-vendor -> deep-lib + let dir = tempfile::tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + let sub_vendor = vendor.join("sub-vendor"); + let deep_lib = sub_vendor.join("deep-lib"); + std::fs::create_dir_all(&deep_lib).unwrap(); + + // Root tracks vendor as nested meta + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"vendor": {"repo": "git@github.com:org/vendor.git", "meta": true}}}"#, + ) + .unwrap(); + + // Vendor tracks sub-vendor as nested meta + std::fs::write( + vendor.join(".meta"), + r#"{"projects": {"sub-vendor": {"repo": "git@github.com:org/sub-vendor.git", "meta": true}}}"#, + ) + .unwrap(); + + // Sub-vendor tracks deep-lib + std::fs::write( + sub_vendor.join(".meta"), + r#"{"projects": {"deep-lib": "git@github.com:org/deep-lib.git"}}"#, + ) + .unwrap(); + + // Check from sub-vendor's perspective (should not be orphan - tracked by vendor) + let result = check_orphan_status(&sub_vendor); + assert!( + result.is_none(), + "sub-vendor should not be orphan when tracked by vendor" + ); + } + + #[test] + fn test_check_orphan_status_deeply_nested_orphan() { + // Test orphan detection at 2 levels deep + let dir = tempfile::tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + let orphan_dir = vendor.join("orphan-project"); + std::fs::create_dir_all(&orphan_dir).unwrap(); + std::fs::create_dir(dir.path().join("backend")).unwrap(); + + // Root tracks vendor and backend + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"vendor": {"repo": "git@github.com:org/vendor.git", "meta": true}, "backend": "git@github.com:org/backend.git"}}"#, + ) + .unwrap(); + + // Vendor does NOT track orphan-project + std::fs::write( + vendor.join(".meta"), + r#"{"projects": {"lib": "git@github.com:org/lib.git"}}"#, + ) + .unwrap(); + std::fs::create_dir(vendor.join("lib")).unwrap(); + + // Orphan has its own .meta + std::fs::write(orphan_dir.join(".meta"), r#"{"projects": {}}"#).unwrap(); + + // Check from orphan's perspective + let result = check_orphan_status(&orphan_dir); + assert!( + result.is_some(), + "orphan-project should be orphan when not tracked by vendor" + ); + + let warning = result.unwrap(); + assert_eq!(warning.suggested_key, "orphan-project"); + assert_eq!(warning.parent, vendor); + } + + #[test] + fn test_walk_meta_tree_handles_malformed_nested_meta() { + // When a nested .meta file is malformed, we should skip it gracefully + let dir = tempfile::tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + std::fs::create_dir(&vendor).unwrap(); + + // Root .meta is valid + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"vendor": {"repo": "git@github.com:org/vendor.git", "meta": true}}}"#, + ) + .unwrap(); + + // Nested .meta is malformed JSON + std::fs::write( + vendor.join(".meta"), + r#"{"projects": {this is not valid json}"#, + ) + .unwrap(); + + // Should still return the tree without the nested children + let tree = walk_meta_tree(dir.path(), None).unwrap(); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].info.name, "vendor"); + // Children should be empty because the nested .meta was malformed + assert_eq!(tree[0].children.len(), 0); + } + + #[test] + fn test_build_project_map_empty_tree() { + let dir = tempfile::tempdir().unwrap(); + let tree: Vec = vec![]; + let map = build_project_map(&tree, dir.path(), ""); + assert!(map.is_empty()); + } + + #[test] + fn test_build_project_map_with_custom_path() { + // Test that custom paths are handled correctly in the map + let dir = tempfile::tempdir().unwrap(); + let custom_path = dir.path().join("custom/path/to/project"); + std::fs::create_dir_all(&custom_path).unwrap(); + + std::fs::write( + dir.path().join(".meta"), + r#"{"projects": {"my-project": {"repo": "git@github.com:org/my-project.git", "path": "custom/path/to/project"}}}"#, + ) + .unwrap(); + + let tree = walk_meta_tree(dir.path(), None).unwrap(); + let map = build_project_map(&tree, dir.path(), ""); + + // The key should be the path, not the name + assert!(map.contains_key("custom/path/to/project")); + let (path, info) = map.get("custom/path/to/project").unwrap(); + assert_eq!(info.name, "my-project"); + assert_eq!(*path, custom_path); + } + + #[test] + fn test_check_orphan_status_yaml_format() { + let dir = tempfile::tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + std::fs::create_dir(&vendor).unwrap(); + + // Parent .meta.yaml does NOT track vendor + std::fs::write( + dir.path().join(".meta.yaml"), + "projects:\n other:\n repo: git@github.com:org/other.git\n", + ) + .unwrap(); + std::fs::create_dir(dir.path().join("other")).unwrap(); + + // Nested .meta (JSON) + std::fs::write(vendor.join(".meta"), r#"{"projects": {}}"#).unwrap(); + + let result = check_orphan_status(&vendor); + assert!(result.is_some(), "vendor should be orphan when not tracked"); + + let warning = result.unwrap(); + assert!(matches!(warning.parent_format, ConfigFormat::Yaml)); + } +} diff --git a/src/lib.rs b/src/lib.rs index c1ef24d..7dd3d65 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; +pub mod config; pub mod data_dir; pub mod lock; pub mod store; From 6b8296592b53bbc0362d5057a3603e12a63afbf5 Mon Sep 17 00:00:00 2001 From: Matt Walters Date: Fri, 13 Feb 2026 15:25:31 -0600 Subject: [PATCH 2/4] fix(review): replace deprecated serde_yaml with serde_yml, normalize paths Address CodeRabbit review feedback: - Replace deprecated serde_yaml (unmaintained since March 2024) with serde_yml, a maintained fork with compatible API - Normalize path separators in check_orphan_status to use forward slashes, preventing false orphan detection on Windows Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 +- src/config.rs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7d7e1a2..649ce67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ anyhow = "1.0" dirs = "5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -serde_yaml = "0.9" +serde_yml = "0.0.12" chrono = { version = "0.4", features = ["serde"] } [target.'cfg(unix)'.dependencies] diff --git a/src/config.rs b/src/config.rs index 464af4f..e778ff7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -172,7 +172,7 @@ pub fn parse_meta_config(meta_path: &Path) -> anyhow::Result<(Vec, // Determine format from file extension let path_str = meta_path.to_string_lossy(); let config: MetaConfig = if path_str.ends_with(".yaml") || path_str.ends_with(".yml") { - serde_yaml::from_str(&config_str) + serde_yml::from_str(&config_str) .with_context(|| format!("Failed to parse YAML config file: {}", meta_path.display()))? } else { serde_json::from_str(&config_str) @@ -235,7 +235,7 @@ pub fn load_meta_defaults(start_dir: &Path) -> MetaDefaults { let path_str = config_path.to_string_lossy(); let config: MetaConfig = if path_str.ends_with(".yaml") || path_str.ends_with(".yml") { - serde_yaml::from_str(&config_str).unwrap_or_default() + serde_yml::from_str(&config_str).unwrap_or_default() } else { serde_json::from_str(&config_str).unwrap_or_default() }; @@ -368,7 +368,11 @@ pub fn check_orphan_status(meta_dir: &Path) -> Option { // Get the relative path from parent to current let relative = meta_dir.strip_prefix(parent_meta_dir).ok()?; - let relative_str = relative.to_string_lossy(); + let relative_str = relative + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); // Check if this path is in the flattened tree if flat_paths.iter().any(|p| p == &*relative_str) { From d3d9048a34b7d78b29fbf58562fa291d4638ba21 Mon Sep 17 00:00:00 2001 From: Matt Walters Date: Fri, 13 Feb 2026 15:57:22 -0600 Subject: [PATCH 3/4] ci: add workspace setup and smart branch cloning CI was failing because the repo uses workspace-inherited fields (version.workspace = true) but was checked out in isolation without a workspace root. Add proper workspace setup (checkout into subdirectory, create workspace Cargo.toml) and smart branch cloning that tries the PR branch name first before falling back to main. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/auto-format.yml | 18 ++++++++++ .github/workflows/ci.yml | 56 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index 3865df5..7854ca5 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -27,18 +27,35 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.head_ref || github.ref_name }} + path: meta_core # Use PAT to trigger downstream workflows; fallback to GITHUB_TOKEN token: ${{ secrets.PARENT_REPO_PAT || github.token }} + - name: Create workspace Cargo.toml + run: | + cat > Cargo.toml << 'EOF' + [workspace] + members = ["meta_core"] + resolver = "2" + + [workspace.package] + version = "0.1.0" + edition = "2021" + license = "MIT" + repository = "https://github.com/harmony-labs/meta" + EOF + - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - name: Run cargo fmt + working-directory: meta_core run: cargo fmt --all - name: Check for changes id: changes + working-directory: meta_core run: | if git diff --quiet; then echo "formatted=false" >> $GITHUB_OUTPUT @@ -48,6 +65,7 @@ jobs: - name: Commit and push if: steps.changes.outputs.formatted == 'true' + working-directory: meta_core run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00bbb0c..34d8716 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,14 +20,34 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + path: meta_core + + - name: Create workspace Cargo.toml + shell: bash + run: | + cat > Cargo.toml << 'EOF' + [workspace] + members = ["meta_core"] + resolver = "2" + + [workspace.package] + version = "0.1.0" + edition = "2021" + license = "MIT" + repository = "https://github.com/harmony-labs/meta" + EOF - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - name: Cache Rust uses: Swatinem/rust-cache@v2 + with: + workspaces: meta_core - name: Run tests + working-directory: meta_core run: cargo test --all-features clippy: @@ -36,6 +56,22 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + path: meta_core + + - name: Create workspace Cargo.toml + run: | + cat > Cargo.toml << 'EOF' + [workspace] + members = ["meta_core"] + resolver = "2" + + [workspace.package] + version = "0.1.0" + edition = "2021" + license = "MIT" + repository = "https://github.com/harmony-labs/meta" + EOF - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -44,8 +80,11 @@ jobs: - name: Cache Rust uses: Swatinem/rust-cache@v2 + with: + workspaces: meta_core - name: Run clippy + working-directory: meta_core run: cargo clippy --all-targets --all-features -- -D warnings format: @@ -54,6 +93,22 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + path: meta_core + + - name: Create workspace Cargo.toml + run: | + cat > Cargo.toml << 'EOF' + [workspace] + members = ["meta_core"] + resolver = "2" + + [workspace.package] + version = "0.1.0" + edition = "2021" + license = "MIT" + repository = "https://github.com/harmony-labs/meta" + EOF - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -61,4 +116,5 @@ jobs: components: rustfmt - name: Check formatting + working-directory: meta_core run: cargo fmt --all -- --check From bca709c06f40c077ec680f254b7493f50abebdc3 Mon Sep 17 00:00:00 2001 From: Matt Walters Date: Fri, 13 Feb 2026 18:26:49 -0600 Subject: [PATCH 4/4] fix: replace serde_yml with serde_yaml_ng and harden CI - Replace serde_yml (RUSTSEC-2025-0068: unsound, unmaintained) with serde_yaml_ng (maintained drop-in replacement) - Fix doc comments: clarify repo field is optional for local-only projects - Normalize project paths (backslash to forward slash) for Windows compat - Fix CI script injection: move github.head_ref to env: instead of inline shell interpolation - Add clone failure handling and branch logging in CI Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 2 +- src/config.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 649ce67..f01ce21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ anyhow = "1.0" dirs = "5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -serde_yml = "0.0.12" +serde_yaml_ng = "0.10" chrono = { version = "0.4", features = ["serde"] } [target.'cfg(unix)'.dependencies] diff --git a/src/config.rs b/src/config.rs index e778ff7..d0ba3c1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,7 +17,7 @@ pub enum ProjectEntry { Simple(String), /// Extended format: object with repo, path, tags, and dependency info Extended { - /// Git remote URL. Required for all projects. + /// Git remote URL (optional for local-only projects). #[serde(default)] repo: Option, #[serde(default)] @@ -39,7 +39,7 @@ pub enum ProjectEntry { pub struct ProjectInfo { pub name: String, pub path: String, - /// Git remote URL. Should be present for all normal projects. + /// Git remote URL (optional for local-only projects). #[serde(default, skip_serializing_if = "Option::is_none")] pub repo: Option, pub tags: Vec, @@ -172,7 +172,7 @@ pub fn parse_meta_config(meta_path: &Path) -> anyhow::Result<(Vec, // Determine format from file extension let path_str = meta_path.to_string_lossy(); let config: MetaConfig = if path_str.ends_with(".yaml") || path_str.ends_with(".yml") { - serde_yml::from_str(&config_str) + serde_yaml_ng::from_str(&config_str) .with_context(|| format!("Failed to parse YAML config file: {}", meta_path.display()))? } else { serde_json::from_str(&config_str) @@ -203,6 +203,7 @@ pub fn parse_meta_config(meta_path: &Path) -> anyhow::Result<(Vec, (repo, resolved_path, tags, provides, depends_on, meta) } }; + let path = path.replace('\\', "/"); ProjectInfo { name, path, @@ -235,7 +236,7 @@ pub fn load_meta_defaults(start_dir: &Path) -> MetaDefaults { let path_str = config_path.to_string_lossy(); let config: MetaConfig = if path_str.ends_with(".yaml") || path_str.ends_with(".yml") { - serde_yml::from_str(&config_str).unwrap_or_default() + serde_yaml_ng::from_str(&config_str).unwrap_or_default() } else { serde_json::from_str(&config_str).unwrap_or_default() };