pitchfork-cli 2.13.1

Daemons with DX
Documentation
use crate::Result;
use crate::daemon::Daemon;
use crate::daemon_id::DaemonId;
use crate::daemon_status::DaemonStatus;
use crate::ipc::client::IpcClient;
use crate::pitchfork_toml::PitchforkToml;
use std::collections::HashSet;

/// Represents a daemon entry that can be either tracked (from state file) or available (from config only)
#[derive(Debug, Clone)]
pub struct DaemonListEntry {
    pub id: DaemonId,
    pub daemon: Daemon,
    pub is_disabled: bool,
    pub is_available: bool, // true if daemon is only in config, not in state
}

/// Get a unified list of all daemons from IPC client and config
///
/// This function merges daemons from the state file (including failed daemons) with daemons
/// defined in config files. Daemons that are only in config (not in state file) are marked
/// as "available".
///
/// This logic is shared across:
/// - `pitchfork list` command
/// - TUI daemon list
///
/// # Arguments
/// * `client` - IPC client to communicate with supervisor (used only for disabled list)
///
/// # Returns
/// A vector of daemon entries with their current status
pub async fn get_all_daemons(client: &IpcClient) -> Result<Vec<DaemonListEntry>> {
    let config = PitchforkToml::all_merged()?;

    // Read state file to get all daemons (including failed ones)
    let state_file = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE)?;
    let state_daemons: Vec<Daemon> = state_file.daemons.values().cloned().collect();

    let disabled_daemons = client.get_disabled_daemons().await?;
    let disabled_set: HashSet<DaemonId> = disabled_daemons.into_iter().collect();

    build_daemon_list(state_daemons, disabled_set, config)
}

/// Get a unified list of all daemons from supervisor directly (for Web UI)
///
/// This function is used by the Web UI which runs inside the supervisor process
/// and can access the supervisor directly without IPC.
///
/// # Arguments
/// * `supervisor` - Reference to the supervisor instance
///
/// # Returns
/// A vector of daemon entries with their current status
pub async fn get_all_daemons_direct(
    supervisor: &crate::supervisor::Supervisor,
) -> Result<Vec<DaemonListEntry>> {
    let config = PitchforkToml::all_merged()?;

    // Read all daemons from state file (including failed/stopped ones)
    let state_file = supervisor.state_file.lock().await;
    let state_daemons: Vec<Daemon> = state_file.daemons.values().cloned().collect();
    let disabled_set: HashSet<DaemonId> = state_file.disabled.clone().into_iter().collect();
    drop(state_file); // Release lock early

    build_daemon_list(state_daemons, disabled_set, config)
}

/// Look up a single daemon by ID from state + config (for Web UI show handler).
///
/// Checks the state file first, then falls back to config files (including namespaces).
/// Returns `None` if the daemon is not found anywhere.
pub async fn get_daemon_direct(
    supervisor: &crate::supervisor::Supervisor,
    id: &DaemonId,
) -> Result<Option<DaemonListEntry>> {
    let pitchfork_id = DaemonId::pitchfork();
    if *id == pitchfork_id {
        return Ok(None);
    }

    // Check state file first
    let state_file = supervisor.state_file.lock().await;
    if let Some(daemon) = state_file.daemons.get(id).cloned() {
        let is_disabled = state_file.disabled.contains(id);
        drop(state_file);
        return Ok(Some(DaemonListEntry {
            id: id.clone(),
            daemon,
            is_disabled,
            is_available: false,
        }));
    }
    let is_disabled = state_file.disabled.contains(id);
    drop(state_file);

    // Not in state — look in local config
    let config = PitchforkToml::all_merged()?;
    if let Some(daemon_config) = config.daemons.get(id) {
        return Ok(Some(DaemonListEntry {
            id: id.clone(),
            daemon: build_placeholder_daemon(id, daemon_config),
            is_disabled,
            is_available: true,
        }));
    }

    // Check registered namespaces
    let namespaces = PitchforkToml::read_global_namespaces();
    for (_, entry) in namespaces {
        match PitchforkToml::all_merged_from(&entry.dir) {
            Ok(ns_config) => {
                if let Some(daemon_config) = ns_config.daemons.get(id) {
                    return Ok(Some(DaemonListEntry {
                        id: id.clone(),
                        daemon: build_placeholder_daemon(id, daemon_config),
                        is_disabled,
                        is_available: true,
                    }));
                }
            }
            Err(e) => {
                log::warn!("Failed to load namespace from {}: {e}", entry.dir.display());
            }
        }
    }

    Ok(None)
}

/// Build a placeholder Daemon from config for daemons that exist in config but not state.
fn build_placeholder_daemon(
    id: &DaemonId,
    daemon_config: &crate::pitchfork_toml::PitchforkTomlDaemon,
) -> Daemon {
    Daemon {
        id: id.clone(),
        status: DaemonStatus::Stopped,
        port: daemon_config.port.clone(),
        depends: vec![],
        env: None,
        watch: vec![],
        watch_mode: daemon_config.watch_mode,
        watch_base_dir: None,
        mise: daemon_config.mise,
        user: daemon_config.user.clone(),
        active_port: None,
        slug: None,
        proxy: None,
        memory_limit: daemon_config.memory_limit,
        cpu_limit: daemon_config.cpu_limit,
        ..Daemon::default()
    }
}

/// Internal helper to build the daemon list from state daemons and config
fn build_daemon_list(
    state_daemons: Vec<Daemon>,
    disabled_set: HashSet<DaemonId>,
    config: PitchforkToml,
) -> Result<Vec<DaemonListEntry>> {
    let mut entries = Vec::new();
    let mut seen_ids = HashSet::new();

    // Skip the supervisor itself
    let pitchfork_id = DaemonId::pitchfork();

    // First, add all daemons from state file
    for daemon in state_daemons {
        if daemon.id == pitchfork_id {
            continue; // Skip supervisor itself
        }

        // proxy and mise are stored as Option<bool> in the Daemon struct.
        // None means "inherit from global settings", which is resolved at display/routing time.
        // No override needed here — daemon_list consumers call .unwrap_or(settings()...) themselves.

        seen_ids.insert(daemon.id.clone());
        entries.push(DaemonListEntry {
            id: daemon.id.clone(),
            is_disabled: disabled_set.contains(&daemon.id),
            is_available: false,
            daemon,
        });
    }

    // Then, add daemons from config that aren't in state file (available daemons)
    for (daemon_id, daemon_config) in &config.daemons {
        if *daemon_id == pitchfork_id || seen_ids.contains(daemon_id) {
            continue;
        }

        let placeholder = build_placeholder_daemon(daemon_id, daemon_config);

        entries.push(DaemonListEntry {
            id: daemon_id.clone(),
            daemon: placeholder,
            is_disabled: disabled_set.contains(daemon_id),
            is_available: true,
        });
        seen_ids.insert(daemon_id.clone());
    }

    // Add daemons from registered namespaces
    let namespaces = PitchforkToml::read_global_namespaces();
    for (ns_name, entry) in namespaces {
        match PitchforkToml::all_merged_from(&entry.dir) {
            Ok(ns_config) => {
                for (daemon_id, daemon_config) in &ns_config.daemons {
                    if *daemon_id == pitchfork_id || seen_ids.contains(daemon_id) {
                        continue;
                    }
                    let placeholder = build_placeholder_daemon(daemon_id, daemon_config);
                    entries.push(DaemonListEntry {
                        id: daemon_id.clone(),
                        daemon: placeholder,
                        is_disabled: disabled_set.contains(daemon_id),
                        is_available: true,
                    });
                    seen_ids.insert(daemon_id.clone());
                }
            }
            Err(e) => {
                log::warn!(
                    "Failed to load namespace '{ns_name}' from {}: {e}",
                    entry.dir.display()
                );
            }
        }
    }

    Ok(entries)
}