mold-ai-core 0.22.1

Shared types, API protocol, and HTTP client for mold
Documentation
//! Wire types for `GET /api/catalog/installed`.
//!
//! One struct serves both sides: `mold-server` serializes it and the
//! client in this crate deserializes it, so the payload shape can no
//! longer drift silently between the two. Every field is always
//! serialized (no `skip_serializing_if`) — the SPA expects explicit
//! `null`s so a single TypeScript interface covers this endpoint and
//! `/api/catalog/search`. Deserialization stays tolerant of older
//! servers via `#[serde(default)]` on everything the client can
//! reasonably backfill.

use serde::{Deserialize, Serialize};

fn default_true() -> bool {
    true
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InstalledCatalogResponse {
    #[serde(default)]
    pub entries: Vec<InstalledCatalogEntry>,
    #[serde(default)]
    pub page: i64,
    #[serde(default)]
    pub page_size: i64,
    #[serde(default)]
    pub total: i64,
}

/// One installed catalog entry, shaped to stay wire-uniform with the
/// entries `/api/catalog/search` returns. Fields the sidecar doesn't
/// carry (ratings, tags, timestamps…) serialize as empty/`null`
/// placeholders on purpose.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledCatalogEntry {
    pub id: String,
    #[serde(default)]
    pub source: String,
    #[serde(default)]
    pub source_id: String,
    pub name: String,
    #[serde(default)]
    pub author: Option<String>,
    pub family: String,
    #[serde(default)]
    pub family_role: String,
    #[serde(default)]
    pub sub_family: Option<String>,
    #[serde(default)]
    pub modality: String,
    pub kind: String,
    #[serde(default)]
    pub file_format: String,
    #[serde(default)]
    pub bundling: String,
    #[serde(default)]
    pub size_bytes: Option<u64>,
    #[serde(default)]
    pub download_count: u64,
    #[serde(default)]
    pub rating: Option<f64>,
    #[serde(default)]
    pub likes: u64,
    /// `None` means the legacy sidecar did not record a safety classification.
    /// Keep it distinct from an explicit `Some(false)` on the JSON wire.
    #[serde(default)]
    pub nsfw: Option<bool>,
    #[serde(default)]
    pub thumbnail_url: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub license: Option<String>,
    #[serde(default)]
    pub license_flags: Option<serde_json::Value>,
    #[serde(default)]
    pub tags: Vec<String>,
    #[serde(default)]
    pub companions: Vec<String>,
    #[serde(default)]
    pub companion_details: Vec<serde_json::Value>,
    #[serde(default)]
    pub download_recipe: DownloadRecipeWire,
    #[serde(default = "default_true")]
    pub supported: bool,
    #[serde(default)]
    pub installed: bool,
    #[serde(default)]
    pub primary_path: Option<String>,
    #[serde(default)]
    pub created_at: Option<String>,
    #[serde(default)]
    pub updated_at: Option<String>,
    #[serde(default)]
    pub added_at: i64,
    #[serde(default)]
    pub trained_words: Vec<String>,
    #[serde(default)]
    pub page_url: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DownloadRecipeWire {
    #[serde(default)]
    pub files: Vec<serde_json::Value>,
    #[serde(default)]
    pub needs_token: Option<bool>,
}

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

    #[test]
    fn installed_catalog_entry_round_trips_unknown_nsfw_as_null() {
        let entry: InstalledCatalogEntry = serde_json::from_value(serde_json::json!({
            "id": "cv:1",
            "name": "Legacy install",
            "family": "flux",
            "kind": "lora",
            "nsfw": null
        }))
        .expect("null is a valid unknown safety classification");

        let wire = serde_json::to_value(entry).unwrap();
        assert_eq!(wire["nsfw"], serde_json::Value::Null);
    }
}