use crate::snippets::audit::{AuditConfig, AuditSeverity, audit};
use crate::snippets::discovery;
use crate::snippets::gaps::{GapConfig, detect_gaps};
use crate::snippets::output;
use crate::snippets::runner::{RunnerConfig, run_validation};
use crate::snippets::session::SessionSpec;
use crate::snippets::types::{Language, SideEffectClass, SnippetStatus, ValidationLevel};
use crate::snippets::validators::ValidatorRegistry;
use clap::Subcommand;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[derive(Subcommand)]
pub enum SnippetsAction {
List {
#[arg(short, long, required = true, num_args = 1..)]
snippets: Vec<PathBuf>,
#[arg(short, long, value_delimiter = ',')]
languages: Option<Vec<String>>,
},
Check {
#[arg(short, long, default_value = "alef.toml")]
config: PathBuf,
#[arg(long)]
strict: bool,
#[arg(long, default_value = "on", value_parser = ["on", "off"])]
cache: String,
#[arg(long = "lang", value_delimiter = ',', num_args = 1..)]
languages: Option<Vec<String>>,
},
Parse { file: PathBuf },
Audit {
#[arg(short, long, required = true, num_args = 1..)]
snippets: Vec<PathBuf>,
#[arg(short, long, num_args = 0..)]
docs: Vec<PathBuf>,
#[arg(long)]
require_frontmatter: bool,
},
Gaps {
#[arg(short, long, required = true, num_args = 1..)]
snippets: Vec<PathBuf>,
#[arg(short, long, num_args = 0..)]
docs: Vec<PathBuf>,
#[arg(short = 'L', long, value_delimiter = ',')]
required_languages: Option<Vec<String>>,
#[arg(long = "include-base-path", num_args = 0..)]
include_base_paths: Vec<PathBuf>,
},
}
pub fn run(action: SnippetsAction) -> ExitCode {
match action {
SnippetsAction::List { snippets, languages } => run_list(&snippets, languages.as_ref()),
SnippetsAction::Check {
config,
strict,
cache,
languages,
} => run_check(&config, strict, cache != "off", languages.as_deref()),
SnippetsAction::Parse { file } => run_parse(&file),
SnippetsAction::Audit {
snippets,
docs,
require_frontmatter,
} => run_audit(&snippets, &docs, require_frontmatter),
SnippetsAction::Gaps {
snippets,
docs,
required_languages,
include_base_paths,
} => run_gaps(&snippets, &docs, required_languages.as_ref(), &include_base_paths),
}
}
struct LanguageFilter {
recognised: Vec<Language>,
unrecognised: Vec<String>,
}
fn parse_language_filter(languages: Option<&[String]>) -> Option<LanguageFilter> {
let languages = languages?;
let mut recognised: Vec<Language> = Vec::new();
let mut unrecognised: Vec<String> = Vec::new();
for requested in languages {
match Language::from_session_target(requested) {
Language::Unknown => unrecognised.push(requested.clone()),
language => {
if !recognised.contains(&language) {
recognised.push(language);
}
}
}
}
Some(LanguageFilter {
recognised,
unrecognised,
})
}
fn reject_unrecognised_languages(filter: Option<&LanguageFilter>) -> Result<(), ExitCode> {
let Some(filter) = filter else { return Ok(()) };
if filter.unrecognised.is_empty() {
return Ok(());
}
tracing::error!(
"unrecognised --lang value(s): {:?}. Use a snippet fence tag (`go`, `kotlin`, ...) or a \
session target name from alef.toml (`kotlin_android`, `node`, ...)",
filter.unrecognised
);
Err(ExitCode::FAILURE)
}
fn run_list(snippets: &[PathBuf], languages: Option<&Vec<String>>) -> ExitCode {
let filter = parse_language_filter(languages.map(Vec::as_slice));
if let Err(code) = reject_unrecognised_languages(filter.as_ref()) {
return code;
}
let selected = filter.as_ref().map(|filter| filter.recognised.as_slice());
match discovery::discover_snippets(snippets, selected) {
Ok(found) => {
output::print_snippet_list(&found);
crate::bin_cli::output::blank();
for (language, count) in &discovery::count_by_language(&found) {
crate::bin_cli::output::line(format!(" {language:<12} {count}"));
}
crate::bin_cli::output::blank();
ExitCode::SUCCESS
}
Err(err) => {
tracing::error!("discovering snippets: {err}");
ExitCode::FAILURE
}
}
}
fn run_check(config_path: &Path, force_strict: bool, use_cache: bool, languages: Option<&[String]>) -> ExitCode {
let (_, resolved) = match crate::bin_cli::helpers::load_config(config_path) {
Ok(config) => config,
Err(error) => {
tracing::error!("loading snippet config: {error}");
return ExitCode::FAILURE;
}
};
let Some((crate_config, config)) = resolved
.iter()
.find_map(|krate| Some((krate, krate.docs.as_ref()?.snippets.as_ref()?)))
else {
tracing::error!("no [workspace.docs.snippets] or [crates.docs.snippets] configuration found");
return ExitCode::FAILURE;
};
let root = config_path.parent().unwrap_or_else(|| Path::new("."));
let excluded_paths: Vec<PathBuf> = config.exclude.iter().map(|excluded| root.join(excluded)).collect();
let snippet_directories = resolved_roots(root, &config.dirs, &excluded_paths);
let mut directories = snippet_directories.clone();
directories.extend(resolved_roots(root, &config.inline_dirs, &excluded_paths));
let docs_directories: Vec<PathBuf> = config.docs_dirs.iter().map(|path| root.join(path)).collect();
let include_base_paths: Vec<PathBuf> = if config.include_base_paths.is_empty() {
docs_directories.clone()
} else {
config.include_base_paths.iter().map(|path| root.join(path)).collect()
};
let required_languages = match config
.required_languages
.iter()
.map(|language| language.parse::<Language>())
.collect::<Result<Vec<Language>, _>>()
{
Ok(languages) => languages,
Err(error) => {
tracing::error!("invalid docs.snippets.required_languages entry: {error}");
return ExitCode::FAILURE;
}
};
let level = config
.validation_level
.as_deref()
.unwrap_or("syntax")
.parse::<ValidationLevel>()
.unwrap_or(ValidationLevel::Syntax);
let strict = force_strict || config.strict;
let language_filter = parse_language_filter(languages);
if let Err(code) = reject_unrecognised_languages(language_filter.as_ref()) {
return code;
}
let selected = language_filter.as_ref().map(|filter| filter.recognised.as_slice());
let found = match discovery::discover_snippets(&directories, selected) {
Ok(found) if !found.is_empty() => found,
Ok(_) => {
match &language_filter {
Some(filter) => tracing::error!("no snippets matched --lang {:?}", filter.recognised),
None => tracing::error!("snippet discovery returned no snippets"),
}
return ExitCode::FAILURE;
}
Err(error) => {
tracing::error!("discovering configured snippets: {error}");
return ExitCode::FAILURE;
}
};
let allowed_side_effects = config
.allowed_side_effects
.iter()
.filter_map(|value| parse_side_effect(value))
.collect();
let runner = RunnerConfig {
level,
parallelism: std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get),
timeout_secs: config.timeout_secs.unwrap_or(120),
fail_fast: config.fail_fast,
deny_unclassified: config.deny_unclassified || force_strict,
allowed_side_effects,
cache_dir: use_cache.then(|| root.join(config.cache_dir())),
changed_only: use_cache,
sessions: match configured_sessions(config, root, &crate_config.features) {
Ok(sessions) => sessions,
Err(error) => {
tracing::error!("{error}");
return ExitCode::FAILURE;
}
},
};
let summary = match run_validation(&found, &ValidatorRegistry::new(), &runner) {
Ok(summary) => summary,
Err(error) => {
tracing::error!("running configured snippet validation: {error}");
return ExitCode::FAILURE;
}
};
output::print_summary(&summary, false);
if let Some(path) = &config.report_output
&& let Err(error) = output::write_report(&summary, &root.join(path), false)
{
tracing::error!("writing snippet report: {error}");
return ExitCode::FAILURE;
}
let strict_failure = strict && has_incomplete_coverage(&summary);
let missing_generated = match missing_generated_snippets(&directories) {
Ok(missing) => missing,
Err(error) => {
tracing::error!("reading generated snippet coverage: {error}");
return ExitCode::FAILURE;
}
};
for missing in &missing_generated {
tracing::warn!(
"generated snippet missing for fixture `{}` language `{}`: {}",
missing.key.fixture_id,
missing.key.language,
missing.reason
);
}
let content_collections: std::collections::BTreeMap<String, PathBuf> = config
.content_collections
.iter()
.map(|(name, collection_root)| (name.clone(), root.join(collection_root)))
.collect();
let (audit_failure, gap_failure) = match run_configured_audit_and_gaps(&ConfiguredCheckInputs {
snippet_directories: &snippet_directories,
docs_directories: &docs_directories,
include_base_paths: &include_base_paths,
required_languages: &required_languages,
exclude: &excluded_paths,
readme: crate_config.readme.as_ref(),
content_collections: &content_collections,
workspace_root: root,
require_frontmatter: config.require_frontmatter,
strict,
}) {
Ok(result) => result,
Err(error) => {
tracing::error!("running configured snippet audit and gap checks: {error}");
return ExitCode::FAILURE;
}
};
if summary.has_failures()
|| strict_failure
|| strict && !missing_generated.is_empty()
|| audit_failure
|| gap_failure
{
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn resolved_roots(root: &Path, dirs: &[PathBuf], excluded: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter()
.map(|path| root.join(path))
.filter(|path| !excluded.iter().any(|prefix| path.starts_with(prefix)))
.collect()
}
struct ConfiguredCheckInputs<'a> {
snippet_directories: &'a [PathBuf],
docs_directories: &'a [PathBuf],
include_base_paths: &'a [PathBuf],
required_languages: &'a [Language],
exclude: &'a [PathBuf],
readme: Option<&'a crate::core::config::ReadmeConfig>,
content_collections: &'a std::collections::BTreeMap<String, PathBuf>,
workspace_root: &'a Path,
require_frontmatter: bool,
strict: bool,
}
fn run_configured_audit_and_gaps(inputs: &ConfiguredCheckInputs<'_>) -> anyhow::Result<(bool, bool)> {
let mut configured_references =
crate::snippets::gaps::readme_snippet_references(inputs.workspace_root, inputs.readme);
configured_references
.extend(crate::snippets::gaps::coverage_ledger_references_allowing_missing_cells(inputs.snippet_directories)?);
configured_references.extend(crate::snippets::gaps::astro_collection_references(
inputs.docs_directories,
inputs.content_collections,
)?);
let audit_failure = if inputs.docs_directories.is_empty() {
false
} else {
report_audit(&audit(&AuditConfig {
docs_dirs: inputs.docs_directories.to_vec(),
snippet_dirs: inputs.snippet_directories.to_vec(),
require_frontmatter: inputs.require_frontmatter,
include_base_paths: inputs.include_base_paths.to_vec(),
configured_references: configured_references.clone(),
exclude: inputs.exclude.to_vec(),
}))
};
if inputs.docs_directories.is_empty() && inputs.required_languages.is_empty() {
return Ok((audit_failure, false));
}
let gap_report = detect_gaps(&GapConfig {
docs_dirs: inputs.docs_directories.to_vec(),
snippet_dirs: inputs.snippet_directories.to_vec(),
required_languages: inputs.required_languages.to_vec(),
include_base_paths: inputs.include_base_paths.to_vec(),
configured_references,
exclude: inputs.exclude.to_vec(),
})?;
let (gap_structural_failure, gap_has_unreferenced) = report_gaps(&gap_report);
Ok((
audit_failure,
gap_structural_failure || (inputs.strict && gap_has_unreferenced),
))
}
fn report_audit(report: &crate::snippets::audit::AuditReport) -> bool {
for issue in &report.issues {
let message = format!(
"snippet audit: {}:{} ({:?}) {}",
issue.path.display(),
issue.line,
issue.kind,
issue.message
);
match issue.severity {
AuditSeverity::Error => tracing::error!("{message}"),
AuditSeverity::Warning => tracing::warn!("{message}"),
}
}
report.has_errors()
}
fn report_gaps(report: &crate::snippets::gaps::GapReport) -> (bool, bool) {
for reference in &report.missing_references {
tracing::error!(
"snippet gap: missing include target {}:{} -> {}",
reference.source.display(),
reference.line,
reference.target.display()
);
}
for path in &report.unreferenced_snippets {
tracing::warn!("snippet gap: unreferenced snippet {}", path.display());
}
for variant in &report.missing_language_variants {
tracing::error!(
"snippet gap: missing required language variant `{}` for {}",
variant.language,
variant.group.display()
);
}
for location in &report.skips_without_reason {
tracing::error!(
"snippet gap: skip without reason {}:{} (block {})",
location.path.display(),
location.line,
location.block_index
);
}
for unknown in &report.unknown_languages {
tracing::error!(
"snippet gap: unknown fence language {}:{} tag=`{}`",
unknown.path.display(),
unknown.line,
unknown.tag
);
}
let structural_failure = !report.missing_references.is_empty()
|| !report.missing_language_variants.is_empty()
|| !report.skips_without_reason.is_empty()
|| !report.unknown_languages.is_empty();
(structural_failure, !report.unreferenced_snippets.is_empty())
}
fn configured_sessions(
config: &crate::core::config::DocsSnippetsConfig,
root: &std::path::Path,
crate_features: &[String],
) -> Result<std::collections::HashMap<String, SessionSpec>, String> {
let root = if root.is_absolute() {
root.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| format!("resolving current directory for snippet sessions: {error}"))?
.join(root)
};
let mut sessions = std::collections::HashMap::new();
for (target, session) in &config.sessions {
let normalized = Language::normalize_session_target(target);
let language = Language::from_session_target(&normalized);
if language == Language::Unknown {
return Err(format!("unknown docs.snippets session target `{target}`"));
}
let mut rust_features = session.rust_features.clone();
if language == Language::Rust {
rust_features.extend(crate_features.iter().cloned());
rust_features.sort();
rust_features.dedup();
}
let spec = SessionSpec {
language,
working_directory: root.join(&session.cwd),
manifest: session.manifest.as_ref().map(|path| root.join(path)),
before: session.before.clone(),
env: session.env.clone(),
include_paths: session.include_paths.iter().map(|path| root.join(path)).collect(),
rust_features,
rust_dependencies: session.rust_dependencies.clone(),
};
if sessions.insert(normalized.clone(), spec).is_some() {
return Err(format!("duplicate docs.snippets session target `{normalized}`"));
}
}
Ok(sessions)
}
fn missing_generated_snippets(directories: &[PathBuf]) -> anyhow::Result<Vec<crate::e2e::snippets::MissingSnippet>> {
let mut missing = Vec::new();
for directory in directories {
let path = directory.join(crate::e2e::snippets::COVERAGE_MANIFEST);
if !path.is_file() {
continue;
}
let content = std::fs::read_to_string(&path)
.map_err(|error| anyhow::anyhow!("failed to read {}: {error}", path.display()))?;
let ledger: crate::e2e::snippets::SnippetCoverageLedger = serde_json::from_str(&content)
.map_err(|error| anyhow::anyhow!("failed to parse {}: {error}", path.display()))?;
missing.extend(ledger.missing);
}
missing.sort_by(|left, right| left.key.cmp(&right.key));
Ok(missing)
}
fn has_incomplete_coverage(summary: &crate::snippets::types::RunSummary) -> bool {
summary.results.iter().any(|result| is_incomplete_status(result.status))
}
fn is_incomplete_status(status: SnippetStatus) -> bool {
matches!(
status,
SnippetStatus::Skip | SnippetStatus::Unavailable | SnippetStatus::Downgraded
)
}
fn parse_side_effect(value: &str) -> Option<SideEffectClass> {
match value.trim().to_ascii_lowercase().as_str() {
"safe" => Some(SideEffectClass::Safe),
"network" => Some(SideEffectClass::Network),
"process" => Some(SideEffectClass::Process),
"install" => Some(SideEffectClass::Install),
"server" => Some(SideEffectClass::Server),
_ => None,
}
}
fn run_parse(file: &Path) -> ExitCode {
match crate::snippets::parser::parse_code_blocks(file) {
Ok(blocks) => {
if blocks.is_empty() {
crate::bin_cli::output::line(format!("No code blocks found in {}", file.display()));
} else {
for (index, block) in blocks.iter().enumerate() {
crate::bin_cli::output::line(format!("--- Block {} (line {}) ---", index + 1, block.start_line));
crate::bin_cli::output::line(format!("Language: {}", block.lang));
if let Some(title) = &block.title {
crate::bin_cli::output::line(format!("Title: {title}"));
}
if let Some(comment) = &block.preceding_comment {
crate::bin_cli::output::line(format!("Annotation: {comment}"));
}
crate::bin_cli::output::line(format!("Code ({} lines):", block.code.lines().count()));
crate::bin_cli::output::line(&block.code);
crate::bin_cli::output::blank();
}
}
ExitCode::SUCCESS
}
Err(err) => {
tracing::error!("parsing {}: {err}", file.display());
ExitCode::FAILURE
}
}
}
fn reject_missing_configured_directories(snippet_dirs: &[PathBuf], docs_dirs: &[PathBuf]) -> Result<(), ExitCode> {
let checked = [
(discovery::SNIPPET_DIRECTORY_KIND, snippet_dirs),
(discovery::DOCUMENTATION_DIRECTORY_KIND, docs_dirs),
];
for (kind, dirs) in checked {
if let Err(error) = discovery::ensure_configured_directories_exist(kind, dirs) {
tracing::error!("{error}");
return Err(ExitCode::FAILURE);
}
}
Ok(())
}
fn run_audit(snippet_dirs: &[PathBuf], docs_dirs: &[PathBuf], require_frontmatter: bool) -> ExitCode {
if let Err(code) = reject_missing_configured_directories(snippet_dirs, docs_dirs) {
return code;
}
let configured_references = match crate::snippets::gaps::coverage_ledger_references(snippet_dirs) {
Ok(references) => references,
Err(error) => {
tracing::error!("reading generated snippet coverage: {error}");
return ExitCode::FAILURE;
}
};
let config = AuditConfig {
docs_dirs: docs_dirs.to_vec(),
snippet_dirs: snippet_dirs.to_vec(),
require_frontmatter,
include_base_paths: docs_dirs.to_vec(),
configured_references,
exclude: Vec::new(),
};
let report = audit(&config);
if report.issues.is_empty() {
crate::bin_cli::output::line("Audit clean: no issues found.");
return ExitCode::SUCCESS;
}
crate::bin_cli::output::line(format!("Audit found {} issue(s):", report.issues.len()));
for issue in &report.issues {
let severity = match issue.severity {
AuditSeverity::Error => "ERROR",
AuditSeverity::Warning => "WARN",
};
crate::bin_cli::output::line(format!(
" [{severity}] {}:{} ({:?}) {}",
issue.path.display(),
issue.line,
issue.kind,
issue.message
));
}
if report.has_errors() {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn run_gaps(
snippet_dirs: &[PathBuf],
docs_dirs: &[PathBuf],
required_languages: Option<&Vec<String>>,
include_base_paths: &[PathBuf],
) -> ExitCode {
if let Err(code) = reject_missing_configured_directories(snippet_dirs, docs_dirs) {
return code;
}
let required = required_languages
.map(|languages| {
languages
.iter()
.map(|language| Language::from_fence_tag(language))
.filter(|language| *language != Language::Unknown)
.collect()
})
.unwrap_or_default();
let resolved_base_paths: Vec<PathBuf> = if include_base_paths.is_empty() {
docs_dirs.to_vec()
} else {
include_base_paths.to_vec()
};
let configured_references = match crate::snippets::gaps::coverage_ledger_references(snippet_dirs) {
Ok(references) => references,
Err(error) => {
tracing::error!("reading generated snippet coverage: {error}");
return ExitCode::FAILURE;
}
};
let config = GapConfig {
docs_dirs: docs_dirs.to_vec(),
snippet_dirs: snippet_dirs.to_vec(),
required_languages: required,
include_base_paths: resolved_base_paths,
configured_references,
exclude: Vec::new(),
};
let report = match detect_gaps(&config) {
Ok(report) => report,
Err(err) => {
tracing::error!("detecting gaps: {err}");
return ExitCode::FAILURE;
}
};
if !report.has_gaps() {
crate::bin_cli::output::line("No gaps found.");
return ExitCode::SUCCESS;
}
if !report.missing_references.is_empty() {
crate::bin_cli::output::line(format!(
"Missing include targets ({}):",
report.missing_references.len()
));
for reference in &report.missing_references {
crate::bin_cli::output::line(format!(
" {}:{} → {}",
reference.source.display(),
reference.line,
reference.target.display()
));
}
}
if !report.unreferenced_snippets.is_empty() {
crate::bin_cli::output::line(format!(
"Unreferenced snippets ({}):",
report.unreferenced_snippets.len()
));
for path in &report.unreferenced_snippets {
crate::bin_cli::output::line(format!(" {}", path.display()));
}
}
if !report.missing_language_variants.is_empty() {
crate::bin_cli::output::line(format!(
"Missing language variants ({}):",
report.missing_language_variants.len()
));
for variant in &report.missing_language_variants {
crate::bin_cli::output::line(format!(" {} — {}", variant.group.display(), variant.language));
}
}
if !report.skips_without_reason.is_empty() {
crate::bin_cli::output::line(format!("Skips without reason ({}):", report.skips_without_reason.len()));
for location in &report.skips_without_reason {
crate::bin_cli::output::line(format!(
" {}:{} (block {})",
location.path.display(),
location.line,
location.block_index
));
}
}
if !report.unknown_languages.is_empty() {
crate::bin_cli::output::line(format!("Unknown languages ({}):", report.unknown_languages.len()));
for unknown in &report.unknown_languages {
crate::bin_cli::output::line(format!(
" {}:{} tag={}",
unknown.path.display(),
unknown.line,
unknown.tag
));
}
}
ExitCode::FAILURE
}
#[cfg(test)]
mod tests;