use std::collections::BTreeMap;
use std::path::Path;
use std::process::{Command as Process, Output, Stdio};
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::hashing;
use crate::manifest::{Command, Manifest, Scope};
const SHELL: &str = "sh";
const STDERR_CAP: usize = 2000;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Probe {
pub run: String,
#[serde(default)]
pub allow_empty: bool,
}
pub fn validate(probes: &BTreeMap<String, Probe>, scopes: &BTreeMap<String, Scope>) -> Result<()> {
match probes.keys().find(|name| scopes.contains_key(*name)) {
Some(name) => Err(Error::NameCollision { name: name.clone() }),
None => Ok(()),
}
}
#[must_use]
pub fn first_changed(
recorded: &BTreeMap<String, String>,
current: &BTreeMap<String, String>,
) -> Option<String> {
current
.iter()
.find(|(name, hash)| recorded.get(name.as_str()) != Some(*hash))
.map(|(name, _)| name.clone())
}
pub struct Resolver<'a> {
manifest: &'a Manifest,
base: &'a Path,
seen: BTreeMap<String, String>,
}
impl<'a> Resolver<'a> {
#[must_use]
pub const fn new(manifest: &'a Manifest, base: &'a Path) -> Self {
Self {
manifest,
base,
seen: BTreeMap::new(),
}
}
pub fn for_rule(&mut self, rule: &Command) -> Result<BTreeMap<String, String>> {
let mut named = BTreeMap::new();
for name in &rule.inputs {
let Some(probe) = self.manifest.probes.get(name) else {
continue;
};
let hash = self.memoized(name, probe)?;
named.insert(name.clone(), hash);
}
Ok(named)
}
fn memoized(&mut self, name: &str, probe: &Probe) -> Result<String> {
if let Some(known) = self.seen.get(name) {
return Ok(known.clone());
}
let fresh = digest(name, probe, self.base)?;
self.seen.insert(name.to_owned(), fresh.clone());
Ok(fresh)
}
#[must_use]
pub const fn resolved(&self) -> &BTreeMap<String, String> {
&self.seen
}
}
fn digest(name: &str, probe: &Probe, base: &Path) -> Result<String> {
let output = capture(name, probe, base)?;
if !output.status.success() {
return Err(Error::ProbeFailed {
name: name.to_owned(),
run: probe.run.clone(),
code: output.status.code().unwrap_or(1),
stderr: excerpt(&output.stderr),
});
}
if !probe.allow_empty && output.stdout.iter().all(u8::is_ascii_whitespace) {
return Err(Error::ProbeEmpty {
name: name.to_owned(),
run: probe.run.clone(),
});
}
Ok(hashing::hash_bytes(&output.stdout))
}
fn capture(name: &str, probe: &Probe, base: &Path) -> Result<Output> {
Process::new(SHELL)
.arg("-c")
.arg(&probe.run)
.current_dir(base)
.stdin(Stdio::null())
.output()
.map_err(|source| Error::ProbeSpawn {
name: name.to_owned(),
run: probe.run.clone(),
source,
})
}
fn excerpt(stderr: &[u8]) -> String {
let text = String::from_utf8_lossy(stderr);
let trimmed = text.trim();
if trimmed.is_empty() {
return "(none)".to_owned();
}
match trimmed.char_indices().nth(STDERR_CAP) {
Some((cut, _)) => format!("{}…", &trimmed[..cut]),
None => trimmed.to_owned(),
}
}
#[cfg(test)]
#[path = "probe_tests.rs"]
mod tests;