use crate::config::{self, ConfigEntity};
use crate::error::{Error, Result};
use crate::module;
use crate::output::{CreateOutput, MergeOutput, MergeResult, RemoveResult};
use crate::project;
use regex::Regex;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionTarget {
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
}
pub fn validate_version_target_conflict(
existing: &[VersionTarget],
new_file: &str,
new_pattern: &str,
component_id: &str,
) -> Result<()> {
for target in existing {
if target.file == new_file {
let existing_pattern = target.pattern.as_deref().unwrap_or("");
if existing_pattern != new_pattern {
return Err(Error::validation_invalid_argument(
"version_targets",
format!(
"File '{}' already has a version target with a different pattern. \
Existing: '{}', New: '{}'",
new_file, existing_pattern, new_pattern
),
None,
None,
)
.with_hint(format!(
"To replace existing targets: homeboy component set {} --replace version_targets --version-target \"{}::{}\"",
component_id, new_file, new_pattern
)));
}
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScopedModuleConfig {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub settings: HashMap<String, serde_json::Value>,
}
#[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 modules: Option<HashMap<String, ScopedModuleConfig>>,
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 build_command: Option<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>,
}
#[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")]
modules: Option<HashMap<String, ScopedModuleConfig>>,
#[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")]
build_command: Option<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>,
}
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,
modules: raw.modules,
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,
build_command: raw.build_command,
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,
}
}
}
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,
modules: c.modules,
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(),
build_command: c.build_command,
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,
}
}
}
#[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,
modules: None,
version_targets: None,
changelog_target: None,
changelog_next_section_label: None,
changelog_next_section_aliases: None,
hooks: HashMap::new(),
build_command: None,
extract_command: None,
remote_owner: None,
deploy_strategy: None,
git_deploy: None,
auto_cleanup: false,
docs_dir: None,
docs_dirs: Vec::new(),
}
}
}
pub fn read_portable_config(repo_path: &Path) -> Result<Option<Value>> {
let config_path = repo_path.join("homeboy.json");
if !config_path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&config_path).map_err(|e| {
Error::internal_io(
e.to_string(),
Some(format!("read {}", config_path.display())),
)
})?;
let value: Value = serde_json::from_str(&content).map_err(|e| {
Error::validation_invalid_json(
e,
Some("parse homeboy.json".to_string()),
Some(content.chars().take(200).collect::<String>()),
)
})?;
Ok(Some(value))
}
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()))
}
const MACHINE_SPECIFIC_FIELDS: &[&str] = &["id", "aliases", "local_path", "remote_path"];
fn overlay_portable(stored: &mut Value, portable: &Value) {
let (Some(stored_obj), Some(portable_obj)) = (stored.as_object_mut(), portable.as_object())
else {
return;
};
for (key, value) in portable_obj {
if MACHINE_SPECIFIC_FIELDS.contains(&key.as_str()) {
continue;
}
if !stored_obj.contains_key(key) {
stored_obj.insert(key.clone(), value.clone());
}
}
}
impl ConfigEntity for Component {
const ENTITY_TYPE: &'static str = "component";
const DIR_NAME: &'static str = "components";
fn id(&self) -> &str {
&self.id
}
fn set_id(&mut self, id: String) {
self.id = id;
}
fn not_found_error(id: String, suggestions: Vec<String>) -> Error {
Error::component_not_found(id, suggestions)
}
fn aliases(&self) -> &[String] {
&self.aliases
}
fn dependents(id: &str) -> Result<Vec<String>> {
projects_using(id)
}
fn on_rename(old_id: &str, new_id: &str) -> Result<()> {
update_project_references(old_id, new_id)
}
fn post_load(&mut self, stored_json: &str) {
let mut stored: Value = match serde_json::from_str(stored_json) {
Ok(v) => v,
Err(_) => return, };
let local_path = Path::new(&self.local_path);
let portable = match read_portable_config(local_path) {
Ok(Some(v)) => v,
_ => return, };
let id = self.id.clone();
let aliases = self.aliases.clone();
overlay_portable(&mut stored, &portable);
if let Ok(merged) = serde_json::from_value::<Component>(stored) {
*self = merged;
self.id = id;
self.aliases = aliases;
}
}
}
entity_crud!(Component; list_ids, slugify_id);
pub fn merge(id: Option<&str>, json_spec: &str, replace_fields: &[String]) -> Result<MergeOutput> {
let raw = config::read_json_spec_to_string(json_spec)?;
if config::is_json_array(&raw) {
return Ok(MergeOutput::Bulk(
config::merge_batch_from_json::<Component>(&raw)?,
));
}
Ok(MergeOutput::Single(merge_from_json(
id,
&raw,
replace_fields,
)?))
}
fn merge_from_json(
id: Option<&str>,
json_spec: &str,
replace_fields: &[String],
) -> Result<MergeResult> {
let raw = config::read_json_spec_to_string(json_spec)?;
let parsed: serde_json::Value = config::from_str(&raw)?;
if let Some(json_id) = parsed.get("id").and_then(|v| v.as_str()) {
if let Some(current_id) = id {
if json_id != current_id {
rename(current_id, json_id)?;
return config::merge_from_json::<Component>(
Some(json_id),
json_spec,
replace_fields,
);
}
}
}
config::merge_from_json::<Component>(id, json_spec, replace_fields)
}
pub fn validate_version_pattern(pattern: &str) -> Result<()> {
if pattern.contains("{version}") {
return Err(Error::validation_invalid_argument(
"version_target.pattern",
format!(
"Pattern '{}' uses template syntax ({{version}}), but a regex with a capture group is required. \
Example: 'Version: (\\d+\\.\\d+\\.\\d+)'",
pattern
),
Some(pattern.to_string()),
None,
));
}
let re = Regex::new(&crate::utils::parser::ensure_multiline(pattern)).map_err(|e| {
Error::validation_invalid_argument(
"version_target.pattern",
format!("Invalid regex pattern '{}': {}", pattern, e),
Some(pattern.to_string()),
None,
)
})?;
if re.captures_len() < 2 {
return Err(Error::validation_invalid_argument(
"version_target.pattern",
format!(
"Pattern '{}' has no capture group. Wrap the version portion in parentheses. \
Example: 'Version: (\\d+\\.\\d+\\.\\d+)'",
pattern
),
Some(pattern.to_string()),
None,
));
}
Ok(())
}
pub fn normalize_version_pattern(pattern: &str) -> String {
if pattern.contains("\\\\") {
pattern.replace("\\\\", "\\")
} else {
pattern.to_string()
}
}
pub fn parse_version_targets(targets: &[String]) -> Result<Vec<VersionTarget>> {
let mut parsed = Vec::new();
for target in targets {
let mut parts = target.splitn(2, "::");
let file = parts
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
Error::validation_invalid_argument(
"version_target",
"Invalid version target format (expected 'file' or 'file::pattern')",
None,
None,
)
})?;
let pattern = parts.next().map(str::trim).filter(|s| !s.is_empty());
if let Some(p) = pattern {
let normalized = normalize_version_pattern(p);
validate_version_pattern(&normalized)?;
parsed.push(VersionTarget {
file: file.to_string(),
pattern: Some(normalized),
});
} else {
parsed.push(VersionTarget {
file: file.to_string(),
pattern: None,
});
}
}
Ok(parsed)
}
pub fn set_changelog_target(component_id: &str, file_path: &str) -> Result<()> {
let mut component = load(component_id)?;
component.changelog_target = Some(file_path.to_string());
save(&component)
}
fn update_project_references(old_id: &str, new_id: &str) -> Result<()> {
let projects = project::list().unwrap_or_default();
for proj in projects {
if proj.component_ids.contains(&old_id.to_string()) {
let updated_ids: Vec<String> = proj
.component_ids
.iter()
.map(|comp_id: &String| {
if comp_id == old_id {
new_id.to_string()
} else {
comp_id.clone()
}
})
.collect();
project::set_components(&proj.id, updated_ids)?;
}
}
Ok(())
}
pub fn projects_using(component_id: &str) -> Result<Vec<String>> {
let projects = project::list().unwrap_or_default();
Ok(projects
.iter()
.filter(|p| p.component_ids.contains(&component_id.to_string()))
.map(|p| p.id.clone())
.collect())
}
pub fn shared_components() -> Result<std::collections::HashMap<String, Vec<String>>> {
let projects = project::list().unwrap_or_default();
let mut sharing: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for project in projects {
for component_id in &project.component_ids {
sharing
.entry(component_id.clone())
.or_default()
.push(project.id.clone());
}
}
Ok(sharing)
}
pub fn resolve_artifact(component: &Component) -> Option<String> {
if let Some(ref artifact) = component.build_artifact {
return Some(artifact.clone());
}
if let Some(ref modules) = component.modules {
for module_id in modules.keys() {
if let Ok(manifest) = module::load_module(module_id) {
if let Some(ref build) = manifest.build {
if let Some(ref pattern) = build.artifact_pattern {
let resolved = pattern
.replace("{component_id}", &component.id)
.replace("{local_path}", &component.local_path);
return Some(resolved);
}
}
}
}
}
None
}
pub fn module_provides_artifact_pattern(component: &Component) -> bool {
component
.modules
.as_ref()
.map(|modules| {
modules.keys().any(|module_id| {
module::load_module(module_id)
.ok()
.and_then(|m| m.build)
.and_then(|b| b.artifact_pattern)
.is_some()
})
})
.unwrap_or(false)
}
pub fn validate_local_path(component: &Component) -> Result<PathBuf> {
let expanded = shellexpand::tilde(&component.local_path);
let path = PathBuf::from(expanded.as_ref());
if !path.is_absolute() {
return Err(Error::validation_invalid_argument(
"local_path",
format!(
"Component '{}' has relative local_path '{}' which cannot be resolved. \
Use absolute path like /Users/chubes/path/to/component",
component.id, component.local_path
),
Some(component.id.clone()),
None,
)
.with_hint(format!(
"Set absolute path: homeboy component set {} --local-path \"/full/path/to/{}\"",
component.id, component.local_path
))
.with_hint("Use 'pwd' in the component directory to get the absolute path".to_string()));
}
if !path.exists() {
return Err(Error::validation_invalid_argument(
"local_path",
format!(
"Component '{}' local_path does not exist: {}",
component.id,
path.display()
),
Some(component.id.clone()),
None,
)
.with_hint(format!("Verify the path exists: ls -la {}", path.display()))
.with_hint(format!(
"Update path: homeboy component set {} --local-path \"/correct/path\"",
component.id
)));
}
Ok(path)
}
pub fn detect_from_cwd() -> Option<String> {
let cwd = std::env::current_dir().ok()?;
let components = list().ok()?;
for component in components {
let expanded = shellexpand::tilde(&component.local_path);
let local_path = Path::new(expanded.as_ref());
if cwd.starts_with(local_path) {
return Some(component.id);
}
}
None
}
pub fn discover_from_portable(dir: &Path) -> Option<Component> {
let portable = read_portable_config(dir).ok()??;
let dir_name = dir.file_name()?.to_string_lossy();
let id = crate::utils::slugify::slugify_id(&dir_name, "component_id").ok()?;
let local_path = dir.to_string_lossy().to_string();
let mut json = portable;
if let Some(obj) = json.as_object_mut() {
obj.insert("id".to_string(), Value::String(id));
obj.insert("local_path".to_string(), Value::String(local_path));
obj.entry("remote_path".to_string())
.or_insert(Value::String(String::new()));
}
serde_json::from_value::<Component>(json).ok()
}
fn detect_git_root(dir: &Path) -> Option<PathBuf> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(dir)
.output()
.ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
None
}
pub fn resolve(id: Option<&str>) -> Result<Component> {
if let Some(id) = id {
return load(id);
}
if let Some(detected_id) = detect_from_cwd() {
return load(&detected_id);
}
let cwd = std::env::current_dir()
.map_err(|e| Error::internal_io(e.to_string(), None))?;
if let Some(component) = discover_from_portable(&cwd) {
return Ok(component);
}
if let Some(git_root) = detect_git_root(&cwd) {
if git_root != cwd {
if let Some(component) = discover_from_portable(&git_root) {
return Ok(component);
}
}
}
let mut hints = vec![
"Provide a component ID: homeboy <command> <component-id>".to_string(),
"Or run from a directory containing homeboy.json".to_string(),
];
if detect_from_cwd().is_none() {
hints.push(
"Register a component: homeboy component create <id> --local-path .".to_string(),
);
}
Err(Error::validation_invalid_argument(
"component_id",
"No component ID provided and no homeboy.json found in current directory",
None,
Some(hints),
))
}
#[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_err());
let err = result.unwrap_err();
let details_str = err.details.to_string();
assert!(
details_str.contains("already has a version target with a different pattern"),
"Expected error details to contain 'already has a version target with a different pattern', got: {}",
details_str
);
}
#[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 overlay_portable_fills_absent_fields() {
let mut stored: Value = serde_json::json!({
"id": "my-plugin",
"local_path": "/home/user/my-plugin",
"remote_path": "/var/www/my-plugin"
});
let portable = serde_json::json!({
"build_command": "npm run build",
"changelog_target": "docs/CHANGELOG.md",
"version_targets": [{"file": "package.json"}]
});
overlay_portable(&mut stored, &portable);
assert_eq!(stored["build_command"], "npm run build");
assert_eq!(stored["changelog_target"], "docs/CHANGELOG.md");
assert!(stored["version_targets"].is_array());
}
#[test]
fn overlay_portable_stored_wins() {
let mut stored: Value = serde_json::json!({
"id": "my-plugin",
"local_path": "/home/user/my-plugin",
"build_command": "make build"
});
let portable = serde_json::json!({
"build_command": "npm run build",
"changelog_target": "docs/CHANGELOG.md"
});
overlay_portable(&mut stored, &portable);
assert_eq!(stored["build_command"], "make build");
assert_eq!(stored["changelog_target"], "docs/CHANGELOG.md");
}
#[test]
fn overlay_portable_skips_machine_specific_fields() {
let mut stored: Value = serde_json::json!({
"id": "my-plugin",
"local_path": "/home/user/my-plugin",
"remote_path": "/var/www/my-plugin"
});
let portable = serde_json::json!({
"id": "wrong-id",
"local_path": "/someone-else/path",
"remote_path": "/other/remote",
"aliases": ["alias1"],
"build_command": "npm run build"
});
overlay_portable(&mut stored, &portable);
assert_eq!(stored["id"], "my-plugin");
assert_eq!(stored["local_path"], "/home/user/my-plugin");
assert_eq!(stored["remote_path"], "/var/www/my-plugin");
assert!(stored.get("aliases").is_none());
assert_eq!(stored["build_command"], "npm run build");
}
#[test]
fn overlay_portable_handles_non_objects() {
let mut stored = serde_json::json!("not an object");
let portable = serde_json::json!({"build_command": "make"});
overlay_portable(&mut stored, &portable);
assert_eq!(stored, "not an object");
}
#[test]
fn overlay_portable_empty_portable_is_noop() {
let mut stored: Value = serde_json::json!({
"id": "my-plugin",
"local_path": "/home/user/my-plugin",
"build_command": "make"
});
let original = stored.clone();
let portable = serde_json::json!({});
overlay_portable(&mut stored, &portable);
assert_eq!(stored, original);
}
#[test]
fn discover_from_portable_creates_component_from_homeboy_json() {
let dir = std::env::temp_dir().join("homeboy_test_discover");
let _ = std::fs::create_dir_all(&dir);
let config = serde_json::json!({
"build_command": "cargo build --release",
"version_targets": [{"file": "Cargo.toml", "pattern": "(?m)^version\\s*=\\s*\"([0-9.]+)\""}],
"changelog_target": "docs/CHANGELOG.md"
});
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, "homeboy-test-discover");
assert_eq!(comp.local_path, dir.to_string_lossy());
assert_eq!(comp.build_command.as_deref(), Some("cargo build --release"));
assert_eq!(
comp.changelog_target.as_deref(),
Some("docs/CHANGELOG.md")
);
assert!(comp.version_targets.is_some());
assert!(comp.remote_path.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_from_portable_returns_none_without_homeboy_json() {
let dir = std::env::temp_dir().join("homeboy_test_no_config");
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::remove_file(dir.join("homeboy.json"));
let result = discover_from_portable(&dir);
assert!(result.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_from_portable_ignores_machine_specific_in_portable() {
let dir = std::env::temp_dir().join("homeboy_test_machine_fields");
let _ = std::fs::create_dir_all(&dir);
let config = serde_json::json!({
"id": "should-be-overridden",
"local_path": "/wrong/path",
"remote_path": "/also/wrong",
"build_command": "make"
});
std::fs::write(dir.join("homeboy.json"), config.to_string()).unwrap();
let comp = discover_from_portable(&dir).unwrap();
assert_eq!(comp.id, "homeboy-test-machine-fields");
assert_eq!(comp.local_path, dir.to_string_lossy());
assert_eq!(comp.remote_path, "/also/wrong");
assert_eq!(comp.build_command.as_deref(), Some("make"));
let _ = std::fs::remove_dir_all(&dir);
}
}