use serde::Serialize;
use std::collections::HashMap;
use crate::component::{self, Component};
use crate::context::resolve_project_ssh;
use crate::error::ErrorCode;
use crate::executor;
use crate::module::{find_module_by_tool, CliConfig};
use crate::project::{self, Project};
use crate::shell;
use crate::ssh::{execute_local_command, CommandOutput};
use crate::template::{render_map, TemplateVars};
use crate::token;
use crate::{Error, Result};
#[derive(Serialize, Clone)]
pub struct CliToolResult {
pub tool: String,
pub module_id: String,
pub identifier: String,
pub target_domain: Option<String>,
pub executed_command: String,
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
pub fn run(tool: &str, identifier: &str, args: &[String]) -> Result<CliToolResult> {
if let Some(result) = try_run_for_component(tool, identifier, args) {
return result;
}
run_for_project(tool, identifier, args)
}
fn try_run_for_component(
tool: &str,
identifier: &str,
args: &[String],
) -> Option<Result<CliToolResult>> {
match component::load(identifier) {
Ok(component) => {
let module = find_module_by_tool(tool)?;
let cli_config = module.cli.as_ref()?;
let command = build_component_command(&component, cli_config, args);
let output = execute_local_command(&command);
Some(Ok(CliToolResult {
tool: tool.to_string(),
module_id: module.id.clone(),
identifier: identifier.to_string(),
target_domain: None,
executed_command: command,
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.exit_code,
}))
}
Err(e) if e.code == ErrorCode::ComponentNotFound => None,
Err(e) => Some(Err(e)),
}
}
fn build_component_command(
component: &Component,
cli_config: &CliConfig,
args: &[String],
) -> String {
let mut variables = HashMap::new();
variables.insert(
TemplateVars::SITE_PATH.to_string(),
component.local_path.clone(),
);
variables.insert(
TemplateVars::CLI_PATH.to_string(),
cli_config
.default_cli_path
.clone()
.unwrap_or_else(|| cli_config.tool.clone()),
);
variables.insert(TemplateVars::ARGS.to_string(), shell::quote_args(args));
render_map(&cli_config.command_template, &variables)
}
fn run_for_project(tool: &str, project_id: &str, args: &[String]) -> Result<CliToolResult> {
run_for_project_with_executor(tool, project_id, args, project::load, execute_local_command)
}
fn run_for_project_with_executor(
tool: &str,
project_id: &str,
args: &[String],
project_loader: fn(&str) -> Result<Project>,
local_executor: fn(&str) -> CommandOutput,
) -> Result<CliToolResult> {
if args.is_empty() {
return Err(Error::other("No command provided".to_string()));
}
let module = find_module_by_tool(tool)
.ok_or_else(|| Error::other(format!("No module provides tool '{}'", tool)))?;
let cli_config = module.cli.as_ref().ok_or_else(|| {
Error::other(format!(
"Module '{}' does not have CLI configuration",
module.id
))
})?;
let project = project_loader(project_id)?;
let (target_domain, command_args) = resolve_subtarget(&project, args)?;
if command_args.is_empty() {
return Err(Error::other(
"No command provided after subtarget".to_string(),
));
}
let (output, executed_command) = if project.server_id.as_ref().is_none_or(|s| s.is_empty()) {
let result = executor::execute_for_project_direct(
&project,
cli_config,
&command_args,
&target_domain,
);
match result {
Ok(cmd_output) => (
cmd_output,
format!("{} {}", cli_config.tool, command_args.join(" ")),
),
Err(_) => {
let (_, rendered_cmd) =
build_project_command(&project, cli_config, &module.id, args)?;
(local_executor(&rendered_cmd), rendered_cmd)
}
}
} else {
let ctx = resolve_project_ssh(project_id)?;
let (_, rendered_cmd) = build_project_command(&project, cli_config, &module.id, args)?;
let cmd_output = ctx.client.execute(&rendered_cmd);
(cmd_output, rendered_cmd)
};
Ok(CliToolResult {
tool: tool.to_string(),
module_id: module.id,
identifier: project_id.to_string(),
target_domain: Some(target_domain),
executed_command,
stdout: output.stdout,
stderr: output.stderr,
exit_code: output.exit_code,
})
}
fn build_project_command(
project: &Project,
cli_config: &CliConfig,
module_id: &str,
args: &[String],
) -> Result<(String, String)> {
let base_path = project
.base_path
.clone()
.filter(|p| !p.is_empty())
.ok_or_else(|| Error::config("Base path not configured".to_string()))?;
let (target_domain, command_args) = resolve_subtarget(project, args)?;
if command_args.is_empty() {
return Err(Error::other(
"No command provided after subtarget".to_string(),
));
}
let cli_path = cli_config
.default_cli_path
.clone()
.unwrap_or_else(|| cli_config.tool.clone());
let mut variables = HashMap::new();
variables.insert(TemplateVars::PROJECT_ID.to_string(), project.id.clone());
variables.insert(TemplateVars::DOMAIN.to_string(), target_domain.clone());
variables.insert(
TemplateVars::ARGS.to_string(),
shell::quote_args(&command_args),
);
variables.insert(TemplateVars::SITE_PATH.to_string(), base_path);
variables.insert(TemplateVars::CLI_PATH.to_string(), cli_path);
let mut rendered = render_map(&cli_config.command_template, &variables);
if !cli_config.settings_flags.is_empty() {
if let Some(modules) = &project.modules {
if let Some(module_config) = modules.get(module_id) {
for (setting_key, flag_template) in &cli_config.settings_flags {
if let Some(value) = module_config.settings.get(setting_key) {
if let Some(value_str) = value.as_str() {
if !value_str.is_empty() {
let flag = flag_template
.replace("{{value}}", &shell::quote_arg(value_str));
rendered.push(' ');
rendered.push_str(&flag);
}
}
}
}
}
}
}
Ok((target_domain, rendered))
}
fn resolve_subtarget(project: &Project, args: &[String]) -> Result<(String, Vec<String>)> {
let require_domain = || {
Error::validation_invalid_argument(
"domain",
"This operation requires a domain to be configured on the project",
Some(project.id.clone()),
None,
)
};
if project.sub_targets.is_empty() {
let domain = project.domain.clone().ok_or_else(require_domain)?;
return Ok((domain, args.to_vec()));
}
let Some(sub_id) = args.first() else {
let subtarget_list = project
.sub_targets
.iter()
.map(|t| {
let slug = project::slugify_id(&t.name).unwrap_or_else(|_| t.name.clone());
format!("- {} (use: {})", t.name, slug)
})
.collect::<Vec<_>>()
.join("\n");
return Err(Error::validation_invalid_argument(
"subtarget",
&format!(
"This project has subtargets configured. You must specify which subtarget to use.\n\nAvailable subtargets for project '{}':\n{}",
project.id, subtarget_list
),
Some(project.id.clone()),
None,
));
};
if let Some(subtarget) = project.sub_targets.iter().find(|t| {
project::slugify_id(&t.name).ok().as_deref() == Some(sub_id)
|| token::identifier_eq(&t.name, sub_id)
}) {
return Ok((subtarget.domain.clone(), args[1..].to_vec()));
}
let subtarget_list = project
.sub_targets
.iter()
.map(|t| {
let slug = project::slugify_id(&t.name).unwrap_or_else(|_| t.name.clone());
format!("- {} (use: {})", t.name, slug)
})
.collect::<Vec<_>>()
.join("\n");
Err(Error::validation_invalid_argument(
"subtarget",
&format!(
"Subtarget '{}' not found. Available subtargets for project '{}':\n{}",
sub_id, project.id, subtarget_list
),
Some(project.id.clone()),
None,
))
}