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::provenance::Provenance;
use crate::{cache, hashing, outputs, parametric, probe, resolve, selection};
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>,
#[serde(skip)]
empty_note: String,
rules: Vec<RuleStatus>,
}
#[derive(Serialize)]
struct RuleStatus {
name: String,
source: 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!("{}\n", report.empty_note));
}
Ok(table::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");
let source = located
.provenance
.commands
.get(hit.rule.name.as_str())
.map(|path| Provenance::display(path, base))
.expect("provenance recorded for every declared command");
rules.push(rule_status(hit, shared, base, &cache_dir, source)?);
}
let manifest_path = located.path.display().to_string();
let empty_note = if rules.is_empty() {
let kept: Vec<&str> = shared_by_rule.keys().map(String::as_str).collect();
selection::empty_note(manifest, &manifest_path, tags, &kept)
} else {
String::new()
};
Ok(Report {
empty_note,
manifest: manifest_path,
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,
source: String,
) -> 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,
source,
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,
source,
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,
}
}
#[path = "status_table.rs"]
mod table;
#[cfg(test)]
#[path = "status_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "status_source_tests.rs"]
mod source_tests;