homeboy 0.124.11

CLI for multi-component deployment and development workflow automation
Documentation
use crate::config::{self, ConfigEntity};
use crate::error::{Error, Result};
use crate::output::{CreateOutput, MergeOutput, RemoveResult};
use crate::project;
use crate::project::ProjectComponentOverrides;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

pub mod check;
pub mod exec;
pub mod status;

pub use check::{collect_check, FleetCheckSummary, FleetComponentCheck, FleetProjectCheck};
pub use exec::{collect_exec, FleetExecProjectResult, FleetExecSummary};
pub use status::{
    collect_status, FleetComponentDrift, FleetComponentStatus, FleetComponentSummary,
    FleetProjectStatus, FleetProjectSummary, FleetServerSummary, FleetStatusResult,
    FleetStatusSummary, FleetWarning,
};

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Fleet {
    #[serde(skip_deserializing, default)]
    pub id: String,

    #[serde(default)]
    pub project_ids: Vec<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Fleet-level component overrides applied as defaults when no project-level
    /// override exists. Resolution order: component (repo) → project → fleet.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub component_overrides: HashMap<String, ProjectComponentOverrides>,

    /// Labels treated as priority issues by `homeboy triage` for this fleet.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub priority_labels: Option<Vec<String>>,
}

impl Fleet {
    pub fn new(id: String, project_ids: Vec<String>) -> Self {
        Self {
            id,
            project_ids,
            description: None,
            component_overrides: HashMap::new(),
            priority_labels: None,
        }
    }
}

impl ConfigEntity for Fleet {
    const ENTITY_TYPE: &'static str = "fleet";
    const DIR_NAME: &'static str = "fleets";

    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::fleet_not_found(id, suggestions)
    }
}

// ============================================================================
// Core CRUD - Generated by entity_crud! macro
// ============================================================================

entity_crud!(Fleet; list_ids, merge);

// ============================================================================
// Operations
// ============================================================================

/// Add a project to a fleet
pub fn add_project(fleet_id: &str, project_id: &str) -> Result<Fleet> {
    let mut fleet = load(fleet_id)?;

    // Validate project exists
    if !project::exists(project_id) {
        let suggestions = config::find_similar_ids::<crate::project::Project>(project_id);
        return Err(Error::project_not_found(project_id, suggestions));
    }

    // Don't add duplicates
    if !fleet.project_ids.contains(&project_id.to_string()) {
        fleet.project_ids.push(project_id.to_string());
        save(&fleet)?;
    }

    Ok(fleet)
}

/// Remove a project from a fleet
pub fn remove_project(fleet_id: &str, project_id: &str) -> Result<Fleet> {
    let mut fleet = load(fleet_id)?;

    fleet.project_ids.retain(|id| id != project_id);
    save(&fleet)?;

    Ok(fleet)
}

/// Get all projects in a fleet with full project data
pub fn get_projects(fleet_id: &str) -> Result<Vec<crate::project::Project>> {
    let fleet = load(fleet_id)?;
    let mut projects = Vec::new();

    for project_id in &fleet.project_ids {
        if let Ok(project) = project::load(project_id) {
            projects.push(project);
        }
    }

    Ok(projects)
}

/// Get component usage across a fleet (component_id -> Vec<project_id>)
pub fn component_usage(fleet_id: &str) -> Result<std::collections::HashMap<String, Vec<String>>> {
    let fleet = load(fleet_id)?;
    let mut usage: std::collections::HashMap<String, Vec<String>> =
        std::collections::HashMap::new();

    for project_id in &fleet.project_ids {
        if let Ok(project) = project::load(project_id) {
            for component_id in project::project_component_ids(&project) {
                usage
                    .entry(component_id)
                    .or_default()
                    .push(project_id.clone());
            }
        }
    }

    Ok(usage)
}

// Fleet Sync was removed in #101 — it had OpenClaw-specific logic hardcoded
// in core, violating homeboy's platform-agnostic design. Use `homeboy deploy`
// to sync files across servers instead.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn fleet_new_has_empty_component_overrides() {
        let fleet = Fleet::new("test-fleet".to_string(), vec!["project-a".to_string()]);
        assert!(fleet.component_overrides.is_empty());
    }

    #[test]
    fn fleet_component_overrides_serialization_roundtrip() {
        let mut fleet = Fleet::new("test-fleet".to_string(), vec![]);
        fleet.component_overrides.insert(
            "my-plugin".to_string(),
            ProjectComponentOverrides {
                remote_path: Some("wp-content/plugins/my-plugin".to_string()),
                remote_owner: Some("www-data:www-data".to_string()),
                ..Default::default()
            },
        );

        let json = serde_json::to_string(&fleet).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();

        // component_overrides should be present in serialized output
        let overrides = parsed.get("component_overrides").unwrap();
        let plugin = overrides.get("my-plugin").unwrap();
        assert_eq!(
            plugin.get("remote_path").unwrap().as_str(),
            Some("wp-content/plugins/my-plugin")
        );
        assert_eq!(
            plugin.get("remote_owner").unwrap().as_str(),
            Some("www-data:www-data")
        );
    }

    #[test]
    fn fleet_empty_component_overrides_not_serialized() {
        let fleet = Fleet::new("test-fleet".to_string(), vec![]);

        let json = serde_json::to_string(&fleet).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();

        // component_overrides should be omitted when empty (skip_serializing_if)
        assert!(parsed.get("component_overrides").is_none());
    }

    #[test]
    fn fleet_priority_labels_serialization_roundtrip() {
        let mut fleet = Fleet::new("test-fleet".to_string(), vec![]);
        fleet.priority_labels = Some(vec!["urgent".to_string(), "release-blocker".to_string()]);

        let json = serde_json::to_string(&fleet).unwrap();
        let parsed: Fleet = serde_json::from_str(&json).unwrap();

        assert_eq!(
            parsed.priority_labels,
            Some(vec!["urgent".to_string(), "release-blocker".to_string()])
        );
    }
}