Skip to content
//! Application inventory, read from the Docker daemon.
//!
//! `once` renders its own listing through a bubbletea TUI, so its stdout is
//! styled text rather than a stable machine format. The daemon, by contrast,
//! labels every `once`-managed container with a `once` JSON label — so that is
//! what `twice` reads. Nothing here mutates anything.

use std::collections::HashMap;

use bollard::Docker;
use bollard::query_parameters::ListContainersOptionsBuilder;
use serde::Deserialize;
use twice_core::{AppSummary, ContainerName, ContainerState, Host, ImageRef};

/// Label key `once` stamps onto the containers and volumes it manages.
const ONCE_LABEL: &str = "once";

/// Read-only view of the applications `once` manages on this host.
#[derive(Debug)]
pub(crate) struct DockerInventory {
    client: Docker,
    proxy_container: String,
}

/// The subset of the `once` label payload `twice` understands. Unknown keys are
/// ignored rather than rejected, because the label is `once`'s private format.
#[derive(Debug, Default, Deserialize)]
struct OnceLabel {
    #[serde(default, alias = "hostname", alias = "Host")]
    host: Option<String>,
    #[serde(default, rename = "autoUpdate", alias = "auto_update")]
    auto_update: Option<bool>,
    #[serde(default, rename = "autoBackup", alias = "auto_backup")]
    auto_backup: Option<bool>,
}

impl DockerInventory {
    /// Connects to the local daemon over its usual socket or `DOCKER_HOST`.
    pub(crate) fn connect(namespace: &str) -> Result<Self, bollard::errors::Error> {
        Ok(Self {
            client: Docker::connect_with_defaults()?,
            proxy_container: format!("{namespace}-proxy"),
        })
    }

    /// Lists every `once`-managed application, running or not.
    pub(crate) async fn list_apps(&self) -> Result<Vec<AppSummary>, bollard::errors::Error> {
        let mut filters = HashMap::new();
        filters.insert("label".to_owned(), vec![ONCE_LABEL.to_owned()]);
        let options = ListContainersOptionsBuilder::default()
            .all(true)
            .filters(&filters)
            .build();

        let mut apps: Vec<AppSummary> = self
            .client
            .list_containers(Some(options))
            .await?
            .into_iter()
            .filter_map(|container| {
                let name = container
                    .names
                    .unwrap_or_default()
                    .first()
                    .map(|n| n.trim_start_matches('/').to_owned())?;
                if name == self.proxy_container {
                    return None;
                }
                let label = container
                    .labels
                    .as_ref()
                    .and_then(|labels| labels.get(ONCE_LABEL))
                    .and_then(|raw| serde_json::from_str::<OnceLabel>(raw).ok())
                    .unwrap_or_default();

                let host = label
                    .host
                    .as_deref()
                    .and_then(|h| Host::parse(h).ok())
                    .or_else(|| Host::parse(&name).ok())?;
                let image = container
                    .image
                    .as_deref()
                    .and_then(|i| ImageRef::parse(i).ok())?;

                Some(AppSummary {
                    host,
                    container: ContainerName(name),
                    image,
                    state: container.state.map_or(ContainerState::Unknown, |s| {
                        ContainerState::from_docker(&s.to_string().to_lowercase())
                    }),
                    status: container.status.unwrap_or_default(),
                    auto_update: label.auto_update,
                    auto_backup: label.auto_backup,
                })
            })
            .collect();

        apps.sort_by(|a, b| a.host.cmp(&b.host));
        Ok(apps)
    }

    /// Whether an application with this host currently exists.
    pub(crate) async fn knows_host(&self, host: &Host) -> Result<bool, bollard::errors::Error> {
        Ok(self.list_apps().await?.iter().any(|app| &app.host == host))
    }
}

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

    #[test]
    fn once_label_tolerates_unknown_keys_and_casings() {
        let raw = r#"{"host":"writebook.example.com","autoUpdate":true,"httpPort":80}"#;
        let parsed: OnceLabel = serde_json::from_str(raw).unwrap();
        assert_eq!(parsed.host.as_deref(), Some("writebook.example.com"));
        assert_eq!(parsed.auto_update, Some(true));
        assert_eq!(parsed.auto_backup, None);
    }

    #[test]
    fn once_label_without_a_host_parses_as_empty() {
        let parsed: OnceLabel =
            serde_json::from_str(r#"{"httpPort":80,"metricsPort":1318}"#).unwrap();
        assert_eq!(parsed.host, None);
    }
}