use std::collections::{HashMap, HashSet};
use std::io;
use std::path::Path;
use futures_util::FutureExt;
use serde::Serialize;
use shep_client::{Client, EventStream, Lagged};
use shep_core::protocol::{BusEvent, LogStream, ProcessInfo};
use shep_core::selector::ProcessSelector;
use crate::cli::{BleatsArgs, Format};
use crate::commands::rpc::typed_request_error;
use crate::commands::selector::parse_selector;
use crate::exit::ExitCode;
use crate::output::{self, Streams, write_outcome};
use crate::remote::interrupt_within_grace;
mod source;
use source::{FileTail, LogSource, stream_label};
pub(crate) use shep_core::logtail::read_tail;
#[cfg(test)]
pub(crate) use shep_core::logtail::TAIL_WINDOW_BYTES;
#[derive(Debug, Serialize)]
struct BleatLine<'a> {
schema_version: u32,
id: u32,
name: &'a str,
stream: &'static str,
instance: Option<u32>,
line: &'a str,
}
async fn resolve_names(
client: &Client,
streams: &mut Streams<'_>,
) -> Result<HashMap<u32, ProcessInfo>, ExitCode> {
match client.list_flock().await {
Ok(procs) => Ok(procs.into_iter().map(|p| (p.id, p)).collect()),
Err(err) => Err(typed_request_error(streams, &err)),
}
}
async fn subscribe(client: &Client, streams: &mut Streams<'_>) -> Result<EventStream, ExitCode> {
let topics = vec!["log.*".to_string(), "daemon.*".to_string()];
match client.subscribe(topics).await {
Ok(stream) => Ok(stream),
Err(err) => {
let code = ExitCode::from(&err);
Err(streams.fail(code, &err.to_string()))
}
}
}
fn resolved_name(cache: &HashMap<u32, ProcessInfo>, id: u32) -> String {
cache
.get(&id)
.map_or_else(|| id.to_string(), |info| info.name.clone())
}
fn resolved_instance(
cache: &HashMap<u32, ProcessInfo>,
counts: &HashMap<String, usize>,
id: u32,
) -> Option<u32> {
let info = cache.get(&id)?;
if counts.get(info.name.as_str()).copied().unwrap_or(0) > 1 {
info.instance
} else {
None
}
}
fn selector_allows(selector: &ProcessSelector, cache: &HashMap<u32, ProcessInfo>, id: u32) -> bool {
match cache.get(&id) {
Some(info) => selector.matches(&info.name, info.id, info.fold.as_deref(), info.instance),
None => selector.matches("", id, None, None),
}
}
fn write_line(
streams: &mut Streams<'_>,
id: u32,
name: &str,
instance: Option<u32>,
stream: &'static str,
line: &str,
) -> io::Result<()> {
match streams.fmt {
Format::Json => {
let payload = BleatLine {
schema_version: output::SCHEMA_VERSION,
id,
name,
instance,
stream,
line,
};
serde_json::to_writer(&mut *streams.out, &payload)?;
writeln!(streams.out)
}
Format::Table => {
let clean = output::log_line::sanitize_log_line(line);
let text = output::log_line::paint_default(&clean, stream, streams.style);
match instance {
Some(slot) => writeln!(streams.out, "{name}:{slot} | {text}"),
None => writeln!(streams.out, "{name} | {text}"),
}
}
}
}
fn instance_count(cache: &HashMap<u32, ProcessInfo>, name: &str) -> usize {
cache.values().filter(|info| info.name == name).count()
}
fn instance_counts(cache: &HashMap<u32, ProcessInfo>) -> HashMap<String, usize> {
let mut counts: HashMap<String, usize> = HashMap::with_capacity(cache.len());
for info in cache.values() {
*counts.entry(info.name.clone()).or_insert(0) += 1;
}
counts
}
fn write_notice(streams: &mut Streams<'_>, quiet: bool, code: &str, message: &str) {
if quiet {
return;
}
streams.aside(code, message);
}
pub(crate) fn missing_log_note(stream: &str, path: &Path) -> String {
if !path.is_relative() {
return format!("no {stream} log at {} yet", path.display());
}
let tried = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
format!(
"no {stream} log at {}; {stream}_file is relative, so the shepherd \
resolves it against its own directory instead",
tried.display()
)
}
fn path_unknown_notice(
streams: &mut Streams<'_>,
quiet: bool,
seen_notices: &mut HashSet<String>,
name: &str,
stream: LogStream,
) {
let message = format!(
"{name}: the daemon did not report a {} log path",
stream_label(stream)
);
if seen_notices.insert(message.clone()) {
write_notice(streams, quiet, "log_path_unknown", &message);
}
}
async fn tail_log_files(
streams: &mut Streams<'_>,
quiet: bool,
cache: &HashMap<u32, ProcessInfo>,
selector: &ProcessSelector,
args: &BleatsArgs,
source: LogSource<'_>,
) -> Result<ExitCode, ExitCode> {
let mut matched: Vec<&ProcessInfo> = cache
.values()
.filter(|info| selector.matches(&info.name, info.id, info.fold.as_deref(), info.instance))
.collect();
matched.sort_unstable_by(|a, b| {
(a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
});
let mut failure = false;
let mut seen_paths: HashSet<String> = HashSet::new();
let mut seen_notices: HashSet<String> = HashSet::new();
let mut path_owners: HashMap<(&'static str, String), usize> = HashMap::new();
for info in cache.values() {
for (stream_name, path) in [
("out", info.out_file.as_deref()),
("err", info.err_file.as_deref()),
] {
if let Some(path) = path {
*path_owners
.entry((stream_name, path.to_string()))
.or_insert(0) += 1;
}
}
}
for info in matched {
let name = &info.name;
let wanted: [(LogStream, Option<&str>, bool); 2] = [
(LogStream::Out, info.out_file.as_deref(), !args.err),
(LogStream::Err, info.err_file.as_deref(), !args.out),
];
for (stream, path, show) in wanted {
if !show {
continue;
}
let stream_name = stream_label(stream);
let Some(path) = path else {
path_unknown_notice(streams, quiet, &mut seen_notices, name, stream);
continue;
};
if !seen_paths.insert(path.to_string()) {
continue;
}
let shared = path_owners
.get(&(stream_name, path.to_string()))
.copied()
.unwrap_or(0)
> 1;
let label_instance = if !shared && instance_count(cache, name) > 1 {
info.instance
} else {
None
};
match source.read(info, stream, path, args.lines).await {
Ok(FileTail::Lines(lines)) => {
for line in lines {
if let Err(write_err) =
write_line(streams, info.id, name, label_instance, stream_name, &line)
{
let code = write_outcome(Err(write_err));
let _ = streams.out.flush();
return Ok(code);
}
}
}
Ok(FileTail::NoPath) => {
path_unknown_notice(streams, quiet, &mut seen_notices, name, stream);
}
Ok(FileTail::Missing(note)) => {
write_notice(streams, quiet, "log_missing", &format!("{name}: {note}"));
}
Ok(FileTail::Unreadable(message)) => {
failure = true;
write_notice(streams, quiet, "log_unreadable", &message);
}
Err(err) => {
let _ = streams.out.flush();
let code = source.stop(streams, &err);
let _ = streams.err.flush();
return Err(code);
}
}
}
}
let _ = streams.out.flush();
Ok(if failure {
ExitCode::Failure
} else {
ExitCode::Success
})
}
fn handle_event(
streams: &mut Streams<'_>,
quiet: bool,
cache: &HashMap<u32, ProcessInfo>,
counts: &HashMap<String, usize>,
selector: &ProcessSelector,
args: &BleatsArgs,
event: BusEvent,
) -> io::Result<()> {
match event {
BusEvent::LogOut { id, line } => {
if !args.err && selector_allows(selector, cache, id) {
let name = resolved_name(cache, id);
let instance = resolved_instance(cache, counts, id);
write_line(streams, id, &name, instance, "out", &line)?;
}
Ok(())
}
BusEvent::LogErr { id, line } => {
if !args.out && selector_allows(selector, cache, id) {
let name = resolved_name(cache, id);
let instance = resolved_instance(cache, counts, id);
write_line(streams, id, &name, instance, "err", &line)?;
}
Ok(())
}
BusEvent::Dropped { count } => {
write_notice(
streams,
quiet,
"dropped",
&format!("the daemon dropped {count} events (its own queue overflowed)"),
);
Ok(())
}
BusEvent::DaemonShutdown => {
write_notice(
streams,
quiet,
"daemon_shutdown",
"the daemon is shutting down",
);
Ok(())
}
_ => Ok(()),
}
}
pub async fn bleats(
client: &Client,
streams: &mut Streams<'_>,
quiet: bool,
args: &BleatsArgs,
) -> ExitCode {
bleats_with_signal(
client,
streams,
quiet,
args,
tokio::signal::ctrl_c().map(|_| ()),
)
.await
}
pub async fn bleats_on_host(
client: &Client,
streams: &mut Streams<'_>,
quiet: bool,
host: &str,
args: &BleatsArgs,
) -> ExitCode {
bleats_over(
client,
streams,
quiet,
args,
Some(host),
tokio::signal::ctrl_c().map(|_| ()),
)
.await
}
pub async fn bleats_with_signal(
client: &Client,
streams: &mut Streams<'_>,
quiet: bool,
args: &BleatsArgs,
interrupt: impl std::future::Future<Output = ()> + Send,
) -> ExitCode {
bleats_over(client, streams, quiet, args, None, interrupt).await
}
async fn bleats_over(
client: &Client,
streams: &mut Streams<'_>,
quiet: bool,
args: &BleatsArgs,
host: Option<&str>,
interrupt: impl std::future::Future<Output = ()> + Send,
) -> ExitCode {
let selector = match parse_selector(streams, &args.selector) {
Ok(selector) => selector,
Err(code) => return code,
};
let cache = match resolve_names(client, streams).await {
Ok(cache) => cache,
Err(code) => return code,
};
let source = match host {
Some(name) => LogSource::Host { client, name },
None => LogSource::Local,
};
if args.no_follow {
let (Ok(code) | Err(code)) =
tail_log_files(streams, quiet, &cache, &selector, args, source).await;
return code;
}
let counts = instance_counts(&cache);
if args.lines > 0
&& let Err(code) = tail_log_files(streams, quiet, &cache, &selector, args, source).await
{
return code;
}
let mut stream = match subscribe(client, streams).await {
Ok(stream) => stream,
Err(code) => return code,
};
tokio::pin!(interrupt);
loop {
tokio::select! {
biased;
item = stream.next() => {
match item {
Some(Ok(event)) => {
if let Err(write_err) =
handle_event(streams, quiet, &cache, &counts, &selector, args, event)
{
let code = write_outcome(Err(write_err));
let _ = streams.out.flush();
return code;
}
}
Some(Err(Lagged { count })) => {
write_notice(
streams,
quiet,
"lagged",
&format!("{count} events dropped locally (lagged)"),
);
}
None => {
let _ = streams.out.flush();
if host.is_some() && interrupt_within_grace(&mut interrupt).await {
return ExitCode::Success;
}
return ExitCode::DaemonUnreachable;
}
}
}
() = &mut interrupt => {
let _ = streams.out.flush();
return ExitCode::Success;
}
}
}
}
#[cfg(test)]
mod tests;