use anyhow::{Context, Result};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::SystemTime;
use super::{OnceTool, PlannedOutput, add_planned_products, require_planned_output};
use crate::config::{StandardConfig, checksum_fields_of, output_config_hash, resolve_extra_inputs};
use crate::file_index::FileIndex;
use crate::graph::{BuildGraph, Product};
use crate::processor::{
Processor, ProcessorBase, check_command_output, format_command, log_command, run_command,
};
const MANIFEST_VERSION: u32 = 1;
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct MassGeneratorConfig {
#[serde(default)]
pub predict_command: String,
#[serde(default)]
pub predict_args: Vec<String>,
#[serde(default)]
pub output_dirs: Vec<String>,
#[serde(default)]
pub loose_manifest: bool,
#[serde(flatten)]
pub standard: StandardConfig,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Manifest {
version: u32,
outputs: Vec<PlannedOutput>,
}
pub struct MassGeneratorProcessor {
config: MassGeneratorConfig,
manifest: Mutex<Option<Arc<Vec<PlannedOutput>>>>,
tool_run: OnceTool,
}
impl MassGeneratorProcessor {
pub fn new(config: MassGeneratorConfig) -> Result<Self> {
for dir in &config.output_dirs {
if let Some(why) = plain_relative_path_error(Path::new(dir)) {
anyhow::bail!("output_dirs entry '{dir}' {why}");
}
}
let std = &config.standard;
let dirs = std.src_dirs.as_deref().is_some_and(|d| !d.is_empty());
let exts = std.src_extensions.as_deref().is_some_and(|e| !e.is_empty());
if dirs && !exts {
anyhow::bail!(
"src_dirs names the plan's input directories but src_extensions is empty, so no \
file under them would key the plan cache; list the extensions the plan reads \
(e.g. src_extensions = [\".md\", \".html\"])"
);
}
Ok(Self {
config,
manifest: Mutex::new(None),
tool_run: OnceTool::new(),
})
}
fn output_dirs(&self) -> Vec<PathBuf> {
self.config.output_dirs.iter().map(PathBuf::from).collect()
}
fn predict_command(&self) -> Command {
let mut cmd = Command::new(&self.config.predict_command);
cmd.args(&self.config.predict_args);
cmd
}
fn declares_plan_inputs(&self) -> bool {
let std = &self.config.standard;
[&std.src_dirs, &std.src_files]
.into_iter()
.any(|v| v.as_deref().is_some_and(|v| !v.is_empty()))
}
fn manifest(
&self,
instance_name: &str,
file_index: &FileIndex,
) -> Result<Arc<Vec<PlannedOutput>>> {
let mut guard = self.manifest.lock();
if let Some(entries) = guard.as_ref() {
return Ok(Arc::clone(entries));
}
if self.config.predict_command.is_empty() {
anyhow::bail!("'predict_command' is not set for processor '{instance_name}'");
}
let cache = if self.declares_plan_inputs() {
let inputs = file_index.scan(&self.config.standard, true);
Some((
plan_cache_path(instance_name),
self.plan_key(instance_name, &inputs)?,
))
} else {
None
};
let mut cmd = self.predict_command();
let raw = match &cache {
Some((path, key)) => {
if let Some(raw) = read_plan_cache(path, key)? {
raw
} else {
let raw = run_predict(&mut cmd, instance_name)?;
parse_manifest(raw.as_bytes(), &self.output_dirs())
.with_context(|| invalid_manifest_context(instance_name, &cmd))?;
write_plan_cache(path, key, &raw)?;
raw
}
}
None => run_predict(&mut cmd, instance_name)?,
};
let entries = parse_manifest(raw.as_bytes(), &self.output_dirs())
.with_context(|| invalid_manifest_context(instance_name, &cmd))?;
let entries = Arc::new(entries);
*guard = Some(Arc::clone(&entries));
Ok(entries)
}
fn plan_key(&self, instance_name: &str, inputs: &[PathBuf]) -> Result<String> {
let config = serde_json::to_string(&self.config)
.context("Failed to serialize mass_generator config for the plan key")?;
let mut parts: Vec<String> = vec![
env!("CARGO_PKG_VERSION").to_string(),
instance_name.to_string(),
config,
];
let mut sorted: Vec<&PathBuf> = inputs.iter().collect();
sorted.sort();
for path in sorted {
let data = std::fs::read(path)
.with_context(|| format!("Failed to read plan input {}", path.display()))?;
parts.push(path.display().to_string());
parts.push(crate::checksum::bytes_checksum(&data));
}
let refs: Vec<&str> = parts.iter().map(String::as_str).collect();
Ok(crate::checksum::hash_parts(&refs))
}
fn run_tool(
&self,
ctx: &crate::build_context::BuildContext,
instance_name: &str,
) -> Result<()> {
let entries =
self.manifest.lock().clone().with_context(|| {
format!("[{instance_name}] executed before its plan was loaded")
})?;
let predicted: BTreeSet<&Path> = entries.iter().map(|e| e.path.as_path()).collect();
for path in &predicted {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(anyhow::Error::from(e)
.context(format!("Failed to remove stale output {}", path.display())));
}
}
}
let output_dirs = self.output_dirs();
let before = snapshot_mtimes(&output_dirs)?;
let command = self.config.standard.require_command(instance_name)?;
let mut cmd = Command::new(command);
cmd.args(&self.config.standard.args);
let output = run_command(ctx, &cmd)?;
check_command_output(
&output,
format_args!("[{instance_name}] {}", format_command(&cmd)),
)?;
let after = snapshot_mtimes(&output_dirs)?;
let foreign = |path: &Path| ctx.is_declared_output(path);
let mismatches = plan_mismatches(&predicted, &before, &after, &foreign);
if !mismatches.is_empty() {
let msg = format!(
"[{instance_name}] the files {} wrote do not match the plan from {}:\n {}",
format_command(&cmd),
format_command(&self.predict_command()),
mismatches.join("\n ")
);
if self.config.loose_manifest {
crate::output::warn(&format!("{msg}\n (loose_manifest = true: continuing)"));
} else {
anyhow::bail!("{msg}");
}
}
Ok(())
}
}
const PLAN_CACHE_DIR: &str = ".rsconstruct/mass_generator";
fn plan_cache_path(instance_name: &str) -> PathBuf {
Path::new(PLAN_CACHE_DIR).join(format!("{instance_name}.json"))
}
#[derive(Serialize, Deserialize)]
struct PlanCache {
key: String,
manifest: String,
}
fn read_plan_cache(path: &Path, key: &str) -> Result<Option<String>> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(anyhow::Error::from(e)
.context(format!("Failed to read plan cache {}", path.display())));
}
};
let cache: PlanCache = serde_json::from_str(&text).with_context(|| {
format!(
"Plan cache {} is corrupt; delete it and rerun",
path.display()
)
})?;
Ok((cache.key == key).then_some(cache.manifest))
}
fn write_plan_cache(path: &Path, key: &str, manifest: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let cache = PlanCache {
key: key.to_string(),
manifest: manifest.to_string(),
};
let text = serde_json::to_string(&cache).context("Failed to serialize the plan cache")?;
std::fs::write(path, text)
.with_context(|| format!("Failed to write plan cache {}", path.display()))
}
fn run_predict(cmd: &mut Command, instance_name: &str) -> Result<String> {
log_command(cmd);
let output = cmd
.output()
.with_context(|| format!("Failed to spawn predict_command: {}", format_command(cmd)))?;
check_command_output(
&output,
format_args!("[{instance_name}] predict_command {}", format_command(cmd)),
)?;
String::from_utf8(output.stdout).with_context(|| {
format!(
"[{instance_name}] predict_command {} printed non-UTF-8 output",
format_command(cmd)
)
})
}
fn invalid_manifest_context(instance_name: &str, cmd: &Command) -> String {
format!(
"[{instance_name}] predict_command {} printed an invalid manifest",
format_command(cmd)
)
}
fn plain_relative_path_error(path: &Path) -> Option<&'static str> {
let text = path.to_string_lossy();
if text.is_empty() {
return Some("is empty");
}
if text.starts_with('/') {
return Some("is absolute");
}
for piece in text.split('/') {
match piece {
"" => return Some("contains an empty component (`//` or a trailing `/`)"),
"." => return Some("contains a `.` component"),
".." => return Some("contains a `..` component"),
_ => {}
}
}
None
}
fn parse_manifest(json: &[u8], output_dirs: &[PathBuf]) -> Result<Vec<PlannedOutput>> {
let manifest: Manifest = serde_json::from_slice(json).context("manifest is not valid JSON")?;
if manifest.version != MANIFEST_VERSION {
anyhow::bail!(
"manifest version {} is not supported (this rsconstruct understands version {MANIFEST_VERSION})",
manifest.version
);
}
let mut entries = manifest.outputs;
for entry in &entries {
let path = &entry.path;
if let Some(why) = plain_relative_path_error(path) {
anyhow::bail!("manifest output path '{}' {why}", path.display());
}
if !output_dirs.iter().any(|dir| path.starts_with(dir)) {
anyhow::bail!(
"manifest output path '{}' is outside every output_dirs entry ({})",
path.display(),
output_dirs
.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
if entry.sources.is_empty() {
anyhow::bail!(
"manifest output '{}' lists no sources; every output must name at least one \
file whose content determines it (the tool's own config file, if nothing else)",
path.display()
);
}
for source in &entry.sources {
if let Some(why) = plain_relative_path_error(source) {
anyhow::bail!(
"manifest output '{}' has source '{}' which {why}",
path.display(),
source.display()
);
}
}
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
for pair in entries.windows(2) {
if pair[0].path == pair[1].path {
anyhow::bail!(
"manifest lists output path '{}' twice",
pair[0].path.display()
);
}
}
Ok(entries)
}
fn snapshot_mtimes(dirs: &[PathBuf]) -> Result<BTreeMap<PathBuf, SystemTime>> {
let mut snapshot = BTreeMap::new();
for dir in dirs {
for path in crate::object_store::walk_files(dir) {
let mtime = std::fs::metadata(&path)
.and_then(|m| m.modified())
.with_context(|| format!("Failed to stat {}", path.display()))?;
snapshot.insert(path, mtime);
}
}
Ok(snapshot)
}
fn plan_mismatches(
predicted: &BTreeSet<&Path>,
before: &BTreeMap<PathBuf, SystemTime>,
after: &BTreeMap<PathBuf, SystemTime>,
foreign: &dyn Fn(&Path) -> bool,
) -> Vec<String> {
let mut mismatches = Vec::new();
for path in predicted {
if !after.contains_key(*path) {
mismatches.push(format!(
"missing: {} (predicted but not produced)",
path.display()
));
}
}
for (path, mtime) in after {
if predicted.contains(path.as_path()) || foreign(path) {
continue;
}
if before.get(path) != Some(mtime) {
mismatches.push(format!(
"unexpected: {} (written but not in the plan)",
path.display()
));
}
}
mismatches
}
impl Processor for MassGeneratorProcessor {
fn scan_config(&self) -> &StandardConfig {
&self.config.standard
}
fn auto_detect(&self, _file_index: &FileIndex) -> bool {
!self.config.standard.command.is_empty()
&& !self.config.predict_command.is_empty()
&& !self.config.output_dirs.is_empty()
}
fn required_tools(&self) -> Vec<String> {
let mut tools = Vec::new();
for tool in [&self.config.standard.command, &self.config.predict_command]
.into_iter()
.chain(self.config.standard.required_tools.iter())
{
if !tool.is_empty() && !tools.contains(tool) {
tools.push(tool.clone());
}
}
tools
}
fn discover(
&self,
graph: &mut BuildGraph,
file_index: &FileIndex,
instance_name: &str,
) -> Result<()> {
let entries = self.manifest(instance_name, file_index)?;
let config_hash = output_config_hash(&self.config, &checksum_fields_of(instance_name));
let extra = resolve_extra_inputs(&self.config.standard.dep_inputs)?;
add_planned_products(graph, &entries, instance_name, &config_hash, &extra)?;
Ok(())
}
fn execute(&self, ctx: &crate::build_context::BuildContext, product: &Product) -> Result<()> {
let instance_name = &product.processor;
self.tool_run.run(&self.config.standard.command, || {
self.run_tool(ctx, instance_name)
})?;
require_planned_output(product, &self.config.standard.command)
}
fn replaces_own_outputs(&self) -> bool {
true
}
fn clean(&self, product: &Product, verbose: bool) -> Result<usize> {
ProcessorBase::clean(product, &product.processor, verbose)
}
fn config_json(&self) -> Option<String> {
ProcessorBase::config_json(&self.config)
}
}
fn plugin_create(toml: &toml::Value) -> anyhow::Result<Box<dyn crate::processor::Processor>> {
crate::registries::deserialize_and_try_create(toml, |cfg| {
MassGeneratorProcessor::new(cfg).map(|p| Box::new(p) as Box<dyn Processor>)
})
}
inventory::submit! {
crate::registries::ProcessorPlugin {
version: 1,
name: "generic",
processor_type: crate::processor::ProcessorType::MassGenerator,
create: plugin_create,
fields: &[
crate::config::FieldSpec { name: "command", ty: crate::config::FieldType::String,
affects_output: true, required: true,
doc: "The tool's build command; runs at most once per build, when any predicted file is dirty" },
crate::config::FieldSpec { name: "predict_command", ty: crate::config::FieldType::String,
affects_output: true, required: true,
doc: "The tool's plan command; prints the JSON manifest of predicted outputs to stdout" },
crate::config::FieldSpec { name: "predict_args", ty: crate::config::FieldType::StringArray,
affects_output: true, required: false,
doc: "Arguments passed to predict_command" },
crate::config::FieldSpec { name: "output_dirs", ty: crate::config::FieldType::StringArray,
affects_output: true, required: true,
doc: "Directories the tool writes into; every manifest path must fall inside one" },
crate::config::FieldSpec { name: "loose_manifest", ty: crate::config::FieldType::Bool,
affects_output: false, required: false,
doc: "Report plan-vs-build mismatches as warnings instead of failing the build" },
],
omit_standard_fields: &["formats", "dep_auto", "output_dir", "batch"],
scan_defaults: Some(crate::config::ScanDefaultsData { src_dirs: &[], src_extensions: &[], src_exclude_dirs: &[] }),
defaults: Some(crate::config::ProcessorDefaults::EMPTY),
defconfig_json: crate::registries::default_config_json::<MassGeneratorConfig>,
keywords: &["mass", "generator", "predict", "manifest", "site", "transparent", "creator"],
description: "Run a tool that enumerates its outputs in advance; one cached product per predicted file",
is_native: false,
is_rust: false,
can_fix: false,
supports_batch: false,
max_jobs_cap: Some(1),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn dirs(list: &[&str]) -> Vec<PathBuf> {
list.iter().map(PathBuf::from).collect()
}
#[test]
fn parse_sorts_entries_by_path() {
let json = br#"{"version":1,"outputs":[
{"path":"_site/b.html","sources":["docs/b.md"]},
{"path":"_site/a.html","sources":["docs/a.md","templates/t.html"]}
]}"#;
let entries = parse_manifest(json, &dirs(&["_site"])).unwrap();
let paths: Vec<_> = entries
.iter()
.map(|e| e.path.display().to_string())
.collect();
assert_eq!(paths, vec!["_site/a.html", "_site/b.html"]);
assert_eq!(entries[0].sources, dirs(&["docs/a.md", "templates/t.html"]));
}
#[test]
fn parse_accepts_an_empty_plan() {
let entries = parse_manifest(br#"{"version":1,"outputs":[]}"#, &dirs(&["_site"])).unwrap();
assert_eq!(entries.len(), 0);
}
#[test]
fn parse_rejects_malformed_manifests() {
let cases: &[(&[u8], &str)] = &[
(b"not json", "not valid JSON"),
(
br#"{"version":2,"outputs":[]}"#,
"version 2 is not supported",
),
(br#"{"version":1,"outputs":[],"extra":1}"#, "unknown field"),
(
br#"{"version":1,"outputs":[{"path":"elsewhere/x.html","sources":["a.md"]}]}"#,
"'elsewhere/x.html' is outside every output_dirs entry (_site)",
),
(
br#"{"version":1,"outputs":[{"path":"/_site/x.html","sources":["a.md"]}]}"#,
"'/_site/x.html' is absolute",
),
(
br#"{"version":1,"outputs":[{"path":"_site/../x.html","sources":["a.md"]}]}"#,
"contains a `..` component",
),
(
br#"{"version":1,"outputs":[{"path":"_site/./x.html","sources":["a.md"]}]}"#,
"contains a `.` component",
),
(
br#"{"version":1,"outputs":[{"path":"_site/x.html","sources":[]}]}"#,
"'_site/x.html' lists no sources",
),
(
br#"{"version":1,"outputs":[{"path":"_site/x.html","sources":["../a.md"]}]}"#,
"source '../a.md' which contains a `..` component",
),
(
br#"{"version":1,"outputs":[
{"path":"_site/x.html","sources":["a.md"]},
{"path":"_site/x.html","sources":["b.md"]}]}"#,
"lists output path '_site/x.html' twice",
),
];
for (json, expected) in cases {
let err = parse_manifest(json, &dirs(&["_site"]))
.err()
.unwrap_or_else(|| panic!("accepted: {}", String::from_utf8_lossy(json)));
let msg = format!("{err:#}");
assert!(msg.contains(expected), "expected '{expected}' in: {msg}");
}
}
#[test]
fn output_dir_match_is_by_component() {
let json = br#"{"version":1,"outputs":[{"path":"_site2/x.html","sources":["a.md"]}]}"#;
assert!(parse_manifest(json, &dirs(&["_site"])).is_err());
}
fn snapshot(entries: &[(&str, u64)]) -> BTreeMap<PathBuf, SystemTime> {
entries
.iter()
.map(|(p, secs)| {
(
PathBuf::from(p),
SystemTime::UNIX_EPOCH + Duration::from_secs(*secs),
)
})
.collect()
}
#[test]
fn plan_mismatch_decision_table() {
let predicted_paths = [Path::new("_site/a.html"), Path::new("_site/b.html")];
let predicted: BTreeSet<&Path> = predicted_paths.into_iter().collect();
let before = snapshot(&[
("_site/stale.html", 10),
("_site/CNAME", 10),
("_site/about.html", 10),
("_site/a.html", 10),
]);
let after = snapshot(&[
("_site/stale.html", 10), ("_site/CNAME", 10), ("_site/about.html", 20), ("_site/a.html", 20), ("_site/extra.html", 20), ("_site/stray.txt", 20), ]);
let foreign = |p: &Path| p == Path::new("_site/about.html");
let mismatches = plan_mismatches(&predicted, &before, &after, &foreign);
assert_eq!(
mismatches,
vec![
"missing: _site/b.html (predicted but not produced)",
"unexpected: _site/extra.html (written but not in the plan)",
"unexpected: _site/stray.txt (written but not in the plan)",
]
);
}
#[test]
fn plan_mismatch_flags_rewritten_stranger() {
let predicted_paths = [Path::new("_site/a.html")];
let predicted: BTreeSet<&Path> = predicted_paths.into_iter().collect();
let before = snapshot(&[("_site/old.html", 10), ("_site/a.html", 10)]);
let after = snapshot(&[("_site/old.html", 11), ("_site/a.html", 11)]);
let mismatches = plan_mismatches(&predicted, &before, &after, &|_| false);
assert_eq!(
mismatches,
vec!["unexpected: _site/old.html (written but not in the plan)"]
);
}
#[test]
fn constructor_rejects_bad_output_dirs() {
let cfg = MassGeneratorConfig {
output_dirs: vec!["../out".to_string()],
..Default::default()
};
let err = MassGeneratorProcessor::new(cfg)
.err()
.expect("`..` must be rejected");
assert!(
format!("{err:#}").contains("output_dirs entry '../out' contains a `..` component")
);
}
#[test]
fn plan_inputs_are_declared_by_src_dirs_or_src_files() {
let proc = |dirs: Option<Vec<&str>>, files: Option<Vec<&str>>| {
let mut cfg = MassGeneratorConfig::default();
cfg.standard.src_dirs = dirs.map(|v| v.into_iter().map(String::from).collect());
cfg.standard.src_files = files.map(|v| v.into_iter().map(String::from).collect());
MassGeneratorProcessor::new(cfg).unwrap()
};
assert!(!proc(None, None).declares_plan_inputs());
assert!(!proc(Some(vec![]), Some(vec![])).declares_plan_inputs());
assert!(proc(Some(vec![]), Some(vec!["site.toml"])).declares_plan_inputs());
assert!(proc(None, Some(vec!["site.toml"])).declares_plan_inputs());
}
#[test]
fn plan_cache_round_trips_and_rejects_other_keys_and_garbage() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("sub/site.json");
assert_eq!(
read_plan_cache(&path, "k1").unwrap(),
None,
"missing file is a miss"
);
write_plan_cache(&path, "k1", "{\"version\":1,\"outputs\":[]}").unwrap();
assert_eq!(
read_plan_cache(&path, "k1").unwrap().as_deref(),
Some("{\"version\":1,\"outputs\":[]}")
);
assert_eq!(
read_plan_cache(&path, "k2").unwrap(),
None,
"other key is a miss"
);
std::fs::write(&path, "not json").unwrap();
let err = read_plan_cache(&path, "k1").unwrap_err();
assert!(
format!("{err:#}").contains("is corrupt; delete it"),
"{err:#}"
);
}
#[test]
fn required_tools_lists_both_commands_once() {
let mut cfg = MassGeneratorConfig {
predict_command: "rssite".to_string(),
..Default::default()
};
cfg.standard.command = "rssite".to_string();
cfg.standard.required_tools = vec!["pandoc".to_string()];
let proc = MassGeneratorProcessor::new(cfg).unwrap();
assert_eq!(proc.required_tools(), vec!["rssite", "pandoc"]);
let mut cfg = MassGeneratorConfig {
predict_command: "./plan.sh".to_string(),
..Default::default()
};
cfg.standard.command = "./build.sh".to_string();
let proc = MassGeneratorProcessor::new(cfg).unwrap();
assert_eq!(proc.required_tools(), vec!["./build.sh", "./plan.sh"]);
}
}