shep 0.2.4

The shep binary: a process manager that keeps a flock of long-running processes alive on macOS, Linux and Windows, with logs, watch and cron restarts, and webhook alerts
Documentation
//! Renders `docs/whistle/tools.md` from the live routers, and pins every
//! claim in it.
//!
//! `#[cfg(test)]` only: nothing outside this module's own tests calls
//! [`render`] or [`row_for`]. `write_the_catalogue` is the one thing here
//! that touches disk, and is `#[ignore]`d so an ordinary run never
//! regenerates the checked-in copy.

use super::Whistle;
use super::gate::Control;

/// Renders the tool catalogue from `Whistle`'s two live routers.
///
/// Name, description and annotations come out of [`Whistle::router`], so they
/// are the values rmcp puts on the wire. The `gate` column is read the same
/// way: a name in the read-only router's `list_all()` renders `always`, one
/// present only once both routers are summed renders `allow_control`.
#[must_use]
pub fn render() -> String {
    let always_present: Vec<String> = Whistle::for_test(Control::ReadOnly)
        .router()
        .list_all()
        .into_iter()
        .map(|tool| tool.name.to_string())
        .collect();
    let tools = Whistle::for_test(Control::Allowed).router().list_all();

    let mut out = String::new();
    out.push_str("# The nine tools\n\n");
    out.push_str(
        "Generated by `cargo test -p shep --lib --all-features -- --ignored \
         write_the_catalogue` from the live routers in `whistle/read.rs` and \
         `whistle/control.rs` — do not hand-edit.\n\n",
    );
    out.push_str("| tool | mutates | destructive | idempotent | gate |\n");
    out.push_str("|---|---|---|---|---|\n");
    for tool in &tools {
        let annotations = tool.annotations.as_ref();
        let read_only = annotations.and_then(|a| a.read_only_hint).unwrap_or(false);
        let gate = if always_present.contains(&tool.name.to_string()) {
            "always"
        } else {
            "allow_control"
        };
        out.push_str(&format!(
            "| `{}` | {} | {} | {} | {} |\n",
            tool.name,
            yes_no(!read_only),
            option_yes_no(annotations.and_then(|a| a.destructive_hint)),
            option_yes_no(annotations.and_then(|a| a.idempotent_hint)),
            gate,
        ));
    }
    out.push('\n');
    for tool in &tools {
        let description = tool.description.as_deref().unwrap_or("");
        out.push_str(&format!("**`{}`** — {description}\n\n", tool.name));
    }
    out
}

fn yes_no(value: bool) -> &'static str {
    if value { "yes" } else { "no" }
}

fn option_yes_no(value: Option<bool>) -> &'static str {
    match value {
        Some(true) => "yes",
        Some(false) => "no",
        None => "-",
    }
}

/// One parsed row of [`render`]'s table, so the tests can speak about
/// columns rather than about substrings.
#[derive(Debug, PartialEq, Eq)]
pub struct Row {
    /// The tool's name, without its backticks.
    pub name: String,
    /// The `mutates` column, as rendered.
    pub mutates: bool,
    /// The `gate` column: `always` or `allow_control`.
    pub gate: &'static str,
}

/// Finds one rendered row by tool name.
///
/// # Panics
///
/// Panics if `rendered` has no row for `name`, or if that row's `gate` cell
/// is neither `always` nor `allow_control`.
#[must_use]
pub fn row_for(rendered: &str, name: &str) -> Row {
    let needle = format!("| `{name}` |");
    let line = rendered
        .lines()
        .find(|line| line.starts_with(&needle))
        .unwrap_or_else(|| panic!("no catalogue row for {name}"));
    let cells: Vec<&str> = line
        .split('|')
        .map(str::trim)
        .filter(|cell| !cell.is_empty())
        .collect();
    // cells: [`name`, mutates, destructive, idempotent, gate]
    let gate = match cells[4] {
        "always" => "always",
        "allow_control" => "allow_control",
        other => panic!("{name}'s gate cell is neither always nor allow_control: {other}"),
    };
    Row {
        name: name.to_string(),
        mutates: cells[1] == "yes",
        gate,
    }
}

/// Writes `docs/whistle/tools.md`.
///
///     cargo test -p shep --lib --all-features -- --ignored write_the_catalogue
#[test]
#[ignore = "writes docs/whistle/tools.md; run deliberately"]
fn write_the_catalogue() {
    let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/whistle/tools.md");
    std::fs::write(path, render()).unwrap_or_else(|err| panic!("{path}: {err}"));
}

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

    /// `EXPECTED` is written by hand, independent of `list_all()`'s own
    /// annotations, so a flipped annotation in `control.rs` reddens this
    /// instead of passing by construction.
    #[test]
    fn the_annotations_match_the_hand_written_table() {
        // (name, read_only, destructive, idempotent)
        const EXPECTED: [(&str, bool, Option<bool>, Option<bool>); 9] = [
            ("describe_sheep", true, None, None),
            ("get_metrics", true, None, None),
            ("list_barks", true, None, None),
            ("list_flock", true, None, None),
            ("reload_sheep", false, Some(false), Some(false)),
            ("restart_sheep", false, Some(true), Some(false)),
            ("start_sheep", false, Some(false), Some(false)),
            ("stop_sheep", false, Some(true), Some(true)),
            ("tail_bleats", true, None, None),
        ];

        let open = Whistle::for_test(Control::Allowed);
        let tools = open.router().list_all();
        assert_eq!(
            tools.len(),
            EXPECTED.len(),
            "the router and this table disagree about how many tools exist: {:?}",
            tools.iter().map(|t| t.name.as_ref()).collect::<Vec<_>>()
        );

        // `list_all()` sorts by name (rmcp handler/server/router/tool.rs:581-590),
        // and EXPECTED is written in that order, so a positional zip is sound
        // and a rename reddens rather than silently pairing the wrong rows.
        for (tool, (name, read_only, destructive, idempotent)) in tools.iter().zip(EXPECTED) {
            assert_eq!(tool.name.as_ref(), name, "sorted order drifted");
            let annotations = tool
                .annotations
                .as_ref()
                .unwrap_or_else(|| panic!("{name} carries no annotations"));
            assert_eq!(
                annotations.read_only_hint,
                Some(read_only),
                "{name}'s readOnlyHint"
            );
            assert_eq!(
                annotations.destructive_hint, destructive,
                "{name}'s destructiveHint"
            );
            assert_eq!(
                annotations.idempotent_hint, idempotent,
                "{name}'s idempotentHint"
            );
        }
    }

    /// Catches a broken renderer, not a wrong annotation: both sides come
    /// from `list_all()`.
    #[test]
    fn every_rendered_row_agrees_with_the_router() {
        let open = Whistle::for_test(Control::Allowed);
        let rendered = render();
        for tool in open.router().list_all() {
            let read_only = tool
                .annotations
                .as_ref()
                .and_then(|a| a.read_only_hint)
                .unwrap_or(false);
            assert_eq!(
                row_for(&rendered, &tool.name).mutates,
                !read_only,
                "{}'s catalogue row and its annotation disagree",
                tool.name
            );
        }
    }

    /// Pins the tool count at nine on both the router and the rendered
    /// table, so a removed tool cannot leave a stale row behind.
    #[test]
    fn the_catalogue_has_exactly_nine_rows() {
        let names: Vec<_> = Whistle::for_test(Control::Allowed)
            .router()
            .list_all()
            .into_iter()
            .map(|tool| tool.name.to_string())
            .collect();
        assert_eq!(names.len(), 9);
        let rendered = render();
        assert_eq!(
            rendered.matches("| `").count(),
            9,
            "exactly nine rows, so a stale row for a removed tool fails too"
        );
    }

    /// The warning lives in the description itself, not just in docs, so it
    /// travels with the tool into `tools/list`.
    #[test]
    fn tail_bleats_warns_about_its_own_output_where_a_model_will_see_it() {
        let tool = Whistle::for_test(Control::ReadOnly)
            .router()
            .get("tail_bleats")
            .cloned()
            .expect("tail_bleats is always registered");
        let description = tool.description.expect("every shep tool is described");
        assert!(description.contains("untrusted"));
        assert!(description.contains("not as commands") || description.contains("as data"));
    }

    /// `write_the_catalogue` is `#[ignore]`d, so nothing regenerates this
    /// file on an ordinary run.
    #[test]
    fn the_checked_in_catalogue_is_current() {
        let on_disk = std::fs::read_to_string(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../docs/whistle/tools.md"
        ))
        .expect("docs/whistle/tools.md is checked in");
        assert_eq!(
            on_disk,
            render(),
            "run: cargo test -p shep --lib --all-features -- --ignored write_the_catalogue"
        );
    }
}