use clap::{Args, Subcommand, ValueEnum};
use serde::Serialize;
use homeboy::component::{self, Component};
use homeboy::project::{self, Project};
use homeboy::server;
#[derive(Args)]
pub struct ProjectArgs {
#[command(subcommand)]
command: ProjectCommand,
}
#[derive(Subcommand)]
enum ProjectCommand {
List,
Show {
project_id: String,
},
Create {
#[arg(long)]
json: Option<String>,
#[arg(long)]
skip_existing: bool,
id: Option<String>,
domain: Option<String>,
#[arg(long)]
server_id: Option<String>,
#[arg(long)]
base_path: Option<String>,
#[arg(long)]
table_prefix: Option<String>,
},
#[command(visible_aliases = ["edit", "merge"])]
Set {
project_id: Option<String>,
spec: Option<String>,
#[arg(long, value_name = "JSON")]
json: Option<String>,
#[arg(long, value_name = "FIELD")]
replace: Vec<String>,
},
Remove {
project_id: Option<String>,
spec: Option<String>,
#[arg(long, value_name = "JSON")]
json: Option<String>,
},
Rename {
project_id: String,
new_id: String,
},
Components {
#[command(subcommand)]
command: ProjectComponentsCommand,
},
Pin {
#[command(subcommand)]
command: ProjectPinCommand,
},
Delete {
project_id: String,
},
}
#[derive(Subcommand)]
enum ProjectComponentsCommand {
List {
project_id: String,
},
Set {
project_id: String,
component_ids: Vec<String>,
},
Add {
project_id: String,
component_ids: Vec<String>,
},
Remove {
project_id: String,
component_ids: Vec<String>,
},
Clear {
project_id: String,
},
}
#[derive(Debug, Serialize)]
pub struct ProjectComponentsOutput {
pub action: String,
pub project_id: String,
pub component_ids: Vec<String>,
pub components: Vec<Component>,
}
#[derive(Debug, Serialize)]
pub struct ProjectListItem {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
domain: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ProjectPinOutput {
pub action: String,
pub project_id: String,
pub r#type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Vec<ProjectPinListItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub added: Option<ProjectPinChange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub removed: Option<ProjectPinChange>,
}
#[derive(Debug, Serialize)]
pub struct ProjectPinListItem {
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub display_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tail_lines: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct ProjectPinChange {
pub path: String,
pub r#type: String,
}
#[derive(Subcommand)]
enum ProjectPinCommand {
List {
project_id: String,
#[arg(long, value_enum)]
r#type: ProjectPinType,
},
Add {
project_id: String,
path: String,
#[arg(long, value_enum)]
r#type: ProjectPinType,
#[arg(long)]
label: Option<String>,
#[arg(long, default_value = "100")]
tail: u32,
},
Remove {
project_id: String,
path: String,
#[arg(long, value_enum)]
r#type: ProjectPinType,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum ProjectPinType {
File,
Log,
}
#[derive(Debug, Default, Serialize)]
pub struct ProjectOutput {
command: String,
#[serde(skip_serializing_if = "Option::is_none")]
project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
project: Option<Project>,
#[serde(skip_serializing_if = "Option::is_none")]
projects: Option<Vec<ProjectListItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
components: Option<ProjectComponentsOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
pin: Option<ProjectPinOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
updated: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
removed: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
deleted: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
import: Option<homeboy::BatchResult>,
#[serde(skip_serializing_if = "Option::is_none")]
batch: Option<homeboy::BatchResult>,
#[serde(skip_serializing_if = "Option::is_none")]
hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
deploy_ready: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
deploy_blockers: Option<Vec<String>>,
}
pub fn run(
args: ProjectArgs,
_global: &crate::commands::GlobalArgs,
) -> homeboy::Result<(ProjectOutput, i32)> {
match args.command {
ProjectCommand::List => list(),
ProjectCommand::Show { project_id } => show(&project_id),
ProjectCommand::Create {
json,
skip_existing,
id,
domain,
server_id,
base_path,
table_prefix,
} => {
let json_spec = if let Some(spec) = json {
spec
} else {
let id = id.ok_or_else(|| {
homeboy::Error::validation_invalid_argument(
"id",
"Missing required argument: id",
None,
None,
)
})?;
let new_project = project::Project {
id,
domain,
server_id,
base_path,
table_prefix,
..Default::default()
};
serde_json::to_string(&new_project).map_err(|e| {
homeboy::Error::internal_unexpected(format!("Failed to serialize: {}", e))
})?
};
match project::create(&json_spec, skip_existing)? {
homeboy::CreateOutput::Single(result) => Ok((
ProjectOutput {
command: "project.create".to_string(),
project_id: Some(result.id),
project: Some(result.entity),
..Default::default()
},
0,
)),
homeboy::CreateOutput::Bulk(summary) => {
let exit_code = if summary.errors > 0 { 1 } else { 0 };
Ok((
ProjectOutput {
command: "project.create".to_string(),
import: Some(summary),
..Default::default()
},
exit_code,
))
}
}
}
ProjectCommand::Set {
project_id,
spec,
json,
replace,
} => {
let json_spec = json.or(spec).ok_or_else(|| {
homeboy::Error::validation_invalid_argument(
"spec",
"Provide JSON spec or use --json flag",
None,
None,
)
})?;
set(project_id.as_deref(), &json_spec, &replace)
}
ProjectCommand::Remove {
project_id,
spec,
json,
} => {
let json_spec = json.or(spec).ok_or_else(|| {
homeboy::Error::validation_invalid_argument(
"spec",
"Provide JSON spec or use --json flag",
None,
None,
)
})?;
remove(project_id.as_deref(), &json_spec)
}
ProjectCommand::Rename { project_id, new_id } => rename(&project_id, &new_id),
ProjectCommand::Components { command } => components(command),
ProjectCommand::Pin { command } => pin(command),
ProjectCommand::Delete { project_id } => delete(&project_id),
}
}
fn list() -> homeboy::Result<(ProjectOutput, i32)> {
let projects = project::list()?;
let items: Vec<ProjectListItem> = projects
.into_iter()
.map(|p| ProjectListItem {
id: p.id,
domain: p.domain,
})
.collect();
let hint = if items.is_empty() {
Some(
"No projects configured. Run 'homeboy init' to see project context"
.to_string(),
)
} else {
None
};
Ok((
ProjectOutput {
command: "project.list".to_string(),
projects: Some(items),
hint,
..Default::default()
},
0,
))
}
fn show(project_id: &str) -> homeboy::Result<(ProjectOutput, i32)> {
let project = project::load(project_id)?;
let hint = if project.server_id.is_none() {
Some(
"Local project: Commands execute on this machine. Only deploy requires a server."
.to_string(),
)
} else if project.component_ids.is_empty() {
Some(format!(
"No components linked. Use: homeboy project components add {} <component-id>",
project.id
))
} else {
None
};
let (deploy_ready, deploy_blockers) = calculate_deploy_readiness(&project);
Ok((
ProjectOutput {
command: "project.show".to_string(),
project_id: Some(project.id.clone()),
project: Some(project),
hint,
deploy_ready: Some(deploy_ready),
deploy_blockers: if deploy_blockers.is_empty() {
None
} else {
Some(deploy_blockers)
},
..Default::default()
},
0,
))
}
fn calculate_deploy_readiness(project: &Project) -> (bool, Vec<String>) {
let mut blockers = Vec::new();
match &project.server_id {
None => {
blockers.push(format!(
"Missing server_id - set with: homeboy project set {} '{{\"server_id\": \"<server-id>\"}}'",
project.id
));
}
Some(sid) if !server::exists(sid) => {
blockers.push(format!(
"Server '{}' not found - create with: homeboy server set {} '{{\"host\": \"...\", \"user\": \"...\"}}'",
sid, sid
));
}
_ => {}
}
if project
.base_path
.as_ref()
.map(|p| p.is_empty())
.unwrap_or(true)
{
blockers.push(format!(
"Missing base_path - set with: homeboy project set {} '{{\"base_path\": \"/path/to/webroot\"}}'",
project.id
));
}
if project.component_ids.is_empty() {
blockers.push(format!(
"No components linked - add with: homeboy project components add {} <component-id>",
project.id
));
}
let deploy_ready = blockers.is_empty();
(deploy_ready, blockers)
}
fn set(
project_id: Option<&str>,
json: &str,
replace_fields: &[String],
) -> homeboy::Result<(ProjectOutput, i32)> {
match project::merge(project_id, json, replace_fields)? {
homeboy::MergeOutput::Single(result) => Ok((
ProjectOutput {
command: "project.set".to_string(),
project_id: Some(result.id.clone()),
project: Some(project::load(&result.id)?),
updated: Some(result.updated_fields),
..Default::default()
},
0,
)),
homeboy::MergeOutput::Bulk(summary) => {
let exit_code = if summary.errors > 0 { 1 } else { 0 };
Ok((
ProjectOutput {
command: "project.set".to_string(),
batch: Some(summary),
..Default::default()
},
exit_code,
))
}
}
}
fn remove(project_id: Option<&str>, json: &str) -> homeboy::Result<(ProjectOutput, i32)> {
let result = project::remove_from_json(project_id, json)?;
Ok((
ProjectOutput {
command: "project.remove".to_string(),
project_id: Some(result.id.clone()),
project: Some(project::load(&result.id)?),
removed: Some(result.removed_from),
..Default::default()
},
0,
))
}
fn rename(project_id: &str, new_id: &str) -> homeboy::Result<(ProjectOutput, i32)> {
let result = project::rename(project_id, new_id)?;
Ok((
ProjectOutput {
command: "project.rename".to_string(),
project_id: Some(result.new_id.clone()),
project: Some(project::load(&result.new_id)?),
updated: Some(vec!["id".to_string()]),
..Default::default()
},
0,
))
}
fn delete(project_id: &str) -> homeboy::Result<(ProjectOutput, i32)> {
project::delete(project_id)?;
Ok((
ProjectOutput {
command: "project.delete".to_string(),
project_id: Some(project_id.to_string()),
deleted: Some(vec![project_id.to_string()]),
..Default::default()
},
0,
))
}
fn components(command: ProjectComponentsCommand) -> homeboy::Result<(ProjectOutput, i32)> {
match command {
ProjectComponentsCommand::List { project_id } => components_list(&project_id),
ProjectComponentsCommand::Set {
project_id,
component_ids,
} => components_set(&project_id, component_ids),
ProjectComponentsCommand::Add {
project_id,
component_ids,
} => components_add(&project_id, component_ids),
ProjectComponentsCommand::Remove {
project_id,
component_ids,
} => components_remove(&project_id, component_ids),
ProjectComponentsCommand::Clear { project_id } => components_clear(&project_id),
}
}
fn components_list(project_id: &str) -> homeboy::Result<(ProjectOutput, i32)> {
let project = project::load(project_id)?;
let mut components = Vec::new();
for component_id in &project.component_ids {
let component = component::load(component_id)?;
components.push(component);
}
Ok((
ProjectOutput {
command: "project.components.list".to_string(),
project_id: Some(project_id.to_string()),
components: Some(ProjectComponentsOutput {
action: "list".to_string(),
project_id: project_id.to_string(),
component_ids: project.component_ids.clone(),
components,
}),
..Default::default()
},
0,
))
}
fn components_set(
project_id: &str,
component_ids: Vec<String>,
) -> homeboy::Result<(ProjectOutput, i32)> {
project::set_components(project_id, component_ids)?;
let project = project::load(project_id)?;
write_project_components(project_id, "set", &project)
}
fn components_add(
project_id: &str,
component_ids: Vec<String>,
) -> homeboy::Result<(ProjectOutput, i32)> {
project::add_components(project_id, component_ids)?;
let project = project::load(project_id)?;
write_project_components(project_id, "add", &project)
}
fn components_remove(
project_id: &str,
component_ids: Vec<String>,
) -> homeboy::Result<(ProjectOutput, i32)> {
project::remove_components(project_id, component_ids)?;
let project = project::load(project_id)?;
write_project_components(project_id, "remove", &project)
}
fn components_clear(project_id: &str) -> homeboy::Result<(ProjectOutput, i32)> {
let mut project = project::load(project_id)?;
project.component_ids.clear();
write_project_components(project_id, "clear", &project)
}
fn write_project_components(
project_id: &str,
action: &str,
project: &Project,
) -> homeboy::Result<(ProjectOutput, i32)> {
project::save(project)?;
let mut components = Vec::new();
for component_id in &project.component_ids {
let component = component::load(component_id)?;
components.push(component);
}
Ok((
ProjectOutput {
command: format!("project.components.{action}"),
project_id: Some(project_id.to_string()),
components: Some(ProjectComponentsOutput {
action: action.to_string(),
project_id: project_id.to_string(),
component_ids: project.component_ids.clone(),
components,
}),
updated: Some(vec!["componentIds".to_string()]),
..Default::default()
},
0,
))
}
fn pin(command: ProjectPinCommand) -> homeboy::Result<(ProjectOutput, i32)> {
match command {
ProjectPinCommand::List { project_id, r#type } => pin_list(&project_id, r#type),
ProjectPinCommand::Add {
project_id,
path,
r#type,
label,
tail,
} => pin_add(&project_id, &path, r#type, label, tail),
ProjectPinCommand::Remove {
project_id,
path,
r#type,
} => pin_remove(&project_id, &path, r#type),
}
}
fn pin_list(project_id: &str, pin_type: ProjectPinType) -> homeboy::Result<(ProjectOutput, i32)> {
let project = project::load(project_id)?;
let (items, type_string) = match pin_type {
ProjectPinType::File => (
project
.remote_files
.pinned_files
.iter()
.map(|file| ProjectPinListItem {
path: file.path.clone(),
label: file.label.clone(),
display_name: file.display_name().to_string(),
tail_lines: None,
})
.collect(),
"file",
),
ProjectPinType::Log => (
project
.remote_logs
.pinned_logs
.iter()
.map(|log| ProjectPinListItem {
path: log.path.clone(),
label: log.label.clone(),
display_name: log.display_name().to_string(),
tail_lines: Some(log.tail_lines),
})
.collect(),
"log",
),
};
Ok((
ProjectOutput {
command: "project.pin.list".to_string(),
project_id: Some(project_id.to_string()),
pin: Some(ProjectPinOutput {
action: "list".to_string(),
project_id: project_id.to_string(),
r#type: type_string.to_string(),
items: Some(items),
added: None,
removed: None,
}),
..Default::default()
},
0,
))
}
fn pin_add(
project_id: &str,
path: &str,
pin_type: ProjectPinType,
label: Option<String>,
tail: u32,
) -> homeboy::Result<(ProjectOutput, i32)> {
let (core_type, type_string) = match pin_type {
ProjectPinType::File => (project::PinType::File, "file"),
ProjectPinType::Log => (project::PinType::Log, "log"),
};
project::pin(
project_id,
core_type,
path,
project::PinOptions {
label,
tail_lines: tail,
},
)?;
Ok((
ProjectOutput {
command: "project.pin.add".to_string(),
project_id: Some(project_id.to_string()),
pin: Some(ProjectPinOutput {
action: "add".to_string(),
project_id: project_id.to_string(),
r#type: type_string.to_string(),
items: None,
added: Some(ProjectPinChange {
path: path.to_string(),
r#type: type_string.to_string(),
}),
removed: None,
}),
..Default::default()
},
0,
))
}
fn pin_remove(
project_id: &str,
path: &str,
pin_type: ProjectPinType,
) -> homeboy::Result<(ProjectOutput, i32)> {
let (core_type, type_string) = match pin_type {
ProjectPinType::File => (project::PinType::File, "file"),
ProjectPinType::Log => (project::PinType::Log, "log"),
};
project::unpin(project_id, core_type, path)?;
Ok((
ProjectOutput {
command: "project.pin.remove".to_string(),
project_id: Some(project_id.to_string()),
pin: Some(ProjectPinOutput {
action: "remove".to_string(),
project_id: project_id.to_string(),
r#type: type_string.to_string(),
items: None,
added: None,
removed: Some(ProjectPinChange {
path: path.to_string(),
r#type: type_string.to_string(),
}),
}),
..Default::default()
},
0,
))
}