shep 0.25.2

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
//! Which dogs can hold a table on a sheep, and one dog's per-sheep schema.
//!
//! Both run off the UI task. Listing is cheap: a built-in dog is this binary
//! and answers in-process, and an adopted one is only named. Its binary is
//! spawned with the schema flag when its row is opened, never before.
//! Nothing here is stored.

use std::path::PathBuf;
use std::time::Duration;

use serde_json::{Map, Value};
use shep_core::config::DogsConfig;
use shep_core::dogs::SHEEP_SCHEMA_KEY;
use shep_core::dogs::manifest::Serves;
use shep_core::paths::ShepPaths;

use super::pane::{ListedDog, SheepDogEntry, StandardSection};
use crate::commands::settings::dog_candidates;
use crate::commands::settings_write::{asked_schema_serving, builtin_schema_serving};
use crate::commands::shep_toml::ShepToml;
use crate::dog_settings::standard::Standard;

/// One entry per dog `paths.daemon_config` names or shep builds in. A
/// built-in answers with its per-sheep schema where it publishes one; an
/// adopted dog is named and left unasked, for [`probe_sheep_dog`].
///
/// A `shep.toml` that cannot be read lists no dog, which leaves every table
/// the sheep carries read-only: without a schema nothing is edited.
pub(super) fn list_sheep_dogs(paths: &ShepPaths) -> Vec<ListedDog> {
    let Ok(doc) = ShepToml::read_only(&paths.daemon_config) else {
        return Vec::new();
    };
    dog_candidates(&doc)
        .into_iter()
        .map(|dog| match builtin_schema_serving(&dog.name) {
            Some(answer) => ListedDog {
                entry: entry_of(paths, dog.name, dog.adopted_path, Some(answer)),
                asked: true,
            },
            None => ListedDog {
                asked: dog.adopted_path.is_none(),
                entry: entry_of(paths, dog.name, dog.adopted_path, None),
            },
        })
        .collect()
}

/// `dog`'s per-sheep schema, asked of its binary with the schema flag. The
/// binary has `budget` to answer, `VERSION_BUDGET` outside a test. A dog
/// that stays silent, or answers something unreadable, has none.
pub(super) fn probe_sheep_dog(
    paths: &ShepPaths,
    dog: &str,
    adopted_path: Option<PathBuf>,
    budget: Duration,
) -> SheepDogEntry {
    let answer = builtin_schema_serving(dog).or_else(|| {
        adopted_path
            .as_deref()
            .and_then(|path| asked_schema_serving(path, &paths.home, dog, budget))
    });
    entry_of(paths, dog.to_owned(), adopted_path, answer)
}

/// What a dog answered, as the entry the tab lists.
///
/// A dog that follows the settings standard has one schema: the entry holds
/// it without the dog-wide fields, and the section every sheep shares. Any
/// other dog's entry holds its `x-shep-sheep` schema, as before.
fn entry_of(
    paths: &ShepPaths,
    name: String,
    adopted_path: Option<PathBuf>,
    answer: Option<(Value, Serves)>,
) -> SheepDogEntry {
    let Some((root, serves)) = answer else {
        return SheepDogEntry {
            name,
            adopted_path,
            schema: None,
            standard: None,
        };
    };
    match Standard::recognise(&root, serves) {
        Some(standard) => SheepDogEntry {
            schema: Some(standard.sheep_form(&root)),
            standard: Some(StandardSection {
                serves,
                shared: standard.shared(&section_of(paths, &name)),
            }),
            name,
            adopted_path,
        },
        None => SheepDogEntry {
            schema: sheep_schema(&root),
            standard: None,
            name,
            adopted_path,
        },
    }
}

/// `name`'s `[<dog>]` table in `dogs.toml`. Empty when the file, or the
/// table, is missing or does not parse: a pane then shows nothing inherited.
fn section_of(paths: &ShepPaths, name: &str) -> Map<String, Value> {
    let source = std::fs::read_to_string(&paths.dogs_config).ok();
    DogsConfig::load(source.as_deref())
        .ok()
        .and_then(|config| config.dog.get(name).cloned())
        .and_then(|table| serde_json::to_value(table).ok())
        .and_then(|value| value.as_object().cloned())
        .unwrap_or_default()
}

/// `pub(crate)`: `shep set` validates a dog's table on a sheep against the
/// same schema the pane edits its rows from (issue #1096).
///
/// `root`'s `x-shep-sheep` value, with `root`'s own `$defs` attached so each
/// `$ref` in it resolves where it points. [`None`] when there is no such key,
/// or when it is not a schema object. A definition the sheep schema carries
/// itself wins over the root's of the same name.
pub(crate) fn sheep_schema(root: &Value) -> Option<Value> {
    let mut sheep = root.get(SHEEP_SCHEMA_KEY)?.as_object()?.clone();
    if let Some(root_defs) = root.get("$defs").and_then(Value::as_object) {
        let defs = sheep
            .entry("$defs")
            .or_insert_with(|| Value::Object(Map::new()));
        if let Value::Object(defs) = defs {
            for (name, def) in root_defs {
                defs.entry(name.clone()).or_insert_with(|| def.clone());
            }
        }
    }
    Some(Value::Object(sheep))
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::*;

    #[test]
    fn the_sheep_schema_carries_the_roots_defs() {
        let root = json!({
            "properties": { "poll": { "type": "string" } },
            "x-shep-sheep": { "$ref": "#/$defs/ProjectSettings" },
            "$defs": { "ProjectSettings": { "type": "object" } },
        });
        assert_eq!(
            sheep_schema(&root),
            Some(json!({
                "$ref": "#/$defs/ProjectSettings",
                "$defs": { "ProjectSettings": { "type": "object" } },
            }))
        );
    }

    #[test]
    fn a_schema_without_the_key_or_with_a_bare_value_has_none() {
        assert_eq!(sheep_schema(&json!({ "properties": {} })), None);
        assert_eq!(sheep_schema(&json!({ "x-shep-sheep": true })), None);
    }

    #[test]
    fn the_sheep_schemas_own_definition_wins() {
        let root = json!({
            "x-shep-sheep": { "$defs": { "Hours": { "type": "string" } } },
            "$defs": { "Hours": { "type": "object" }, "Other": {} },
        });
        let sheep = sheep_schema(&root).expect("a sheep schema");
        assert_eq!(sheep["$defs"]["Hours"], json!({ "type": "string" }));
        assert_eq!(sheep["$defs"]["Other"], json!({}));
    }

    #[test]
    fn a_shep_toml_that_does_not_parse_lists_no_dog() {
        let dir = tempfile::tempdir().unwrap();
        let config = dir.path().join("shep.toml");
        std::fs::write(&config, "[daemon\n").unwrap();
        assert!(list_sheep_dogs(&ShepPaths::at(dir.path().to_path_buf())).is_empty());
    }

    /// An adopted dog is listed unasked: its binary is not run to list it.
    /// A built-in answers in-process, with no sheep schema when it
    /// publishes none.
    #[cfg(unix)]
    #[test]
    fn listing_names_an_adopted_dog_and_never_runs_it() {
        let dir = tempfile::tempdir().unwrap();
        let marker = dir.path().join("ran");
        let script = dir.path().join("shep-jobs");
        std::fs::write(&script, format!("#!/bin/sh\ntouch {}\n", marker.display())).unwrap();
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
        }
        let config = dir.path().join("shep.toml");
        std::fs::write(
            &config,
            format!(
                "[daemon.adopted_dogs]\njobs = {:?}\n",
                script.display().to_string()
            ),
        )
        .unwrap();

        let dogs = list_sheep_dogs(&ShepPaths::at(dir.path().to_path_buf()));
        let jobs = dogs
            .iter()
            .find(|dog| dog.entry.name == "jobs")
            .expect("jobs");
        assert!(!jobs.asked);
        assert_eq!(jobs.entry.adopted_path.as_deref(), Some(script.as_path()));
        assert_eq!(jobs.entry.schema, None);
        let bark = dogs
            .iter()
            .find(|dog| dog.entry.name == "bark")
            .expect("a built-in");
        assert!(bark.asked);
        assert_eq!(bark.entry.schema, None);
        assert!(!marker.exists(), "listing ran the adopted binary");
    }

    /// An adopted dog is its own binary, asked with the schema flag.
    #[cfg(unix)]
    #[test]
    fn an_adopted_dog_is_asked_and_a_silent_one_has_no_schema() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().unwrap();
        let script = dir.path().join("shep-jobs");
        let schema = json!({
            "properties": {},
            "x-shep-sheep": { "$ref": "#/$defs/Settings" },
            "$defs": { "Settings": { "type": "object" } },
        });
        std::fs::write(
            &script,
            format!("#!/bin/sh\n[ \"$1\" = --schema ] && echo '{schema}'\nexit 0\n"),
        )
        .unwrap();
        let quiet = dir.path().join("shep-quiet");
        std::fs::write(&quiet, "#!/bin/sh\nexit 0\n").unwrap();
        let broken = dir.path().join("shep-broken");
        std::fs::write(&broken, "#!/bin/sh\necho '{not json'\nexit 0\n").unwrap();
        for bin in [&script, &quiet, &broken] {
            std::fs::set_permissions(bin, std::fs::Permissions::from_mode(0o755)).unwrap();
        }

        // Thirty seconds, not the real one: a first spawn of a new script
        // on a loaded machine outruns a second, and this is not the bound.
        let budget = Duration::from_secs(30);
        let jobs = probe_sheep_dog(
            &ShepPaths::at(dir.path().to_path_buf()),
            "jobs",
            Some(script.clone()),
            budget,
        );
        assert_eq!(jobs.adopted_path.as_deref(), Some(script.as_path()));
        assert_eq!(
            jobs.schema,
            Some(json!({
                "$ref": "#/$defs/Settings",
                "$defs": { "Settings": { "type": "object" } },
            }))
        );
        for (name, bin) in [("quiet", quiet), ("broken", broken)] {
            let dog = probe_sheep_dog(
                &ShepPaths::at(dir.path().to_path_buf()),
                name,
                Some(bin),
                budget,
            );
            assert_eq!(dog.schema, None, "{name} answers no schema");
        }
    }

    /// A dog that marks a field dog-wide is listed with its one schema
    /// minus that field, and with the section every sheep shares.
    #[cfg(unix)]
    #[test]
    fn a_standard_dog_is_probed_into_its_sheep_form_and_its_shared_section() {
        let dir = tempfile::tempdir().unwrap();
        crate::commands::standard_dog_testing::adopt_jobs(dir.path(), Serves::OptedIn);
        std::fs::write(
            dir.path().join("dogs.toml"),
            "[jobs]\npoll = \"5m\"\nmodel = \"sonnet\"\n[other]\nmodel = \"x\"\n",
        )
        .unwrap();
        let paths = ShepPaths::at(dir.path().to_path_buf());
        let script = dir.path().join("shep-jobs");

        let jobs = probe_sheep_dog(&paths, "jobs", Some(script), Duration::from_secs(30));

        let schema = jobs.schema.expect("a schema");
        assert!(schema["properties"].get("poll").is_none(), "{schema}");
        assert!(schema["properties"].get("model").is_some(), "{schema}");
        let section = jobs.standard.expect("a standard dog");
        assert_eq!(section.serves, Serves::OptedIn);
        assert_eq!(Value::Object(section.shared), json!({ "model": "sonnet" }));
    }

    /// A dog with its own `x-shep-sheep` schema is not the standard, whatever
    /// its manifest says it serves.
    #[cfg(unix)]
    #[test]
    fn a_dog_with_a_second_sheep_schema_is_probed_as_before() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().unwrap();
        let script = dir.path().join("shep-jobs");
        let schema = json!({
            "properties": { "poll": { "x-shep-dog-wide": true } },
            "x-shep-sheep": { "type": "object" },
        });
        std::fs::write(
            &script,
            format!("#!/bin/sh\n[ \"$1\" = --schema ] && echo '{schema}'\nexit 0\n"),
        )
        .unwrap();
        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
        let paths = ShepPaths::at(dir.path().to_path_buf());

        let jobs = probe_sheep_dog(&paths, "jobs", Some(script), Duration::from_secs(30));

        assert_eq!(jobs.schema, Some(json!({ "type": "object" })));
        assert_eq!(jobs.standard, None);
    }

    /// A manifest with no `serves` field and nothing marked dog-wide is not
    /// the standard: the probe lists the dog's `x-shep-sheep` schema and no
    /// section, which is what it listed before the standard existed.
    #[cfg(unix)]
    #[test]
    fn a_dog_whose_manifest_lacks_the_field_is_probed_as_before() {
        let dir = tempfile::tempdir().unwrap();
        crate::commands::standard_dog_testing::adopt_jobs_without_the_standard(dir.path(), true);
        let paths = ShepPaths::at(dir.path().to_path_buf());
        let script = dir.path().join("shep-jobs");

        let jobs = probe_sheep_dog(&paths, "jobs", Some(script), Duration::from_secs(30));

        assert_eq!(jobs.standard, None);
        assert_eq!(
            jobs.schema,
            Some(json!({
                "type": "object",
                "properties": { "model": { "type": "string" }, "limit": { "type": "integer" } },
            }))
        );
    }

    /// The same manifest with no sheep schema either: nothing in it makes
    /// the dog the standard, so it lists with no schema, as it did before.
    #[cfg(unix)]
    #[test]
    fn a_dog_with_plain_settings_and_no_field_has_no_sheep_schema() {
        let dir = tempfile::tempdir().unwrap();
        crate::commands::standard_dog_testing::adopt_jobs_without_the_standard(dir.path(), false);
        let paths = ShepPaths::at(dir.path().to_path_buf());
        let script = dir.path().join("shep-jobs");

        let jobs = probe_sheep_dog(&paths, "jobs", Some(script), Duration::from_secs(30));

        assert_eq!(jobs.standard, None);
        assert_eq!(jobs.schema, None);
    }
}