use crate::snippets::cache::ValidationCache;
use crate::snippets::error::Result;
use crate::snippets::session::{SessionSpec, prepare_sessions_isolated};
use crate::snippets::types::{
DowngradeReason, RunSummary, SideEffectClass, Snippet, SnippetAnnotationKind, SnippetStatus, ValidationLevel,
ValidationResult,
};
use crate::snippets::validators::ValidatorRegistry;
use rayon::prelude::*;
use std::collections::{BTreeMap, HashMap};
use std::sync::Mutex;
use std::time::Instant;
mod batch;
mod session_prep;
mod session_resolution;
use batch::validate_batches;
use session_prep::{session_preparation_error, session_preparation_result};
pub struct RunnerConfig {
pub level: ValidationLevel,
pub parallelism: usize,
pub timeout_secs: u64,
pub fail_fast: bool,
pub deny_unclassified: bool,
pub allowed_side_effects: Vec<SideEffectClass>,
pub cache_dir: Option<std::path::PathBuf>,
pub changed_only: bool,
pub sessions: HashMap<String, SessionSpec>,
}
impl Default for RunnerConfig {
fn default() -> Self {
Self {
level: ValidationLevel::Syntax,
parallelism: available_parallelism(),
timeout_secs: 120,
fail_fast: false,
deny_unclassified: false,
allowed_side_effects: Vec::new(),
cache_dir: Some(std::path::PathBuf::from(".alef/snippets")),
changed_only: false,
sessions: HashMap::new(),
}
}
}
fn available_parallelism() -> usize {
std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get)
}
pub fn run_validation(snippets: &[Snippet], registry: &ValidatorRegistry, config: &RunnerConfig) -> Result<RunSummary> {
let preparation = prepare_sessions_isolated(&config.sessions, config.timeout_secs);
let sessions = preparation.sessions;
let session_errors = preparation.errors;
let session_locks = sessions
.keys()
.map(|target| (target.clone(), Mutex::new(())))
.collect::<HashMap<_, _>>();
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(config.parallelism)
.build()
.map_err(|err| crate::snippets::error::Error::Other(format!("failed to build thread pool: {err}")))?;
let fail_fast = config.fail_fast;
let calling_span = tracing::Span::current();
let results: Vec<ValidationResult> = pool.install(|| {
let _entered = calling_span.enter();
if fail_fast {
fail_fast_results(snippets, registry, config, &sessions, &session_errors, &session_locks)
} else {
parallel_results(snippets, registry, config, &sessions, &session_errors, &session_locks)
}
});
Ok(RunSummary::from_results(results))
}
fn fail_fast_results(
snippets: &[Snippet],
registry: &ValidatorRegistry,
config: &RunnerConfig,
sessions: &HashMap<String, crate::snippets::session::ValidationSession>,
session_errors: &HashMap<String, crate::snippets::session::SessionPreparationError>,
session_locks: &HashMap<String, Mutex<()>>,
) -> Vec<ValidationResult> {
tracing::info!(
snippet_count = snippets.len(),
timeout_secs = config.timeout_secs,
"Starting fail-fast snippet validation"
);
let started = Instant::now();
let reporter = FailureReporter::new(snippets);
let mut results = Vec::with_capacity(snippets.len());
for snippet in snippets {
let preparation_error = session_preparation_error(snippet, config, session_errors);
let session = session_for(snippet, sessions);
let lock = session_key(snippet, sessions).and_then(|key| session_locks.get(key));
let result = validate_one(
snippet,
registry,
config,
session,
lock,
preparation_error.as_ref(),
Some(&reporter),
);
reporter.record(&result);
let should_stop =
preparation_error.is_none() && matches!(result.status, SnippetStatus::Fail | SnippetStatus::Error);
results.push(result);
if should_stop {
break;
}
}
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
tracing::info!(
snippet_count = results.len(),
duration_ms,
"Finished fail-fast snippet validation"
);
results
}
fn parallel_results(
snippets: &[Snippet],
registry: &ValidatorRegistry,
config: &RunnerConfig,
sessions: &HashMap<String, crate::snippets::session::ValidationSession>,
session_errors: &HashMap<String, crate::snippets::session::SessionPreparationError>,
session_locks: &HashMap<String, Mutex<()>>,
) -> Vec<ValidationResult> {
let reporter = FailureReporter::new(snippets);
let batched = validate_batches(
snippets,
registry,
config,
sessions,
session_errors,
session_locks,
&reporter,
);
let unclaimed_counts = fallback_counts_by_language(snippets, &batched);
let started = Instant::now();
let results = snippets
.par_iter()
.enumerate()
.map(|(index, snippet)| {
if let Some(result) = batched[index].clone() {
return result;
}
let session = session_for(snippet, sessions);
let lock = session_key(snippet, sessions).and_then(|key| session_locks.get(key));
let result = validate_one(snippet, registry, config, session, lock, None, Some(&reporter));
reporter.record(&result);
result
})
.collect();
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
for (language, invoked) in reporter.invoked_by_language() {
let unclaimed = unclaimed_counts.get(&language).copied().unwrap_or(invoked);
tracing::info!(
language = %language,
snippet_count = invoked,
resolved_without_toolchain = unclaimed.saturating_sub(invoked),
duration_ms,
"Finished per-snippet validation"
);
}
results
}
fn fallback_counts_by_language(
snippets: &[Snippet],
batched: &[Option<ValidationResult>],
) -> BTreeMap<crate::snippets::types::Language, usize> {
let mut counts = BTreeMap::new();
for (snippet, entry) in snippets.iter().zip(batched) {
if entry.is_none() {
*counts.entry(snippet.language).or_insert(0_usize) += 1;
}
}
counts
}
const FAILURE_PROGRESS_STRIDE: usize = 25;
const FAILURE_MESSAGE_PREVIEW_CHARS: usize = 400;
#[derive(Clone, Copy, Default)]
struct LanguageTally {
completed: usize,
failed: usize,
unavailable: usize,
invoked: usize,
}
struct FailureReporter {
totals: BTreeMap<crate::snippets::types::Language, usize>,
tallies: Mutex<BTreeMap<crate::snippets::types::Language, LanguageTally>>,
span: tracing::Span,
}
impl FailureReporter {
fn new(snippets: &[Snippet]) -> Self {
let mut totals = BTreeMap::new();
for snippet in snippets {
*totals.entry(snippet.language).or_insert(0_usize) += 1;
}
Self {
totals,
tallies: Mutex::new(BTreeMap::new()),
span: tracing::Span::current(),
}
}
fn record_toolchain_start(&self, language: crate::snippets::types::Language, timeout_secs: u64) {
let Ok(mut tallies) = self.tallies.lock() else {
return;
};
let tally = tallies.entry(language).or_default();
tally.invoked += 1;
let first = tally.invoked == 1;
drop(tallies);
if !first {
return;
}
let snippet_count = self.totals.get(&language).copied().unwrap_or(0);
self.span.in_scope(|| {
tracing::info!(
language = %language,
snippet_count = snippet_count,
timeout_secs = timeout_secs,
"Starting per-snippet validation"
);
});
}
fn invoked_by_language(&self) -> BTreeMap<crate::snippets::types::Language, usize> {
let Ok(tallies) = self.tallies.lock() else {
return BTreeMap::new();
};
tallies
.iter()
.filter(|(_, tally)| tally.invoked > 0)
.map(|(language, tally)| (*language, tally.invoked))
.collect()
}
fn record(&self, result: &ValidationResult) {
let language = result.snippet.language;
let failed = matches!(result.status, SnippetStatus::Fail | SnippetStatus::Error);
let unavailable = matches!(result.status, SnippetStatus::Unavailable);
let Ok(mut tallies) = self.tallies.lock() else {
return;
};
let tally = tallies.entry(language).or_default();
tally.completed += 1;
if failed {
tally.failed += 1;
}
if unavailable {
tally.unavailable += 1;
}
let tally = *tally;
drop(tallies);
let snippet_count = self.totals.get(&language).copied().unwrap_or(tally.completed);
self.span.in_scope(|| {
if failed && tally.failed == 1 {
tracing::warn!(
language = %language,
path = %result.snippet.source_origin.path.display(),
line = result.snippet.source_origin.line,
snippet_count = snippet_count,
error = %failure_preview(result.message.as_deref()),
"First snippet validation failure for this language"
);
} else if unavailable && tally.unavailable == 1 {
tracing::warn!(
language = %language,
path = %result.snippet.source_origin.path.display(),
line = result.snippet.source_origin.line,
snippet_count = snippet_count,
unresolved_dependency = result.unresolved_dependency,
error = %failure_preview(result.message.as_deref()),
"First snippet validation unavailability for this language"
);
} else if failed && tally.failed % FAILURE_PROGRESS_STRIDE == 0 {
tracing::warn!(
language = %language,
failed = tally.failed,
completed = tally.completed,
snippet_count = snippet_count,
"Snippet validation failures accumulating"
);
}
if tally.completed < snippet_count {
return;
}
if tally.failed > 0 {
tracing::warn!(
language = %language,
failed = tally.failed,
unavailable = tally.unavailable,
snippet_count = snippet_count,
"Finished snippet validation for this language with failures"
);
} else if tally.unavailable > 0 {
tracing::warn!(
language = %language,
unavailable = tally.unavailable,
snippet_count = snippet_count,
"Finished snippet validation for this language with every result unvalidated"
);
} else {
tracing::debug!(
language = %language,
snippet_count = snippet_count,
"Finished snippet validation for this language"
);
}
});
}
}
fn failure_preview(message: Option<&str>) -> String {
let joined = message
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" | ");
if joined.is_empty() {
return "<no validator output>".to_string();
}
match joined.char_indices().nth(FAILURE_MESSAGE_PREVIEW_CHARS) {
Some((index, _)) => format!("{}...", &joined[..index]),
None => joined,
}
}
type BatchKey = (crate::snippets::types::Language, Option<String>, ValidationLevel);
struct ValidationOutcome {
status: SnippetStatus,
message: Option<String>,
duration_ms: u64,
}
fn batch_level(
snippet: &Snippet,
registry: &ValidatorRegistry,
config: &RunnerConfig,
session: Option<&crate::snippets::session::ValidationSession>,
) -> Option<ValidationLevel> {
if cached_result(snippet, config, session).is_some() || side_effect_rejection(snippet, config).is_some() {
return None;
}
if let Some(annotation) = &snippet.annotation
&& annotation.kind == SnippetAnnotationKind::Skip
{
return None;
}
let validator = registry.get(snippet.language)?;
if !validator.supports_batching() {
return None;
}
let level = capped_level(snippet, config, validator);
validator.is_available_at(level).then_some(level)
}
fn annotation_level_limit(snippet: &Snippet) -> Option<ValidationLevel> {
snippet
.annotation
.as_ref()
.and_then(|annotation| match annotation.kind {
SnippetAnnotationKind::SyntaxOnly => Some(ValidationLevel::Syntax),
SnippetAnnotationKind::CompileOnly => Some(ValidationLevel::Compile),
SnippetAnnotationKind::TypeCheckOnly => Some(ValidationLevel::TypeCheck),
SnippetAnnotationKind::Skip => None,
})
}
fn effective_validation_level(snippet: &Snippet, requested: ValidationLevel) -> ValidationLevel {
[annotation_level_limit(snippet), snippet.metadata.level]
.into_iter()
.flatten()
.fold(requested, ValidationLevel::min)
}
fn capped_level(
snippet: &Snippet,
config: &RunnerConfig,
validator: &dyn crate::snippets::validators::SnippetValidator,
) -> ValidationLevel {
effective_validation_level(snippet, config.level)
.min(validator.max_level())
.min(validator.achievable_level(config.level))
}
fn structurally_unreachable(
validator: &dyn crate::snippets::validators::SnippetValidator,
requested: ValidationLevel,
) -> bool {
validator.max_level() < requested
|| (validator.achievable_level(requested) < requested && validator.achievable_level_is_structural(requested))
}
fn session_for<'a>(
snippet: &Snippet,
sessions: &'a HashMap<String, crate::snippets::session::ValidationSession>,
) -> Option<&'a crate::snippets::session::ValidationSession> {
match session_resolution::resolve_session_claim(snippet, sessions, |session| session.language) {
session_resolution::SessionClaim::Claimed(key) => sessions.get(key),
session_resolution::SessionClaim::Unclaimed | session_resolution::SessionClaim::Ambiguous(_) => None,
}
}
fn session_key<'a>(
snippet: &Snippet,
sessions: &'a HashMap<String, crate::snippets::session::ValidationSession>,
) -> Option<&'a str> {
match session_resolution::resolve_session_claim(snippet, sessions, |session| session.language) {
session_resolution::SessionClaim::Claimed(key) => Some(key),
session_resolution::SessionClaim::Unclaimed | session_resolution::SessionClaim::Ambiguous(_) => None,
}
}
fn validate_one(
snippet: &Snippet,
registry: &ValidatorRegistry,
config: &RunnerConfig,
session: Option<&crate::snippets::session::ValidationSession>,
session_lock: Option<&Mutex<()>>,
session_preparation_error: Option<&crate::snippets::session::SessionPreparationError>,
reporter: Option<&FailureReporter>,
) -> ValidationResult {
if let Some(preparation_error) = session_preparation_error {
return session_preparation_result(snippet, config, preparation_error);
}
if let Some(result) = cached_result(snippet, config, session) {
return result;
}
if let Some(message) = side_effect_rejection(snippet, config) {
return result(
snippet,
SnippetStatus::Skip,
config.level,
config.level,
Some(message),
0,
);
}
if let Some(annotation) = &snippet.annotation
&& annotation.kind == SnippetAnnotationKind::Skip
{
return result(
snippet,
SnippetStatus::Skip,
config.level,
config.level,
Some(skip_message("skipped via annotation", annotation.reason.as_deref())),
0,
);
}
let Some(validator) = registry.get(snippet.language) else {
return result(
snippet,
SnippetStatus::Unavailable,
config.level,
config.level,
Some(format!("no validator for {}", snippet.language)),
0,
);
};
let effective_level = capped_level(snippet, config, validator);
if !validator.is_available_at(effective_level) {
return result(
snippet,
SnippetStatus::Unavailable,
config.level,
config.level,
Some(format!("{} toolchain not found", snippet.language)),
0,
);
}
if let Some(reporter) = reporter {
reporter.record_toolchain_start(snippet.language, config.timeout_secs);
}
let mut start = Instant::now();
let validation = |start: &mut Instant| {
*start = Instant::now();
validator.validate_in_session(snippet, effective_level, config.timeout_secs, session)
};
let session_lock = session_lock.filter(|_| validator.requires_session_exclusivity());
let validation_result = match session_lock {
Some(lock) => match lock.lock() {
Ok(_guard) => validation(&mut start),
Err(error) => Err(crate::snippets::error::Error::Other(format!(
"locking {} snippet validation session: {error}",
snippet.language
))),
},
None => validation(&mut start),
};
let (status, message) = match validation_result {
Ok((status, message)) => (status, message),
Err(err) => (SnippetStatus::Error, Some(err.to_string())),
};
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
finalize_result(
snippet,
validator,
config,
session,
effective_level,
ValidationOutcome {
status,
message,
duration_ms,
},
)
}
struct ResultClassification {
status: SnippetStatus,
capability_capped: bool,
downgrade_reason: Option<DowngradeReason>,
}
fn classify_result(
snippet: &Snippet,
validator: &dyn crate::snippets::validators::SnippetValidator,
config: &RunnerConfig,
effective_level: ValidationLevel,
status: SnippetStatus,
) -> ResultClassification {
if status != SnippetStatus::Pass || effective_level >= config.level {
return ResultClassification {
status,
capability_capped: false,
downgrade_reason: None,
};
}
let annotated_level = effective_validation_level(snippet, config.level);
let structural = structurally_unreachable(validator, config.level);
if annotated_level >= config.level && structural {
return ResultClassification {
status,
capability_capped: true,
downgrade_reason: Some(DowngradeReason::ValidatorCapability),
};
}
let declared_binds = snippet
.metadata
.level
.is_some_and(|level| config.level.min(level) == annotated_level);
if effective_level == annotated_level && annotated_level < config.level && declared_binds {
return ResultClassification {
status,
capability_capped: false,
downgrade_reason: Some(DowngradeReason::Declared),
};
}
let reason = if effective_level < annotated_level && structural {
DowngradeReason::ValidatorCapability
} else if effective_level < annotated_level {
DowngradeReason::Environment
} else {
DowngradeReason::Annotation
};
ResultClassification {
status: SnippetStatus::Downgraded,
capability_capped: false,
downgrade_reason: Some(reason),
}
}
fn finalize_result(
snippet: &Snippet,
validator: &dyn crate::snippets::validators::SnippetValidator,
config: &RunnerConfig,
session: Option<&crate::snippets::session::ValidationSession>,
effective_level: ValidationLevel,
outcome: ValidationOutcome,
) -> ValidationResult {
let ValidationOutcome {
mut status,
message,
duration_ms,
} = outcome;
let mut unresolved_dependency = false;
if status == SnippetStatus::Fail
&& let Some(error_output) = &message
&& validator.is_dependency_error(error_output)
{
if effective_level == ValidationLevel::Syntax {
status = SnippetStatus::Pass;
} else {
status = SnippetStatus::Unavailable;
unresolved_dependency = true;
}
}
let classification = classify_result(snippet, validator, config, effective_level, status);
let status = classification.status;
let message = if classification.downgrade_reason == Some(DowngradeReason::Declared) {
Some(format!(
"requested {}, validated at declared level {effective_level}",
config.level
))
} else if status == SnippetStatus::Downgraded {
Some(format!("requested {}, validated at {}", config.level, effective_level))
} else if classification.capability_capped {
Some(format!(
"requested {}, validated at {} ({} validator caps at {})",
config.level, effective_level, snippet.language, effective_level
))
} else if unresolved_dependency {
Some(format!(
"could not validate at {effective_level}: {} toolchain ran but reported a missing dependency or build \
artifact -- run `alef build` first if this crate validates snippets against built artifacts: {}",
snippet.language,
message.as_deref().unwrap_or("<no validator output>")
))
} else {
message
};
debug_assert!(
classification.downgrade_reason.is_some()
|| !(classification.status == SnippetStatus::Downgraded || classification.capability_capped),
"a Downgraded or capability_capped result must always carry a downgrade_reason"
);
let mut result = result(snippet, status, config.level, effective_level, message, duration_ms);
result.capability_capped = classification.capability_capped;
result.downgrade_reason = classification.downgrade_reason;
result.unresolved_dependency = unresolved_dependency;
if let Some(cache) = config.cache_dir.clone().map(ValidationCache::new)
&& let Err(error) = cache.store(
snippet,
config.level,
session.map(|value| value.fingerprint.as_str()),
&result,
)
{
tracing::warn!("writing snippet validation cache: {error}");
}
result
}
fn cached_result(
snippet: &Snippet,
config: &RunnerConfig,
session: Option<&crate::snippets::session::ValidationSession>,
) -> Option<ValidationResult> {
if !config.changed_only {
return None;
}
let cache = config.cache_dir.clone().map(ValidationCache::new)?;
let mut result = cache.load(snippet, config.level, session.map(|value| value.fingerprint.as_str()))?;
result.snippet = snippet.clone();
result.duration_ms = 0;
result.message = result.message.or_else(|| Some("cached".to_string()));
Some(result)
}
fn side_effect_rejection(snippet: &Snippet, config: &RunnerConfig) -> Option<String> {
if config.level != ValidationLevel::Run {
return None;
}
let Some(class) = snippet.metadata.side_effect else {
return config
.deny_unclassified
.then(|| "unclassified side effects are denied".to_string());
};
if class == SideEffectClass::Safe || config.allowed_side_effects.contains(&class) {
None
} else {
Some(format!("side effect class {class:?} is not allowed").to_lowercase())
}
}
pub(super) fn result(
snippet: &Snippet,
status: SnippetStatus,
requested_level: ValidationLevel,
effective_level: ValidationLevel,
message: Option<String>,
duration_ms: u64,
) -> ValidationResult {
ValidationResult {
snippet: snippet.clone(),
status,
level: effective_level,
requested_level,
effective_level,
message,
duration_ms,
capability_capped: false,
downgrade_reason: None,
unresolved_dependency: false,
}
}
fn skip_message(message: &str, reason: Option<&str>) -> String {
match reason {
Some(reason) if !reason.is_empty() => format!("{message}: {reason}"),
_ => message.to_string(),
}
}
#[cfg(test)]
mod no_work_logging_tests;
#[cfg(test)]
mod session_concurrency_tests;
#[cfg(test)]
mod session_preparation_classification_tests;
#[cfg(test)]
mod validation_dispatch_tests;
#[cfg(test)]
mod downgrade_classification_tests;
#[cfg(test)]
mod batch_logging_tests;
#[cfg(test)]
mod failure_reporting_tests;