polyc-agent-catalog 2026.8.3

The shipped Agent catalog (manifests/base/agents.yaml) as one typed artifact.
//! The shipped `Agent` catalog as one owning artifact.
//!
//! `manifests/base/agents.yaml` is the authored, kubectl-applied source of
//! truth for the deployed `Agent` custom resources (see that file's own
//! header, and `docs/reference/declarative-catalog.md`). Before this crate,
//! every consumer that needed the shipped catalog's *content* — the
//! installer, and three separate test suites — addressed the tree path by
//! hand and re-parsed it, and one of those consumers (the harness test
//! support) even carried its own partial copy of the `Agent` schema. A
//! catalog edit could then pass two of those readers and silently drift
//! against the third.
//!
//! This crate embeds the shipped file at compile time and exposes it through
//! one typed decode path, [`shipped`], into `polyc_controller::Agent` — the
//! same type the apiserver and the reconciler use. Every other Rust
//! consumer of the catalog's *content* goes through this crate; the
//! installer's raw `kubectl apply -f` (which applies the file rather than
//! parsing it) instead names the tree path via [`MANIFEST_PATH`].
//!
//! Parsing itself does not deep-validate references (a dangling
//! `toolsEnabled` or `canHandoffTo` entry is a reconciler `.status` concern,
//! not a decode concern) — see `polyc_controller::agent`'s module doc.

use polyc_controller::Agent;
use serde::Deserialize as _;

/// The catalog's tree path, relative to the repository root.
///
/// The single string every path-addressing consumer (the CLI installer, and
/// any test asserting on the applied path) should use instead of a locally
/// hand-copied literal. This is a path for tools that *apply* the manifest
/// (`kubectl apply -f`); this crate's own [`shipped`] reads the file's
/// *content* via a compiled-in [`include_str!`], not this constant, so a
/// decode never depends on the process's working directory.
pub const MANIFEST_PATH: &str = "manifests/base/agents.yaml";

/// The shipped catalog's raw YAML, embedded at compile time.
///
/// `agents.yaml` at this crate's root is a symlink to
/// `manifests/base/agents.yaml`, so the repository keeps one authored file.
/// The include must stay INSIDE the crate root: `cargo publish` packages only
/// the crate directory, and it follows the symlink to copy the content. An
/// include that reached `../../../manifests/...` built in the workspace and
/// failed to build from the published tarball (the 2026.8.3 release run).
const RAW: &str = include_str!("../agents.yaml");

/// A failure decoding the shipped catalog.
#[derive(Debug, thiserror::Error)]
pub enum CatalogError {
    /// A document in the catalog did not parse as an `Agent`.
    #[error("parse agent document {index} in {MANIFEST_PATH}: {source}")]
    Parse {
        /// Zero-based index of the offending YAML document.
        index: usize,
        /// The underlying decode failure.
        #[source]
        source: serde_yaml_ng::Error,
    },
    /// An `Agent` document is missing `metadata.name` — every shipped agent
    /// must be addressable by name.
    #[error("agent document {index} in {MANIFEST_PATH} has no metadata.name")]
    MissingName {
        /// Zero-based index of the offending YAML document.
        index: usize,
    },
}

/// Decode the shipped `Agent` catalog into its typed form.
///
/// # Errors
///
/// Returns [`CatalogError`] when a document fails to parse as an `Agent`, or
/// when a parsed `Agent` has no `metadata.name`.
pub fn shipped() -> Result<Vec<Agent>, CatalogError> {
    serde_yaml_ng::Deserializer::from_str(RAW)
        .enumerate()
        .map(|(index, doc)| {
            let agent =
                Agent::deserialize(doc).map_err(|source| CatalogError::Parse { index, source })?;
            if agent.metadata.name.is_none() {
                return Err(CatalogError::MissingName { index });
            }
            Ok(agent)
        })
        .collect()
}

/// The shipped agent named `name`, if the catalog ships one.
#[must_use]
pub fn find<'a>(agents: &'a [Agent], name: &str) -> Option<&'a Agent> {
    agents
        .iter()
        .find(|agent| agent.metadata.name.as_deref() == Some(name))
}

/// `agent`'s `metadata.name`.
///
/// [`shipped`] already refuses to decode any document missing
/// `metadata.name` (see [`CatalogError::MissingName`]), so every `Agent` that
/// reaches a caller through this crate is named — callers that hold an
/// `Agent` produced by [`shipped`] should use this instead of re-deriving
/// their own fallback (a silent `unwrap_or_default` would mask that
/// invariant weakening instead of failing loudly).
///
/// # Panics
///
/// Panics if `agent.metadata.name` is `None` — meaning `agent` did not come
/// from [`shipped`], since that path guarantees a name.
#[must_use]
pub fn name(agent: &Agent) -> &str {
    agent
        .metadata
        .name
        .as_deref()
        .expect("polyc_agent_catalog::shipped guarantees every agent is named")
}

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

    #[test]
    fn the_shipped_catalog_parses_and_every_agent_is_named() {
        let agents = shipped().expect("the shipped catalog must parse");
        assert!(!agents.is_empty(), "the shipped catalog must ship agents");
        for agent in &agents {
            assert!(agent.metadata.name.is_some());
        }
    }

    #[test]
    fn the_shipped_catalog_ships_the_deployment_default_assistant() {
        let agents = shipped().expect("the shipped catalog must parse");
        assert!(
            find(&agents, "assistant").is_some(),
            "manifests/base/agents.yaml must ship an agent named assistant \
             (POLYCHROME_DEFAULT_AGENT)"
        );
    }
}