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
//! The first-run welcome: the art, the quick start, and the text around them.
//!
//! One module owns the copy so it has a single home and a single pinning
//! test, the way `docs/lookout/frames.txt` pins a rendered frame.

use std::path::Path;

use crate::cli::Format;
use crate::exit::ExitCode;
use crate::output::{OutputEnvelope, SCHEMA_VERSION, Streams};

/// The art, with `{version}` and `{home}` substituted at render time.
///
/// No `\` line-continuation before the first line: `\` plus a newline strips
/// the next line's leading whitespace, which would unindent the leftmost
/// sheep. A pinning test written the same way loses the same columns.
const ART: &str = "      ,-~-.     ,-~-.     ,-~-.
     ( o.o )   ( o.o )   ( o.o )       shep {version}
      `-^-'     `-^-'     `-^-'        flock at {home}
       \" \"       \" \"       \" \"
    /\\  /\\
   ( o  o )--,   the shepherd keeps them running
    `--..--'  |
      |  |    '
";

/// The four commands that get someone from nothing to a process that
/// survives a reboot.
///
/// The welcome holds this and [`DOGS`] and nothing else. `--home`, `fold`
/// and the whistle are `shep --help`'s job: a welcome that lists everything
/// teaches nothing.
const QUICK_START: &str = "\
Getting started
  shep start server.js    start it and keep it alive
  shep flock              see what's running
  shep bleats server      follow its output
  shep startup            bring it back after a reboot
";

/// Three dogs to install at once, as fixed text.
///
/// Never read from the index: a first run may have no cache. A test holds
/// each name equal to an `adopt_as` the index carries.
const DOGS: &str = "\
Dogs add what shep leaves out
  shep install log-rotate rotate logs as they grow
  shep install discord    watch and drive a flock from Discord
  shep install deploy     ship from a git branch
";

/// The line that closes the welcome.
const CLOSING: &str = "  shep welcome            show this again\n";

/// Renders the welcome for one flock.
///
/// `home` appears twice, once as the art's caption and once in the prose line
/// under it, so a `--home` render names the flock it is actually about rather
/// than advertising the default.
pub(crate) fn render(home: &Path) -> String {
    let home = home.display().to_string();
    let art = ART
        .replace("{version}", env!("CARGO_PKG_VERSION"))
        .replace("{home}", &home);
    format!(
        "{art}\nSet up {home}. Logs, pids and the shepherd's socket live here.\n\n{QUICK_START}\n{DOGS}\n{CLOSING}"
    )
}

/// The welcome as one JSON field, so `--format json` gets an envelope like
/// every other verb rather than silence.
///
/// Not a [`crate::output::Render`] impl: that trait is for tabular data. The
/// envelope is built from the same [`OutputEnvelope`] and [`SCHEMA_VERSION`]
/// as every other verb.
#[derive(Debug, serde::Serialize)]
struct WelcomeData {
    /// The rendered welcome, newlines and all.
    text: String,
}

/// Prints the welcome as a side effect of whichever command created the home.
///
/// Suppressed under `--format json` and when stderr is not a terminal. The
/// home is still created: suppression governs the output, never the side
/// effect. `stderr_is_terminal` is a parameter so a test writing into a `Vec`
/// can reach this branch.
pub(crate) fn on_first_run(streams: &mut Streams<'_>, home: &Path, stderr_is_terminal: bool) {
    if streams.fmt == Format::Json || !stderr_is_terminal {
        return;
    }
    let _ = write!(streams.err, "{}", render(home));
}

/// `shep welcome`: the same text, asked for by name.
///
/// stdout rather than stderr, and no terminal check, because here the welcome
/// *is* the command's output rather than a diagnostic. An explicit invocation
/// outranks the side-effect path, so a `shep welcome` that also happens to
/// create the home prints once, here.
pub(crate) fn welcome(streams: &mut Streams<'_>, home: &Path) -> ExitCode {
    let text = render(home);
    let wrote = match streams.fmt {
        Format::Table => write!(streams.out, "{text}"),
        Format::Json => {
            let envelope = OutputEnvelope {
                schema_version: SCHEMA_VERSION,
                command: "welcome",
                data: WelcomeData { text },
            };
            serde_json::to_writer(&mut *streams.out, &envelope)
                .map_err(std::io::Error::other)
                .and_then(|()| writeln!(streams.out))
        }
    };
    match wrote {
        Ok(()) => ExitCode::Success,
        Err(_) => ExitCode::Internal,
    }
}

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

    /// Pinned whole, the way `docs/lookout/frames.txt` pins a rendered frame.
    /// Art drifts silently otherwise, and the one thing a welcome cannot
    /// afford is to look unmaintained.
    #[test]
    fn the_welcome_renders_exactly_this() {
        let rendered = render(Path::new("/home/ada/.shep"));
        let expected = format!(
            "      ,-~-.     ,-~-.     ,-~-.
     ( o.o )   ( o.o )   ( o.o )       shep {version}
      `-^-'     `-^-'     `-^-'        flock at /home/ada/.shep
       \" \"       \" \"       \" \"
    /\\  /\\
   ( o  o )--,   the shepherd keeps them running
    `--..--'  |
      |  |    '

Set up /home/ada/.shep. Logs, pids and the shepherd's socket live here.

Getting started
  shep start server.js    start it and keep it alive
  shep flock              see what's running
  shep bleats server      follow its output
  shep startup            bring it back after a reboot

Dogs add what shep leaves out
  shep install log-rotate rotate logs as they grow
  shep install discord    watch and drive a flock from Discord
  shep install deploy     ship from a git branch

  shep welcome            show this again
",
            version = env!("CARGO_PKG_VERSION"),
        );
        assert_eq!(rendered, expected);
    }

    /// The path appears twice and both must follow `--home`, or a second
    /// flock's welcome would advertise the first flock's directory.
    #[test]
    fn the_home_path_is_substituted_everywhere_it_appears() {
        let rendered = render(Path::new("/srv/api"));
        assert_eq!(
            rendered.matches("/srv/api").count(),
            2,
            "both the art's caption and the prose line name the home:\n{rendered}"
        );
        assert!(
            !rendered.contains("~/.shep"),
            "no hardcoded default leaks through:\n{rendered}"
        );
    }

    /// No em dashes in copy a user reads. Doc comments may carry them; this
    /// may not.
    #[test]
    fn the_welcome_copy_has_no_em_dashes() {
        let rendered = render(Path::new("/home/ada/.shep"));
        assert!(
            !rendered.contains('\u{2014}'),
            "em dash in user-facing copy"
        );
        assert!(
            !rendered.contains('\u{2013}'),
            "en dash in user-facing copy"
        );
    }

    /// Runs `f` against buffered streams rendering as `fmt`, and hands back
    /// what each received.
    fn drain(fmt: Format, f: impl FnOnce(&mut Streams<'_>)) -> (String, String) {
        let mut out = Vec::new();
        let mut err = Vec::new();
        {
            let mut streams = Streams::bare(&mut out, &mut err, fmt);
            f(&mut streams);
        }
        (
            String::from_utf8(out).unwrap(),
            String::from_utf8(err).unwrap(),
        )
    }

    /// A fresh machine is exactly where a provisioning script runs first, so
    /// the side-effect welcome goes to stderr and leaves stdout for the
    /// command the operator actually ran.
    #[test]
    fn the_first_run_welcome_goes_to_stderr() {
        let (out, err) = drain(Format::Table, |s| {
            on_first_run(s, Path::new("/home/ada/.shep"), true);
        });
        assert!(out.is_empty(), "stdout must stay clean: {out}");
        assert!(
            err.contains("Getting started"),
            "stderr must carry it: {err}"
        );
    }

    /// `shep start server.js | jq` on a cold box must not find a sheep in its
    /// input, and neither must a `--format json` consumer.
    #[test]
    fn the_first_run_welcome_is_suppressed_for_json_and_for_pipes() {
        let (_, json) = drain(Format::Json, |s| {
            on_first_run(s, Path::new("/x"), true);
        });
        assert!(json.is_empty(), "--format json must suppress it: {json}");

        let (_, piped) = drain(Format::Table, |s| {
            on_first_run(s, Path::new("/x"), false);
        });
        assert!(
            piped.is_empty(),
            "a non-terminal stderr suppresses it: {piped}"
        );
    }

    /// Asked for by name it is the command's output, not a diagnostic, so it
    /// goes to stdout and no terminal check applies.
    #[test]
    fn the_welcome_verb_prints_to_stdout_even_when_piped() {
        let (out, err) = drain(Format::Table, |s| {
            welcome(s, Path::new("/home/ada/.shep"));
        });
        assert!(
            out.contains("Getting started"),
            "stdout must carry it: {out}"
        );
        assert!(err.is_empty(), "nothing belongs on stderr here: {err}");
    }

    /// Every other verb answers `--format json` with an envelope. So does
    /// this one, rather than printing nothing and looking broken.
    #[test]
    fn the_welcome_verb_answers_json_with_an_envelope() {
        let (out, _) = drain(Format::Json, |s| {
            welcome(s, Path::new("/home/ada/.shep"));
        });
        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
        assert_eq!(parsed["command"], "welcome");
        assert_eq!(parsed["schema_version"], SCHEMA_VERSION);
        assert!(
            parsed["data"]["text"]
                .as_str()
                .unwrap()
                .contains("Getting started"),
            "the envelope carries the text: {out}"
        );
    }

    /// A terminal is 80 columns until proven otherwise, and a welcome that
    /// wraps looks broken rather than charming.
    #[test]
    fn the_welcome_fits_an_eighty_column_terminal() {
        let rendered = render(Path::new("/home/ada/.shep"));
        for line in rendered.lines() {
            assert!(
                line.chars().count() <= 80,
                "line is {} columns: {line:?}",
                line.chars().count()
            );
        }
    }

    /// The welcome's dogs are fixed text, so the index is the only thing
    /// that can notice one is renamed or dropped.
    #[test]
    fn every_dog_the_welcome_names_is_in_the_index_under_that_adopt_as() {
        let index = Path::new(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../web/public/dogs.json"
        ));
        if !index.is_file() {
            return;
        }
        let bytes = std::fs::read(index).unwrap_or_else(|err| {
            panic!("web/public/dogs.json exists in the workspace but could not be read: {err}")
        });
        let index = crate::dog_index::parse_index(&bytes).expect("dogs.json parses");

        let rendered = render(Path::new("/home/ada/.shep"));
        let named: Vec<&str> = rendered
            .lines()
            .filter_map(|line| line.trim_start().strip_prefix("shep install "))
            .filter_map(|rest| rest.split_whitespace().next())
            .collect();
        assert_eq!(named.len(), 3, "the welcome names three dogs:\n{rendered}");
        for name in named {
            assert!(
                index.dogs.iter().any(|dog| dog.adopt_as == name),
                "no index row carries adopt_as {name:?}"
            );
        }
    }
}