use crate::core::store::purity::{
classify, level_label, recipe_purity, PurityLevel, PuritySignals,
};
use crate::core::store::repro_score::{compute_score, grade, ReproInput};
use std::path::Path;
struct PurityExtract {
resources: Vec<(String, PurityLevel, Vec<String>)>,
recipe_level: PurityLevel,
}
pub(crate) const PURITY_LEVELS: [&str; 4] = ["pure", "pinned", "constrained", "impure"];
fn parse_min_purity(s: &str) -> Result<PurityLevel, String> {
match s.to_ascii_lowercase().as_str() {
"pure" => Ok(PurityLevel::Pure),
"pinned" => Ok(PurityLevel::Pinned),
"constrained" => Ok(PurityLevel::Constrained),
"impure" => Ok(PurityLevel::Impure),
other => Err(format!(
"unknown purity level `{other}` (expected pure, pinned, constrained or impure)"
)),
}
}
pub(crate) fn cmd_validate_check_recipe_purity(
file: &Path,
json: bool,
min_purity: Option<&str>,
) -> Result<(), String> {
let min_level = min_purity.map(parse_min_purity).transpose()?;
let PurityExtract {
resources,
recipe_level,
} = extract_purity(file)?;
let pass = min_level.is_none_or(|min| recipe_level <= min);
if json {
let j = serde_json::json!({
"recipe_purity": format!("{:?}", recipe_level),
"recipe_purity_level": recipe_level as u8,
"min_purity": min_level.map(|l| format!("{l:?}")),
"pass": pass,
"resources": resources.iter().map(|(name, level, reasons)| {
serde_json::json!({
"name": name,
"purity": format!("{:?}", level),
"level": *level as u8,
"reasons": reasons,
})
}).collect::<Vec<_>>(),
});
println!(
"{}",
serde_json::to_string_pretty(&j).unwrap_or_else(|_| "{}".to_string())
);
} else {
println!("Recipe purity: {}", level_label(recipe_level));
for (name, level, reasons) in &resources {
println!(" {name}: {}", level_label(*level));
for r in reasons {
println!(" - {r}");
}
}
}
if pass {
Ok(())
} else {
let min = min_level.expect("pass is only false when a minimum was set");
Err(format!(
"recipe purity {} is worse than the required minimum {}",
level_label(recipe_level),
level_label(min)
))
}
}
pub(crate) fn cmd_validate_check_reproducibility_score(
file: &Path,
json: bool,
) -> Result<(), String> {
let inputs = extract_repro_inputs(file)?;
let score = compute_score(&inputs);
if json {
let j = serde_json::json!({
"composite": score.composite,
"grade": grade(score.composite),
"purity_score": score.purity_score,
"store_score": score.store_score,
"lock_score": score.lock_score,
"resources": score.resources,
});
println!(
"{}",
serde_json::to_string_pretty(&j).unwrap_or_else(|_| "{}".to_string())
);
} else {
println!(
"Reproducibility score: {:.0}/100 (grade {})",
score.composite,
grade(score.composite)
);
println!(
" Purity: {:.0} | Store: {:.0} | Lock: {:.0}",
score.purity_score, score.store_score, score.lock_score
);
for r in &score.resources {
println!(
" {}: {:.0} ({:?}{}{})",
r.name,
r.score,
r.purity,
if r.has_store { " +store" } else { "" },
if r.has_lock_pin { " +lock" } else { "" },
);
}
}
Ok(())
}
fn extract_purity(file: &Path) -> Result<PurityExtract, String> {
let content =
std::fs::read_to_string(file).map_err(|e| format!("read {}: {e}", file.display()))?;
let doc: serde_yaml_ng::Value =
serde_yaml_ng::from_str(&content).map_err(|e| format!("parse {}: {e}", file.display()))?;
let resources = doc
.get("resources")
.and_then(|r| r.as_mapping())
.ok_or_else(|| "no resources section found".to_string())?;
let mut own: Vec<(String, serde_yaml_ng::Value)> = Vec::new();
let mut deps: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for (key, val) in resources {
let name = key.as_str().unwrap_or("").to_string();
deps.insert(name.clone(), depends_on_of(val));
own.push((name, val.clone()));
}
let order = purity_classification_order(&own, &deps);
let mut resolved: std::collections::BTreeMap<String, PurityLevel> =
std::collections::BTreeMap::new();
let mut results = Vec::new();
let mut levels = Vec::new();
for name in order {
let Some((_, val)) = own.iter().find(|(n, _)| *n == name) else {
continue;
};
let dep_levels = deps
.get(&name)
.map(|ds| ds.iter().filter_map(|d| resolved.get(d).copied()).collect())
.unwrap_or_default();
let signals = PuritySignals {
has_version: val.get("version").is_some(),
has_store: val.get("store").and_then(|v| v.as_bool()).unwrap_or(false),
has_sandbox: val.get("sandbox").is_some(),
has_curl_pipe: detect_curl_pipe(val),
dep_levels,
};
let result = classify(&name, &signals);
resolved.insert(name, result.level);
levels.push(result.level);
results.push((result.name, result.level, result.reasons));
}
results.sort_by(|a, b| a.0.cmp(&b.0));
let recipe_level = recipe_purity(&levels);
Ok(PurityExtract {
resources: results,
recipe_level,
})
}
fn extract_repro_inputs(file: &Path) -> Result<Vec<ReproInput>, String> {
let content =
std::fs::read_to_string(file).map_err(|e| format!("read {}: {e}", file.display()))?;
let doc: serde_yaml_ng::Value =
serde_yaml_ng::from_str(&content).map_err(|e| format!("parse {}: {e}", file.display()))?;
let resources = doc
.get("resources")
.and_then(|r| r.as_mapping())
.ok_or_else(|| "no resources section found".to_string())?;
let lock_path = file
.parent()
.unwrap_or(Path::new("."))
.join("forjar.inputs.lock.yaml");
let lock_pins = if lock_path.exists() {
std::fs::read_to_string(&lock_path).unwrap_or_default()
} else {
String::new()
};
let mut inputs = Vec::new();
for (key, val) in resources {
let name = key.as_str().unwrap_or("").to_string();
let signals = PuritySignals {
has_version: val.get("version").is_some(),
has_store: val.get("store").and_then(|v| v.as_bool()).unwrap_or(false),
has_sandbox: val.get("sandbox").is_some(),
has_curl_pipe: detect_curl_pipe(val),
dep_levels: vec![],
};
let result = classify(&name, &signals);
let has_lock_pin = lock_pins.contains(&name);
inputs.push(ReproInput {
name,
purity: result.level,
has_store: signals.has_store,
has_lock_pin,
});
}
inputs.sort_by(|a, b| a.name.cmp(&b.name));
Ok(inputs)
}
fn depends_on_of(val: &serde_yaml_ng::Value) -> Vec<String> {
match val.get("depends_on") {
Some(serde_yaml_ng::Value::Sequence(seq)) => seq
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
Some(serde_yaml_ng::Value::String(s)) => vec![s.clone()],
_ => Vec::new(),
}
}
fn purity_classification_order(
own: &[(String, serde_yaml_ng::Value)],
deps: &std::collections::BTreeMap<String, Vec<String>>,
) -> Vec<String> {
let names: std::collections::BTreeSet<&str> = own.iter().map(|(n, _)| n.as_str()).collect();
let mut remaining: std::collections::BTreeMap<&str, std::collections::BTreeSet<&str>> = own
.iter()
.map(|(n, _)| {
let pending = deps
.get(n)
.map(|ds| {
ds.iter()
.filter(|d| names.contains(d.as_str()))
.map(String::as_str)
.collect()
})
.unwrap_or_default();
(n.as_str(), pending)
})
.collect();
let mut order = Vec::with_capacity(own.len());
while !remaining.is_empty() {
let ready: Vec<&str> = remaining
.iter()
.filter(|(_, pending)| pending.is_empty())
.map(|(n, _)| *n)
.collect();
if ready.is_empty() {
order.extend(remaining.keys().map(|n| (*n).to_string()));
break;
}
for n in &ready {
remaining.remove(n);
}
for pending in remaining.values_mut() {
for n in &ready {
pending.remove(n);
}
}
order.extend(ready.into_iter().map(str::to_string));
}
order
}
fn detect_curl_pipe(val: &serde_yaml_ng::Value) -> bool {
let s = serde_yaml_ng::to_string(val).unwrap_or_default();
(s.contains("curl") && s.contains("bash")) || (s.contains("wget") && s.contains("sh"))
}