use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
pub mod inventory;
pub mod mutations;
pub mod portable;
pub mod relationships;
pub mod resolution;
pub mod versioning;
pub use inventory::{exists, extension_provides_artifact_pattern, inventory, list, list_ids, load};
pub use mutations::{delete_safe, merge, rename, set_changelog_target};
pub use portable::{
discover_from_portable, has_portable_config, infer_portable_component_id, mutate_portable,
portable_json, read_portable_config, write_portable_config,
};
pub use relationships::{associated_projects, projects_using, rename_component, shared_components};
pub use resolution::{
detect_from_cwd, resolve, resolve_artifact, resolve_effective, validate_local_path,
};
pub use versioning::{
normalize_version_pattern, parse_version_targets, validate_version_pattern,
validate_version_target_conflict,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionTarget {
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScopedExtensionConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub settings: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommandScopeConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScopeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defaults: Option<CommandScopeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit: Option<CommandScopeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lint: Option<CommandScopeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test: Option<CommandScopeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refactor: Option<CommandScopeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deploy: Option<CommandScopeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release: Option<CommandScopeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fleet: Option<CommandScopeConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(from = "RawComponent", into = "RawComponent")]
pub struct Component {
pub id: String,
pub aliases: Vec<String>,
pub local_path: String,
pub remote_path: String,
pub build_artifact: Option<String>,
pub extensions: Option<HashMap<String, ScopedExtensionConfig>>,
pub version_targets: Option<Vec<VersionTarget>>,
pub changelog_target: Option<String>,
pub changelog_next_section_label: Option<String>,
pub changelog_next_section_aliases: Option<Vec<String>>,
pub hooks: HashMap<String, Vec<String>>,
pub extract_command: Option<String>,
pub remote_owner: Option<String>,
pub deploy_strategy: Option<String>,
pub git_deploy: Option<GitDeployConfig>,
pub auto_cleanup: bool,
pub docs_dir: Option<String>,
pub docs_dirs: Vec<String>,
pub scopes: Option<ScopeConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct RawComponent {
#[serde(default, skip_serializing)]
id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
aliases: Vec<String>,
#[serde(default)]
local_path: String,
#[serde(default)]
remote_path: String,
#[serde(
skip_serializing_if = "Option::is_none",
default,
deserialize_with = "deserialize_empty_as_none"
)]
build_artifact: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
extensions: Option<HashMap<String, ScopedExtensionConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
version_targets: Option<Vec<VersionTarget>>,
#[serde(skip_serializing_if = "Option::is_none", alias = "changelog_targets")]
changelog_target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
changelog_next_section_label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
changelog_next_section_aliases: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
hooks: HashMap<String, Vec<String>>,
#[serde(default, skip_serializing)]
pre_version_bump_commands: Vec<String>,
#[serde(default, skip_serializing)]
post_version_bump_commands: Vec<String>,
#[serde(default, skip_serializing)]
post_release_commands: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
extract_command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
remote_owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
deploy_strategy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
git_deploy: Option<GitDeployConfig>,
#[serde(default)]
auto_cleanup: bool,
#[serde(skip_serializing_if = "Option::is_none")]
docs_dir: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
docs_dirs: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
scopes: Option<ScopeConfig>,
}
fn merge_legacy_hook(hooks: &mut HashMap<String, Vec<String>>, event: &str, commands: Vec<String>) {
if !commands.is_empty() && !hooks.contains_key(event) {
hooks.insert(event.to_string(), commands);
}
}
impl From<RawComponent> for Component {
fn from(raw: RawComponent) -> Self {
let mut hooks = raw.hooks;
merge_legacy_hook(
&mut hooks,
"pre:version:bump",
raw.pre_version_bump_commands,
);
merge_legacy_hook(
&mut hooks,
"post:version:bump",
raw.post_version_bump_commands,
);
merge_legacy_hook(&mut hooks, "post:release", raw.post_release_commands);
Component {
id: raw.id,
aliases: raw.aliases,
local_path: raw.local_path,
remote_path: raw.remote_path,
build_artifact: raw.build_artifact,
extensions: raw.extensions,
version_targets: raw.version_targets,
changelog_target: raw.changelog_target,
changelog_next_section_label: raw.changelog_next_section_label,
changelog_next_section_aliases: raw.changelog_next_section_aliases,
hooks,
extract_command: raw.extract_command,
remote_owner: raw.remote_owner,
deploy_strategy: raw.deploy_strategy,
git_deploy: raw.git_deploy,
auto_cleanup: raw.auto_cleanup,
docs_dir: raw.docs_dir,
docs_dirs: raw.docs_dirs,
scopes: raw.scopes,
}
}
}
impl From<Component> for RawComponent {
fn from(c: Component) -> Self {
RawComponent {
id: c.id,
aliases: c.aliases,
local_path: c.local_path,
remote_path: c.remote_path,
build_artifact: c.build_artifact,
extensions: c.extensions,
version_targets: c.version_targets,
changelog_target: c.changelog_target,
changelog_next_section_label: c.changelog_next_section_label,
changelog_next_section_aliases: c.changelog_next_section_aliases,
hooks: c.hooks,
pre_version_bump_commands: Vec::new(),
post_version_bump_commands: Vec::new(),
post_release_commands: Vec::new(),
extract_command: c.extract_command,
remote_owner: c.remote_owner,
deploy_strategy: c.deploy_strategy,
git_deploy: c.git_deploy,
auto_cleanup: c.auto_cleanup,
docs_dir: c.docs_dir,
docs_dirs: c.docs_dirs,
scopes: c.scopes,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitDeployConfig {
#[serde(
default = "default_git_remote",
skip_serializing_if = "is_default_remote"
)]
pub remote: String,
#[serde(
default = "default_git_branch",
skip_serializing_if = "is_default_branch"
)]
pub branch: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub post_pull: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag_pattern: Option<String>,
}
fn default_git_remote() -> String {
"origin".to_string()
}
fn default_git_branch() -> String {
"main".to_string()
}
fn is_default_remote(s: &str) -> bool {
s == "origin"
}
fn is_default_branch(s: &str) -> bool {
s == "main"
}
impl Component {
pub fn new(
id: String,
local_path: String,
remote_path: String,
build_artifact: Option<String>,
) -> Self {
Self {
id,
aliases: Vec::new(),
local_path,
remote_path,
build_artifact,
extensions: None,
version_targets: None,
changelog_target: None,
changelog_next_section_label: None,
changelog_next_section_aliases: None,
hooks: HashMap::new(),
extract_command: None,
remote_owner: None,
deploy_strategy: None,
git_deploy: None,
auto_cleanup: false,
docs_dir: None,
docs_dirs: Vec::new(),
scopes: None,
}
}
}
fn deserialize_empty_as_none<'de, D>(
deserializer: D,
) -> std::result::Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<String>::deserialize(deserializer)?;
Ok(opt.filter(|s| !s.is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_version_target_conflict_different_pattern_errors() {
let existing = vec![VersionTarget {
file: "plugin.php".to_string(),
pattern: Some("Version: (.*)".to_string()),
}];
let result = validate_version_target_conflict(
&existing,
"plugin.php",
"define('VER', '(.*)')",
"test-comp",
);
assert!(result.is_ok());
}
#[test]
fn validate_version_target_conflict_same_pattern_ok() {
let existing = vec![VersionTarget {
file: "plugin.php".to_string(),
pattern: Some("Version: (.*)".to_string()),
}];
let result =
validate_version_target_conflict(&existing, "plugin.php", "Version: (.*)", "test-comp");
assert!(result.is_ok());
}
#[test]
fn validate_version_target_conflict_different_file_ok() {
let existing = vec![VersionTarget {
file: "plugin.php".to_string(),
pattern: Some("Version: (.*)".to_string()),
}];
let result = validate_version_target_conflict(
&existing,
"package.json",
"\"version\": \"(.*)\"",
"test-comp",
);
assert!(result.is_ok());
}
#[test]
fn validate_version_target_conflict_empty_existing_ok() {
let existing: Vec<VersionTarget> = vec![];
let result =
validate_version_target_conflict(&existing, "plugin.php", "Version: (.*)", "test-comp");
assert!(result.is_ok());
}
#[test]
fn validate_version_pattern_rejects_template_syntax() {
let result = validate_version_pattern("Version: {version}");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.details.to_string().contains("template syntax"));
}
#[test]
fn validate_version_pattern_rejects_no_capture_group() {
let result = validate_version_pattern(r"Version: \d+\.\d+\.\d+");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.details.to_string().contains("no capture group"));
}
#[test]
fn validate_version_pattern_rejects_invalid_regex() {
let result = validate_version_pattern(r"Version: (\d+\.\d+");
assert!(result.is_err());
}
#[test]
fn validate_version_pattern_accepts_valid_pattern() {
assert!(validate_version_pattern(r"Version:\s*(\d+\.\d+\.\d+)").is_ok());
}
#[test]
fn parse_version_targets_rejects_template_syntax() {
let targets = vec!["style.css::Version: {version}".to_string()];
let result = parse_version_targets(&targets);
assert!(result.is_err());
}
#[test]
fn normalize_version_pattern_converts_double_escaped() {
let double_escaped = r"Version:\\s*(\\d+\\.\\d+\\.\\d+)";
let normalized = normalize_version_pattern(double_escaped);
assert_eq!(normalized, r"Version:\s*(\d+\.\d+\.\d+)");
let correct = r"Version:\s*(\d+\.\d+\.\d+)";
let normalized2 = normalize_version_pattern(correct);
assert_eq!(normalized2, r"Version:\s*(\d+\.\d+\.\d+)");
}
#[test]
fn parse_version_targets_normalizes_double_escaped_patterns() {
let targets = vec!["plugin.php::Version:\\s*(\\d+\\.\\d+\\.\\d+)".to_string()];
let result = parse_version_targets(&targets).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].file, "plugin.php");
assert_eq!(
result[0].pattern.as_ref().unwrap(),
r"Version:\s*(\d+\.\d+\.\d+)"
);
}
#[test]
fn discover_from_portable_creates_component_from_homeboy_json() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
let config = serde_json::json!({
"id": "test-discover",
"version_targets": [{"file": "Cargo.toml", "pattern": "(?m)^version\\s*=\\s*\"([0-9.]+)\""}],
"changelog_target": "docs/CHANGELOG.md",
"extensions": {"rust": {}}
});
std::fs::write(dir.join("homeboy.json"), config.to_string()).unwrap();
let result = discover_from_portable(&dir);
assert!(
result.is_some(),
"Should discover component from homeboy.json"
);
let comp = result.unwrap();
assert_eq!(comp.id, "test-discover");
assert_eq!(comp.local_path, dir.to_string_lossy());
assert_eq!(comp.changelog_target.as_deref(), Some("docs/CHANGELOG.md"));
assert!(comp
.extensions
.as_ref()
.is_some_and(|m| m.contains_key("rust")));
assert!(comp.version_targets.is_some());
assert!(comp.remote_path.is_empty()); }
#[test]
fn discover_from_portable_returns_none_without_homeboy_json() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
let result = discover_from_portable(&dir);
assert!(result.is_none());
}
#[test]
fn discover_from_portable_ignores_machine_specific_in_portable() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
let config = serde_json::json!({
"id": "test-machine-fields",
"local_path": "/wrong/path",
"remote_path": "/also/wrong",
"extract_command": "tar -xf artifact.tar.gz"
});
std::fs::write(dir.join("homeboy.json"), config.to_string()).unwrap();
let comp = discover_from_portable(&dir).unwrap();
assert_eq!(comp.id, "test-machine-fields");
assert_eq!(comp.local_path, dir.to_string_lossy());
assert_eq!(comp.remote_path, "/also/wrong");
assert_eq!(
comp.extract_command.as_deref(),
Some("tar -xf artifact.tar.gz")
);
}
#[test]
fn discover_from_portable_with_baselines_and_extensions() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
let config = serde_json::json!({
"auto_cleanup": false,
"baselines": {
"lint": {
"context_id": "data-machine",
"created_at": "2026-03-06T04:47:29Z",
"item_count": 0,
"known_fingerprints": [],
"metadata": {
"findings_count": 0
}
}
},
"changelog_target": "docs/CHANGELOG.md",
"extensions": {
"wordpress": {}
},
"id": "data-machine",
"version_targets": [
{"file": "data-machine.php", "pattern": "(?m)^\\s*\\*?\\s*Version:\\s*([0-9.]+)"}
]
});
std::fs::write(dir.join("homeboy.json"), config.to_string()).unwrap();
let result = discover_from_portable(&dir);
assert!(
result.is_some(),
"Should discover component even with baselines field in homeboy.json"
);
let comp = result.unwrap();
assert_eq!(comp.id, "data-machine");
assert_eq!(comp.local_path, dir.to_string_lossy());
assert!(
comp.extensions.is_some(),
"extensions should be set from portable config"
);
assert!(
comp.extensions.as_ref().unwrap().contains_key("wordpress"),
"wordpress extension should be present"
);
assert_eq!(comp.changelog_target.as_deref(), Some("docs/CHANGELOG.md"));
assert!(comp.version_targets.is_some());
}
}