shep 0.1.13

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.
//!
//! This is the `lookout::frames` pattern (12a), applied to prose instead of
//! pixels: 12a shipped two false captions in a generated artefact because
//! only one of them was pinned by a test, and a table of nine tools with a
//! "mutates" column is exactly the artefact that rots the same way.
//!
//! `#[cfg(test)]`-only, per the plan's task table: nothing outside this
//! module's own tests calls [`render`] or [`row_for`], and
//! `write_the_catalogue` is the one thing in this file that touches disk —
//! `#[ignore]`d, so an ordinary run never regenerates the checked-in copy
//! out from under a reviewer.

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

/// Renders the tool catalogue from `Whistle`'s two live routers.
///
/// Every row is read out of [`Whistle::router`] — the name, the description
/// and the annotations are the same values rmcp puts on the wire, not a
/// second list maintained by hand beside them. A tool added without a row is
/// impossible; a row claiming an annotation the tool does not carry fails
/// [`tests::every_rendered_row_agrees_with_the_router`].
///
/// The `gate` column is read the same way, not hand-typed: a name present in
/// the read-only router's own `list_all()` renders `always`, and a name
/// present only once both routers are summed renders `allow_control`. That
/// makes the column a second live fact about the routers rather than a guess
/// about which four tools the control router holds.
#[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 --bins --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 freshness and shape tests
/// can speak about columns rather than about substrings.
///
/// Defined here rather than left implicit: a test that reads a `.mutates`
/// field off a type nothing declared cannot be written at all, which is the
/// point — the fields present here are exactly the ones a test below needs.
#[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 if there is none — this is
/// test-only code and a missing row is the failure, not a `None` to handle.
///
/// # 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 --bins --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::*;

    /// The one test in this phase that must genuinely bite.
    ///
    /// `ToolAnnotations` is a wire-visible field an agent host reads to
    /// decide whether to ask a human first, so a mutating tool annotated
    /// `readOnlyHint: true` is a lie told to a machine. The expected values
    /// below are **hand-written from the plan's "The nine tools" section**
    /// and are deliberately independent of the source they check — flipping
    /// an annotation in `control.rs` reddens exactly one line here.
    ///
    /// The first draft's version of this test could not fail. It built the
    /// `mutates` column FROM `list_all()`'s annotations and then asserted
    /// the column matched those annotations — a comparison of a rendering
    /// against its own source, true by construction. Flipping `stop_sheep`
    /// to `read_only_hint = true` flipped both sides together and it stayed
    /// green.
    ///
    /// A tool added or removed also reddens this, on the length assertion,
    /// which is the intended cost: nine tools is a decision, and changing
    /// it should require editing a table a human reads.
    #[test]
    fn the_annotations_match_the_hand_written_table() {
        // (name, read_only, destructive, idempotent) — from the plan, by hand.
        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"
            );
        }
    }

    /// fails if a rendered row stops agreeing with the router it was
    /// rendered from. Weaker than the table above by design — this one IS
    /// generated on both sides, so it catches a broken renderer, not a
    /// wrong annotation.
    #[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
            );
        }
    }

    /// fails if the tool COUNT stops being nine.
    ///
    /// That is what this test pins, and the doc says only that because the
    /// other two things a first draft might claim for it are structurally
    /// impossible rather than tested: rows are GENERATED from
    /// `list_all()`, so "a tool added without a row cannot ship" is true by
    /// construction, and `rendered.contains(name)` is true for the same
    /// reason. Freshness of the checked-in copy is
    /// `the_checked_in_catalogue_is_current`'s job; correctness of the
    /// annotations is `the_annotations_match_the_hand_written_table`'s. A
    /// stale row for a REMOVED tool is the one extra thing the row count
    /// below still catches.
    #[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"
        );
    }

    /// fails if the injection warning leaves `tail_bleats`'s own
    /// description. A warning that lives only in a README is a warning no
    /// model ever reads: the description travels with the tool, in
    /// `tools/list`, into the context the log lines land in.
    #[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"));
    }

    /// fails if the checked-in catalogue drifts from what the code renders.
    /// `write_the_catalogue` is `#[ignore]`d, so nothing regenerates the
    /// file on an ordinary run — this is what makes the stale copy a
    /// failure instead of a surprise in a review.
    #[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 --bins --all-features -- --ignored write_the_catalogue"
        );
    }
}