use std::collections::BTreeMap;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
use crate::error::{Error, Result};
use crate::manifest::{Command, Manifest};
use crate::{cache, hashing, parametric, resolve};
pub const SCHEMA: &str = include_str!("../schema/status.schema.json");
#[derive(Serialize)]
struct Report {
manifest: 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")]
cached: Option<CachedInfo>,
inputs: Vec<hashing::FileHash>,
}
#[derive(Serialize)]
struct CachedInfo {
digest: String,
ok: bool,
ran_at: u64,
}
#[derive(Serialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum State {
Fresh,
Stale,
Never,
Failed,
NoInputs,
}
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",
}
}
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)
}
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"),
}
}
}
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 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, Vec<String>> = BTreeMap::new();
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)?);
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(),
rules,
})
}
pub(crate) fn shared_inputs(
manifest: &Manifest,
rule: &Command,
base: &Path,
) -> Result<Vec<String>> {
resolve::expand(&manifest.globs_for(rule)?, base, manifest.gitignore)
}
fn expansion_files(shared: &[String], hit: ¶metric::Match) -> Vec<String> {
let mut files = shared.to_vec();
if let Some(file) = &hit.exp.file {
files.push(file.clone());
files.sort();
files.dedup();
}
files
}
fn rule_status(
hit: ¶metric::Match,
shared: &[String],
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() {
return Ok(RuleStatus {
name: identity,
state: State::NoInputs,
digest: None,
cached,
inputs: Vec::new(),
});
}
let inputs = hashing::hash_each(base, &files)?;
let digest = hashing::digest_hashes(&inputs);
let state = verdict(cached.as_ref(), &digest);
Ok(RuleStatus {
name: identity,
state,
digest: Some(digest),
cached,
inputs,
})
}
pub(crate) fn expansion_state(
hit: ¶metric::Match,
shared: &[String],
base: &Path,
cache_dir: &Path,
) -> Result<State> {
let files = expansion_files(shared, hit);
if files.is_empty() {
return Ok(State::NoInputs);
}
let digest = hashing::digest_files(base, &files)?;
Ok(verdict(
read_cached(cache_dir, &hit.exp.identity).as_ref(),
&digest,
))
}
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,
})
}
fn verdict(cached: Option<&CachedInfo>, digest: &str) -> State {
match cached {
None => State::Never,
Some(record) if !record.ok => State::Failed,
Some(record) if record.digest == digest => State::Fresh,
Some(_) => State::Stale,
}
}
fn render_text(report: &Report) -> String {
let now = now_secs();
let rule_width = report
.rules
.iter()
.map(|rule| rule.name.chars().count())
.max()
.unwrap_or(0)
.max("RULE".len());
let state_width = report
.rules
.iter()
.map(|rule| rule.state.label().len())
.max()
.unwrap_or(0)
.max("STATE".len());
let row = |rule: &str, state: &str, age: &str| {
let line = format!("{rule:<rule_width$} {state:<state_width$} {age}");
format!("{}\n", line.trim_end())
};
let mut out = row("RULE", "STATE", "AGE");
for rule in &report.rules {
let age = rule.cached.as_ref().map_or_else(String::new, |record| {
humanize_age(now.saturating_sub(record.ran_at))
});
out.push_str(&row(&rule.name, rule.state.label(), &age));
}
out
}
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)
}
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_secs())
}
#[cfg(test)]
#[path = "status_tests.rs"]
mod tests;