use serde::Serialize;
use std::path::PathBuf;
use crate::component::{self, Component};
use crate::config::{is_json_input, parse_bulk_ids};
use crate::error::{Error, Result};
use crate::module;
use crate::output::{BulkResult, BulkSummary, ItemOutcome};
use crate::paths;
use crate::permissions;
use crate::ssh::execute_local_command_in_dir;
use crate::utils::command::CapturedOutput;
use crate::utils::shell;
#[derive(Debug, Clone)]
pub enum ResolvedBuildCommand {
ComponentDefined(String),
ModuleProvided {
command: String,
source: String,
},
LocalScript {
command: String,
script_name: String,
},
}
impl ResolvedBuildCommand {
pub fn command(&self) -> &str {
match self {
ResolvedBuildCommand::ComponentDefined(cmd) => cmd,
ResolvedBuildCommand::ModuleProvided { command, .. } => command,
ResolvedBuildCommand::LocalScript { command, .. } => command,
}
}
}
pub fn resolve_build_command(component: &Component) -> Result<ResolvedBuildCommand> {
if let Some(cmd) = &component.build_command {
return Ok(ResolvedBuildCommand::ComponentDefined(cmd.clone()));
}
if let Some(modules) = &component.modules {
for module_id in modules.keys() {
if let Ok(module) = module::load_module(module_id) {
if let Some(build) = &module.build {
let bundled = build.module_script.as_ref().and_then(|module_script| {
paths::module(module_id).ok().and_then(|module_dir| {
let script_path = module_dir.join(module_script);
script_path.exists().then(|| {
let quoted_path =
shell::quote_path(&script_path.to_string_lossy());
let command = build
.command_template
.as_ref()
.map(|t| t.replace("{{script}}", "ed_path))
.unwrap_or_else(|| format!("sh {}", quoted_path));
ResolvedBuildCommand::ModuleProvided {
command,
source: format!("{}:{}", module_id, module_script),
}
})
})
});
if let Some(result) = bundled {
return Ok(result);
}
let local_path = PathBuf::from(&component.local_path);
for script_name in &build.script_names {
let local_script = local_path.join(script_name);
if local_script.exists() {
let command = build
.command_template
.as_ref()
.map(|t| t.replace("{{script}}", script_name))
.unwrap_or_else(|| format!("sh {}", script_name));
return Ok(ResolvedBuildCommand::LocalScript {
command,
script_name: script_name.clone(),
});
}
}
}
}
}
}
if module::module_provides_build(component) {
Err(Error::other(format!(
"Component '{}' links a module with build support, but no build script was found.\n\
Expected: module's bundled script OR local script matching module pattern.\n\
Check module installation or add a local build.sh to the component directory.",
component.id
)))
} else {
Err(Error::other(format!(
"Component '{}' has no build configuration. Either:\n\
- Configure buildCommand: homeboy component set {} --json '{{\"buildCommand\": \"<command>\"}}'\n\
- Link a module with build support: homeboy component set {} --json '{{\"modules\": {{\"wordpress\": {{}}}}}}'",
component.id, component.id, component.id
)))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct BuildOutput {
pub command: String,
pub component_id: String,
pub build_command: String,
#[serde(flatten)]
pub output: CapturedOutput,
pub success: bool,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum BuildResult {
Single(BuildOutput),
Bulk(BulkResult<BuildOutput>),
}
pub fn run(input: &str) -> Result<(BuildResult, i32)> {
if is_json_input(input) {
run_bulk(input)
} else {
run_single(input)
}
}
pub fn build_component(component: &component::Component) -> (Option<i32>, Option<String>) {
let validated_path = match component::validate_local_path(component) {
Ok(p) => p,
Err(e) => return (Some(1), Some(format_path_validation_error(component, &e))),
};
let resolved = match resolve_build_command(component) {
Ok(r) => r,
Err(e) => return (Some(1), Some(e.to_string())),
};
let build_cmd = resolved.command().to_string();
let local_path_str = validated_path.to_string_lossy().to_string();
permissions::fix_local_permissions(&local_path_str);
let env_vars = get_build_env_vars(component);
let env_refs: Vec<(&str, &str)> = env_vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let output = execute_local_command_in_dir(
&build_cmd,
Some(&local_path_str),
if env_refs.is_empty() { None } else { Some(&env_refs) },
);
if output.success {
(Some(output.exit_code), None)
} else {
(
Some(output.exit_code),
Some(format_build_error(&component.id, &build_cmd, &local_path_str, output.exit_code, &output.stderr, &output.stdout)),
)
}
}
fn format_path_validation_error(component: &component::Component, error: &Error) -> String {
format!(
"Build failed for component '{}':\n {}\n\nHint: Update local_path with:\n homeboy component set {} --local-path \"/path/to/component\"",
component.id,
error.message,
component.id
)
}
fn format_build_error(
component_id: &str,
build_cmd: &str,
working_dir: &str,
exit_code: i32,
stderr: &str,
stdout: &str,
) -> String {
let output_text = if stderr.trim().is_empty() { stdout } else { stderr };
let tail: Vec<&str> = output_text.lines().rev().take(15).collect();
let output_tail: String = tail.into_iter().rev().collect::<Vec<_>>().join("\n");
let hint = match exit_code {
127 => "\nHint: Command not found. Check that the build command and its dependencies are installed and in PATH.",
126 => "\nHint: Permission denied. Check file permissions on the build script.",
_ => "",
};
let mut msg = format!(
"Build failed for '{}' (exit code {}).\n Command: {}\n Working directory: {}",
component_id, exit_code, build_cmd, working_dir
);
if !output_tail.is_empty() {
msg.push_str("\n\n--- Build output (last 15 lines) ---\n");
msg.push_str(&output_tail);
msg.push_str("\n--- End of output ---");
}
if !hint.is_empty() {
msg.push_str(hint);
}
msg
}
fn run_single(component_id: &str) -> Result<(BuildResult, i32)> {
let (output, exit_code) = execute_build(component_id)?;
Ok((BuildResult::Single(output), exit_code))
}
fn run_bulk(json_spec: &str) -> Result<(BuildResult, i32)> {
let input = parse_bulk_ids(json_spec)?;
let mut results = Vec::with_capacity(input.component_ids.len());
let mut succeeded = 0usize;
let mut failed = 0usize;
for id in &input.component_ids {
match execute_build(id) {
Ok((output, _)) => {
if output.success {
succeeded += 1;
} else {
failed += 1;
}
results.push(ItemOutcome {
id: id.clone(),
result: Some(output),
error: None,
});
}
Err(e) => {
failed += 1;
results.push(ItemOutcome {
id: id.clone(),
result: None,
error: Some(e.to_string()),
});
}
}
}
let exit_code = if failed > 0 { 1 } else { 0 };
Ok((
BuildResult::Bulk(BulkResult {
action: "build".to_string(),
results,
summary: BulkSummary {
total: succeeded + failed,
succeeded,
failed,
},
}),
exit_code,
))
}
fn execute_build(component_id: &str) -> Result<(BuildOutput, i32)> {
let comp = component::load(component_id)?;
let validated_path = component::validate_local_path(&comp)?;
let local_path_str = validated_path.to_string_lossy().to_string();
let resolved = resolve_build_command(&comp)?;
let build_cmd = resolved.command().to_string();
if let Some((exit_code, stderr)) = run_pre_build_scripts(&comp)? {
if exit_code != 0 {
return Ok((
BuildOutput {
command: "build.run".to_string(),
component_id: component_id.to_string(),
build_command: build_cmd,
output: CapturedOutput::new(String::new(), stderr),
success: false,
},
exit_code,
));
}
}
permissions::fix_local_permissions(&local_path_str);
let env_vars = get_build_env_vars(&comp);
let env_refs: Vec<(&str, &str)> = env_vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let cmd_output = execute_local_command_in_dir(
&build_cmd,
Some(&local_path_str),
if env_refs.is_empty() { None } else { Some(&env_refs) },
);
Ok((
BuildOutput {
command: "build.run".to_string(),
component_id: component_id.to_string(),
build_command: build_cmd,
output: CapturedOutput::new(cmd_output.stdout, cmd_output.stderr),
success: cmd_output.success,
},
cmd_output.exit_code,
))
}
fn run_pre_build_scripts(comp: &Component) -> Result<Option<(i32, String)>> {
let modules = match &comp.modules {
Some(m) => m,
None => return Ok(None),
};
for module_id in modules.keys() {
let module = match module::load_module(module_id) {
Ok(m) => m,
Err(_) => continue,
};
let build_config = match &module.build {
Some(b) => b,
None => continue,
};
let pre_build_script = match &build_config.pre_build_script {
Some(s) => s,
None => continue,
};
let module_path = paths::module(module_id)?;
let script_path = module_path.join(pre_build_script);
if !script_path.exists() {
continue;
}
let env: [(&str, &str); 3] = [
("HOMEBOY_MODULE_PATH", &module_path.to_string_lossy()),
("HOMEBOY_COMPONENT_PATH", &comp.local_path),
("HOMEBOY_PLUGIN_PATH", &comp.local_path),
];
let output = execute_local_command_in_dir(
&script_path.to_string_lossy(),
None,
Some(&env),
);
if !output.success {
let combined = if output.stderr.is_empty() {
output.stdout
} else {
output.stderr
};
return Ok(Some((output.exit_code, combined)));
}
}
Ok(None)
}
fn get_build_env_vars(comp: &Component) -> Vec<(String, String)> {
let mut env = Vec::new();
if let Some(modules) = &comp.modules {
for module_id in modules.keys() {
if let Ok(module) = module::load_module(module_id) {
if module.build.is_some() {
if let Ok(module_path) = paths::module(module_id) {
let module_path_str = module_path.to_string_lossy().to_string();
env.push(("HOMEBOY_MODULE_PATH".to_string(), module_path_str));
env.push(("HOMEBOY_COMPONENT_PATH".to_string(), comp.local_path.clone()));
env.push(("HOMEBOY_PLUGIN_PATH".to_string(), comp.local_path.clone()));
break; }
}
}
}
}
env
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_json_input_detects_json() {
assert!(is_json_input(r#"{"componentIds": ["a"]}"#));
assert!(is_json_input(r#" {"componentIds": ["a"]}"#));
assert!(!is_json_input("extrachill-api"));
assert!(!is_json_input("some-component-id"));
}
}