use std::path::Path;
use crate::cli::Format;
use crate::exit::ExitCode;
use crate::output::{OutputEnvelope, SCHEMA_VERSION, Streams};
const ART: &str = " ,-~-. ,-~-. ,-~-.
( o.o ) ( o.o ) ( o.o ) shep {version}
`-^-' `-^-' `-^-' flock at {home}
\" \" \" \" \" \"
/\\ /\\
( o o )--, the shepherd keeps them running
`--..--' |
| | '
";
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
";
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
";
const CLOSING: &str = " shep welcome show this again\n";
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}"
)
}
#[derive(Debug, serde::Serialize)]
struct WelcomeData {
text: String,
}
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));
}
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::*;
#[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);
}
#[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}"
);
}
#[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"
);
}
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(),
)
}
#[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}"
);
}
#[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}"
);
}
#[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}");
}
#[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}"
);
}
#[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()
);
}
}
#[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:?}"
);
}
}
}