use std::collections::BTreeMap;
use std::path::Path;
use serde::Serialize;
use crate::clock::Clock;
use crate::error::{Error, Result};
use crate::manifest::{Command, Manifest};
use crate::{cache, hashing, outputs, parametric, probe, resolve};
pub const SCHEMA: &str = include_str!("../schema/status.schema.json");
#[derive(Serialize)]
struct Report {
manifest: String,
#[serde(skip)]
now: Clock,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
probes: BTreeMap<String, String>,
rules: Vec<RuleStatus>,
}
#[derive(Serialize)]
struct RuleStatus {
name: String,
state: State,
#[serde(skip_serializing_if = "Option::is_none")]
digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
missing_output: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cached: Option<CachedInfo>,
inputs: Vec<hashing::FileHash>,
}
#[derive(Serialize)]
struct CachedInfo {
digest: String,
ok: bool,
ran_at: u64,
#[serde(skip_serializing_if = "Vec::is_empty")]
outputs: Vec<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
probes: BTreeMap<String, String>,
}
#[derive(Serialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum State {
Fresh,
Stale,
Never,
Failed,
NoInputs,
MissingOutput,
}
pub(crate) struct Assessment {
pub(crate) state: State,
pub(crate) missing_output: Option<String>,
pub(crate) changed_probe: Option<String>,
}
impl State {
pub(crate) const fn label(self) -> &'static str {
match self {
Self::Fresh => "fresh",
Self::Stale => "stale",
Self::Never => "never",
Self::Failed => "failed",
Self::NoInputs => "no-inputs",
Self::MissingOutput => "missing-output",
}
}
pub(crate) const fn is_fresh(self) -> bool {
matches!(self, Self::Fresh)
}
pub(crate) const fn is_remediable(self) -> bool {
matches!(
self,
Self::Stale | Self::Never | Self::Failed | Self::MissingOutput
)
}
pub(crate) const fn reason(self) -> Option<&'static str> {
match self {
Self::Fresh => None,
Self::Stale => Some("inputs changed since it last passed"),
Self::Never => Some("never run"),
Self::Failed => Some("last run failed"),
Self::NoInputs => Some("resolved no input files"),
Self::MissingOutput => Some("a declared output is missing"),
}
}
}
impl Assessment {
pub(crate) fn reason(&self) -> Option<String> {
if let Some(path) = self.missing_output.as_deref() {
return Some(format!("declared output `{path}` is missing"));
}
if let Some(name) = self.changed_probe.as_deref() {
return Some(format!("probe `{name}` changed since it last passed"));
}
self.state.reason().map(str::to_owned)
}
}
pub fn report(cwd: &Path, tags: &[String]) -> Result<String> {
let report = collect(cwd, tags)?;
if report.rules.is_empty() {
return Ok(format!("no rules defined in {}\n", report.manifest));
}
Ok(render_text(&report))
}
pub fn report_json(cwd: &Path, tags: &[String]) -> Result<String> {
let report = collect(cwd, tags)?;
let text = serde_json::to_string_pretty(&report)
.map_err(|err| Error::Internal(format!("serializing status json: {err}")))?;
Ok(format!("{text}\n"))
}
fn collect(cwd: &Path, tags: &[String]) -> Result<Report> {
let now = Clock::resolve()?;
let located = Manifest::locate(cwd)?;
let manifest = &located.manifest;
let base = located.root.as_path();
let cache_dir = base.join(&manifest.cache_dir);
let mut matches = Vec::with_capacity(manifest.commands.len());
let mut shared_by_rule: BTreeMap<String, Shared> = BTreeMap::new();
let mut probes = probe::Resolver::new(manifest, base);
for rule in &manifest.commands {
if !tags.is_empty() && !tags.iter().all(|tag| rule.tags.contains(tag)) {
continue;
}
shared_by_rule.insert(
rule.name.clone(),
shared_inputs(manifest, rule, base, &mut probes)?,
);
matches.extend(parametric::expand_rule(manifest, base, rule)?);
}
parametric::detect_collision(&matches)?;
let mut rules = Vec::with_capacity(matches.len());
for hit in &matches {
let shared = shared_by_rule
.get(hit.rule.name.as_str())
.expect("shared inputs resolved for every kept rule");
rules.push(rule_status(hit, shared, base, &cache_dir)?);
}
Ok(Report {
manifest: located.path.display().to_string(),
now,
probes: probes.resolved().clone(),
rules,
})
}
pub(crate) struct Shared {
files: Vec<String>,
probes: BTreeMap<String, String>,
}
pub(crate) fn shared_inputs(
manifest: &Manifest,
rule: &Command,
base: &Path,
probes: &mut probe::Resolver,
) -> Result<Shared> {
Ok(Shared {
files: resolve::expand_groups(&manifest.glob_groups(rule)?, base)?,
probes: probes.for_rule(rule)?,
})
}
fn expansion_files(shared: &Shared, hit: ¶metric::Match) -> Vec<String> {
let mut files = shared.files.clone();
if let Some(file) = &hit.exp.file {
files.push(file.clone());
files.sort();
files.dedup();
}
files
}
fn rule_status(
hit: ¶metric::Match,
shared: &Shared,
base: &Path,
cache_dir: &Path,
) -> Result<RuleStatus> {
let identity = hit.exp.identity.clone();
let files = expansion_files(shared, hit);
let cached = read_cached(cache_dir, &identity);
if files.is_empty() && shared.probes.is_empty() {
return Ok(RuleStatus {
name: identity,
state: State::NoInputs,
digest: None,
missing_output: None,
cached,
inputs: Vec::new(),
});
}
let inputs = hashing::hash_each(base, &files)?;
let digest = hashing::digest_all(&inputs, &shared.probes);
let missing = outputs::first_missing(base, &hit.rule.outputs);
let assessed = verdict(cached.as_ref(), &digest, missing, &shared.probes);
Ok(RuleStatus {
name: identity,
state: assessed.state,
digest: Some(digest),
missing_output: assessed.missing_output,
cached,
inputs,
})
}
pub(crate) fn expansion_state(
hit: ¶metric::Match,
shared: &Shared,
base: &Path,
cache_dir: &Path,
) -> Result<Assessment> {
let files = expansion_files(shared, hit);
if files.is_empty() && shared.probes.is_empty() {
return Ok(Assessment {
state: State::NoInputs,
missing_output: None,
changed_probe: None,
});
}
let digest = hashing::digest_with(base, &files, &shared.probes)?;
Ok(verdict(
read_cached(cache_dir, &hit.exp.identity).as_ref(),
&digest,
outputs::first_missing(base, &hit.rule.outputs),
&shared.probes,
))
}
fn read_cached(cache_dir: &Path, name: &str) -> Option<CachedInfo> {
cache::read(cache_dir, name).map(|cached| CachedInfo {
digest: cached.digest,
ok: cached.ok,
ran_at: cached.ran_at,
outputs: cached.outputs,
probes: cached.probes,
})
}
fn verdict(
cached: Option<&CachedInfo>,
digest: &str,
missing: Option<String>,
probes: &BTreeMap<String, String>,
) -> Assessment {
let state = match cached {
None => State::Never,
Some(record) if !record.ok => State::Failed,
Some(_) if missing.is_some() => State::MissingOutput,
Some(record) if record.digest == digest => State::Fresh,
Some(_) => State::Stale,
};
let missing_output = match state {
State::MissingOutput => missing,
_ => None,
};
let changed_probe = match state {
State::Stale => cached.and_then(|record| probe::first_changed(&record.probes, probes)),
_ => None,
};
Assessment {
state,
missing_output,
changed_probe,
}
}
fn render_text(report: &Report) -> String {
let now = report.now.now_secs();
let ages: Vec<String> = report
.rules
.iter()
.map(|rule| {
rule.cached.as_ref().map_or_else(String::new, |record| {
humanize_age(now.saturating_sub(record.ran_at))
})
})
.collect();
let voided = report
.rules
.iter()
.any(|rule| rule.missing_output.is_some());
let rule_width = column_width(
report.rules.iter().map(|rule| rule.name.chars().count()),
"RULE",
);
let state_width = column_width(
report.rules.iter().map(|rule| rule.state.label().len()),
"STATE",
);
let age_width = if voided {
column_width(ages.iter().map(|age| age.chars().count()), "AGE")
} else {
0
};
let row = |rule: &str, state: &str, age: &str, missing: &str| {
let line =
format!("{rule:<rule_width$} {state:<state_width$} {age:<age_width$} {missing}");
format!("{}\n", line.trim_end())
};
let mut out = row(
"RULE",
"STATE",
"AGE",
if voided { "MISSING OUTPUT" } else { "" },
);
for (rule, age) in report.rules.iter().zip(&ages) {
out.push_str(&row(
&rule.name,
rule.state.label(),
age,
rule.missing_output.as_deref().unwrap_or(""),
));
}
out
}
fn column_width(cells: impl Iterator<Item = usize>, header: &str) -> usize {
cells.max().unwrap_or(0).max(header.len())
}
fn humanize_age(secs: u64) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = 60 * MINUTE;
const DAY: u64 = 24 * HOUR;
if secs < MINUTE {
format!("{secs}s ago")
} else if secs < HOUR {
format!("{}m ago", secs / MINUTE)
} else if secs < DAY {
format!("{}h ago", secs / HOUR)
} else {
format!("{}d ago", secs / DAY)
}
}
#[cfg(test)]
#[path = "status_tests.rs"]
mod tests;