Current section

Files

Jump to
rbt_weave crates weave-content src commands.rs
Raw

crates/weave-content/src/commands.rs

//! High-level orchestration commands for content operations.
//!
//! These functions encapsulate the full pipeline for validate, verify,
//! and check-staleness operations. Both `weave-content` CLI and `loom-seed`
//! call into these functions.
use std::collections::{HashMap, HashSet};
use std::path::Path;
use rayon::prelude::*;
use crate::entity;
use crate::registry;
use crate::staleness;
use crate::tags;
use crate::verifier;
use crate::{
load_registry, load_tag_registry, parse_full, resolve_case_files, resolve_content_root,
};
// ── Validate ────────────────────────────────────────────────────────────────
/// Run full validation on content files: parse, check schema, cross-file
/// duplicate detection, redirect collision checks, qualifier consistency.
///
/// When `strict` is true, warnings (e.g. filename mismatches) are treated
/// as errors and cause a non-zero exit code.
///
/// When `quiet` is true, per-entity and per-relationship detail lines are
/// suppressed. Only file-level summaries and errors/warnings are shown.
///
/// Returns exit code (0 = success).
pub fn validate(path: Option<&str>, root: Option<&str>, strict: bool, quiet: bool) -> i32 {
let content_root = resolve_content_root(path, root);
let redirect_slugs = load_redirect_slugs(&content_root);
if !redirect_slugs.is_empty() {
eprintln!("redirects: {} entries loaded", redirect_slugs.len());
let collision_count = check_redirect_collisions(&redirect_slugs, &content_root);
if collision_count > 0 {
eprintln!(
"error: {collision_count} file(s) at old redirected paths — move them or remove the redirect entry"
);
return 1;
}
}
let reg = match load_registry(&content_root) {
Ok(r) => r,
Err(code) => return code,
};
let tag_reg = match load_tag_registry(&content_root) {
Ok(r) => r,
Err(code) => return code,
};
let case_files = match resolve_case_files(path, &content_root) {
Ok(f) => f,
Err(code) => return code,
};
if case_files.is_empty() {
eprintln!("no case files found");
return 1;
}
if !reg.is_empty() {
eprintln!("registry: {} entities loaded", reg.len());
}
// In strict mode, filename mismatch warnings become errors
if strict {
let filename_warnings = reg.check_filenames();
if !filename_warnings.is_empty() {
for w in &filename_warnings {
eprintln!("{w}");
}
eprintln!(
"error: {} filename warning(s) treated as errors (--strict)",
filename_warnings.len()
);
return 1;
}
}
if !tag_reg.is_empty() {
eprintln!(
"tags: {} tags loaded across {} categories",
tag_reg.len(),
tag_reg.category_slugs().len()
);
}
let mut entity_tag_errors = false;
for entry in reg.entries() {
let tag_errors = tag_reg.validate_tags(&entry.tags, 2);
for err in &tag_errors {
eprintln!("{}:{err}", entry.path.display());
}
if !tag_errors.is_empty() {
entity_tag_errors = true;
}
}
let results: Vec<ValidateResult> = case_files
.par_iter()
.map(|case_path| validate_single_case(case_path, &reg, &tag_reg, quiet))
.collect();
let mut exit_code = i32::from(entity_tag_errors);
let mut all_events: Vec<(String, String)> = Vec::new();
let mut all_rel_ids: Vec<(String, String, usize)> = Vec::new();
for result in &results {
if result.exit_code != 0 {
exit_code = result.exit_code;
}
all_events.extend(result.events.iter().cloned());
all_rel_ids.extend(result.rel_ids.iter().cloned());
}
if let Some(code) = check_duplicate_event_names(&all_events) {
exit_code = code;
}
if let Some(code) = check_duplicate_rel_ids(&all_rel_ids) {
exit_code = code;
}
check_qualifier_consistency(&reg, strict, &mut exit_code);
// Print summary
let ok_count = results.iter().filter(|r| r.exit_code == 0).count();
let err_count = results.iter().filter(|r| r.exit_code != 0).count();
let total_entities: usize = results.iter().map(|r| r.entity_count).sum();
let total_rels: usize = results.iter().map(|r| r.rel_count).sum();
eprintln!(
"validate: {ok_count} case(s) ok, {err_count} failed ({total_entities} entities, {total_rels} relationships)",
);
exit_code
}
struct ValidateResult {
exit_code: i32,
events: Vec<(String, String)>,
rel_ids: Vec<(String, String, usize)>,
entity_count: usize,
rel_count: usize,
}
fn validate_single_case(
path: &str,
reg: &registry::EntityRegistry,
tag_reg: &tags::TagRegistry,
quiet: bool,
) -> ValidateResult {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("{path}: error reading file: {e}");
return ValidateResult {
exit_code: 2,
events: Vec::new(),
rel_ids: Vec::new(),
entity_count: 0,
rel_count: 0,
};
}
};
match parse_full(&content, Some(reg)) {
Ok((case, entities, rels)) => {
eprintln!(
"{path}: ok -- {id}: {title} ({ent} entities, {rel} relationships, {src} sources)",
id = case.id.as_deref().unwrap_or("(no id)"),
title = case.title,
ent = entities.len(),
rel = rels.len(),
src = case.sources.len(),
);
if !quiet {
if !case.summary.is_empty() {
eprintln!(
" summary: {}...",
&case.summary[..case.summary.len().min(80)]
);
}
for e in &entities {
let id_display = e.id.as_deref().unwrap_or("(no id)");
eprintln!(
" line {}: {id_display} {} ({}, {} fields)",
e.line,
e.name,
e.label,
e.fields.len()
);
}
}
let events: Vec<(String, String)> = entities
.iter()
.filter(|e| e.label == entity::Label::Event)
.map(|e| (e.name.clone(), path.to_string()))
.collect();
for r in &rels {
if !quiet {
let id_display = r.id.as_deref().unwrap_or("(no id)");
eprintln!(
" line {}: {id_display} {} -> {}: {}",
r.line, r.source_name, r.target_name, r.rel_type,
);
}
}
let mut exit_code = 0;
let tag_errors = tag_reg.validate_tags(&case.tags, 2);
for err in &tag_errors {
eprintln!("{path}:{err}");
}
if !tag_errors.is_empty() {
exit_code = 1;
}
let rel_ids: Vec<(String, String, usize)> = rels
.iter()
.filter_map(|r| {
r.id.as_ref()
.map(|id| (id.clone(), path.to_string(), r.line))
})
.collect();
ValidateResult {
exit_code,
events,
rel_ids,
entity_count: entities.len(),
rel_count: rels.len(),
}
}
Err(errors) => {
for err in &errors {
eprintln!("{path}:{err}");
}
ValidateResult {
exit_code: 1,
events: Vec::new(),
rel_ids: Vec::new(),
entity_count: 0,
rel_count: 0,
}
}
}
}
fn check_duplicate_event_names(all_events: &[(String, String)]) -> Option<i32> {
let mut seen: HashMap<&str, &str> = HashMap::new();
let mut has_duplicates = false;
for (name, path) in all_events {
if let Some(&first_path) = seen.get(name.as_str()) {
eprintln!(
"error: duplicate event name {name:?} in {path} (first defined in {first_path})"
);
has_duplicates = true;
} else {
seen.insert(name, path);
}
}
if has_duplicates { Some(1) } else { None }
}
fn check_duplicate_rel_ids(all_rel_ids: &[(String, String, usize)]) -> Option<i32> {
let mut seen: HashMap<&str, (&str, usize)> = HashMap::new();
let mut has_duplicates = false;
for (id, path, line) in all_rel_ids {
if let Some(&(first_path, first_line)) = seen.get(id.as_str()) {
eprintln!(
"error: duplicate relationship id {id:?} at {path}:{line} (first defined at {first_path}:{first_line})"
);
has_duplicates = true;
} else {
seen.insert(id, (path, *line));
}
}
if has_duplicates { Some(1) } else { None }
}
fn check_qualifier_consistency(reg: &registry::EntityRegistry, strict: bool, exit_code: &mut i32) {
use entity::FieldValue;
let mut by_lower: HashMap<String, Vec<(String, String)>> = HashMap::new();
for entry in reg.entries() {
let qualifier = entry
.entity
.fields
.iter()
.find(|(k, _)| k == "qualifier")
.and_then(|(_, v)| match v {
FieldValue::Single(s) => Some(s.as_str()),
FieldValue::List(_) => None,
});
if let Some(q) = qualifier {
by_lower
.entry(q.to_lowercase())
.or_default()
.push((q.to_string(), entry.path.display().to_string()));
}
}
let mut inconsistencies = 0usize;
for occurrences in by_lower.values() {
let first = &occurrences[0].0;
let inconsistent: Vec<_> = occurrences.iter().filter(|(q, _)| q != first).collect();
if !inconsistent.is_empty() {
inconsistencies += 1;
eprintln!(
"warning: inconsistent qualifier casing for {:?}:",
occurrences[0].0
);
for (q, path) in occurrences {
eprintln!(" {path}: {q:?}");
}
}
}
if strict && inconsistencies > 0 {
eprintln!(
"error: {inconsistencies} qualifier consistency warning(s) treated as errors (--strict)"
);
*exit_code = 1;
}
}
/// Load redirect `from:` slugs from `redirects.yaml` in the content root.
fn load_redirect_slugs(content_root: &Path) -> HashSet<String> {
let path = content_root.join("redirects.yaml");
let Ok(content) = std::fs::read_to_string(&path) else {
return HashSet::new();
};
let mut slugs = HashSet::new();
for line in content.lines() {
if let Some(from) = line.strip_prefix(" - from: ") {
slugs.insert(from.trim().to_string());
}
}
slugs
}
fn check_redirect_collisions(redirect_slugs: &HashSet<String>, content_root: &Path) -> usize {
if redirect_slugs.is_empty() {
return 0;
}
let mut errors = 0;
let root_str = content_root.to_string_lossy();
for slug in redirect_slugs {
let file_path = content_root.join(format!("{slug}.md"));
if file_path.exists() {
eprintln!(
"{root_str}/{slug}.md: error: file at old redirected path (see redirects.yaml)"
);
errors += 1;
}
}
errors
}
// ── Verify ──────────────────────────────────────────────────────────────────
/// Configuration for URL verification.
pub struct VerifyConfig {
/// Maximum concurrent requests.
pub concurrency: usize,
/// Per-URL timeout in seconds.
pub timeout: u64,
/// Path to URL verification cache file.
pub cache_path: Option<String>,
/// Report all as warnings, never fail.
pub warn_only: bool,
/// Maximum concurrent requests per domain.
pub domain_concurrency: usize,
/// Maximum retries for transient failures (429, 503, timeout).
pub retries: usize,
}
/// Run URL verification on content files.
///
/// Collects all URLs globally across all cases, deduplicates, verifies in a
/// single multi-threaded async pass with per-domain rate limiting, then
/// reports results per file.
///
/// Returns exit code (0 = success).
#[allow(clippy::too_many_lines)]
pub fn verify(path: Option<&str>, root: Option<&str>, config: &VerifyConfig) -> i32 {
let content_root = resolve_content_root(path, root);
let reg = match load_registry(&content_root) {
Ok(r) => r,
Err(code) => return code,
};
let case_files = match resolve_case_files(path, &content_root) {
Ok(f) => f,
Err(code) => return code,
};
if case_files.is_empty() {
eprintln!("no case files found");
return 1;
}
// Phase 1: Parse all case files in parallel and collect URLs globally
let mut all_urls: Vec<verifier::UrlEntry> = Vec::new();
let mut url_to_files: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
let mut file_url_counts: Vec<(String, usize)> = Vec::new();
let mut parse_exit_code = 0;
let parse_results: Vec<(String, Result<Vec<verifier::UrlEntry>, i32>)> = case_files
.iter()
.map(|case_path| {
let result = collect_case_urls(case_path, &reg);
(case_path.clone(), result)
})
.collect();
for (case_path, result) in parse_results {
match result {
Ok(urls) => {
file_url_counts.push((case_path.clone(), urls.len()));
for entry in urls {
url_to_files
.entry(entry.url().to_string())
.or_default()
.push(case_path.clone());
all_urls.push(entry);
}
}
Err(code) => {
parse_exit_code = code;
}
}
}
// Add registry thumbnail URLs
let registry_urls = verifier::collect_registry_urls(&reg);
let registry_url_count = registry_urls.len();
for entry in registry_urls {
url_to_files
.entry(entry.url().to_string())
.or_default()
.push("(registry)".to_string());
all_urls.push(entry);
}
// Global dedup: keep thumbnail flag if any entry is thumbnail
let mut seen: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
for entry in &all_urls {
let is_thumb = seen.entry(entry.url().to_string()).or_insert(false);
if entry.is_thumbnail() {
*is_thumb = true;
}
}
let deduped_urls: Vec<verifier::UrlEntry> = seen
.into_iter()
.map(|(url, is_thumbnail)| verifier::UrlEntry::new(url, is_thumbnail))
.collect();
let total_urls = deduped_urls.len();
eprintln!(
"verify: {} case(s), {} registry URLs, {} unique URLs (deduped from {})",
case_files.len(),
registry_url_count,
total_urls,
all_urls.len()
);
if total_urls == 0 && parse_exit_code == 0 {
eprintln!("no URLs to verify");
return 0;
}
// Phase 2: Cache partition
let verify_cache = load_verify_cache("global", config.cache_path.as_deref());
let (cached_results, urls_to_check) = partition_cached(&deduped_urls, verify_cache.as_ref());
let check_count = urls_to_check.len();
let cached_count = cached_results.len();
eprintln!(
"verify: {cached_count} cached, {check_count} to check \
(concurrency={}, domain_concurrency={}, timeout={}s, retries={})",
config.concurrency, config.domain_concurrency, config.timeout, config.retries
);
// Phase 3: Single multi-threaded runtime for all URL checks
let fresh_results = if urls_to_check.is_empty() {
Vec::new()
} else {
let rt = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
eprintln!("failed to create async runtime: {e}");
return 2;
}
};
rt.block_on(verifier::verify_urls(
urls_to_check,
config.concurrency,
config.timeout,
config.domain_concurrency,
config.retries,
))
};
// Update cache with fresh results
if let Some(ref vc) = verify_cache {
for check in &fresh_results {
vc.put(&check.url, check.status, check.detail.as_deref());
}
}
// Merge all results
let mut results_by_url: std::collections::HashMap<String, &verifier::UrlCheck> =
std::collections::HashMap::new();
for check in &cached_results {
results_by_url.insert(check.url.clone(), check);
}
for check in &fresh_results {
results_by_url.insert(check.url.clone(), check);
}
// Phase 4: Report results per file
let mut exit_code = parse_exit_code;
for (case_path, url_count) in &file_url_counts {
if *url_count == 0 {
continue;
}
let file_urls: Vec<&str> = url_to_files
.iter()
.filter(|(_, files)| files.contains(case_path))
.map(|(url, _)| url.as_str())
.collect();
let file_results: Vec<&verifier::UrlCheck> = file_urls
.iter()
.filter_map(|url| results_by_url.get(*url).copied())
.collect();
let has_error = print_file_results(case_path, &file_results);
if has_error && !config.warn_only {
exit_code = 1;
}
}
// Registry results
if registry_url_count > 0 {
let reg_urls: Vec<&str> = url_to_files
.iter()
.filter(|(_, files)| files.contains(&"(registry)".to_string()))
.map(|(url, _)| url.as_str())
.collect();
let reg_results: Vec<&verifier::UrlCheck> = reg_urls
.iter()
.filter_map(|url| results_by_url.get(*url).copied())
.collect();
let has_error = print_file_results("(registry)", &reg_results);
if has_error && !config.warn_only {
exit_code = 1;
}
}
// Global summary
let all_results: Vec<&verifier::UrlCheck> = results_by_url.values().copied().collect();
let ok_count = all_results
.iter()
.filter(|c| c.status == verifier::CheckStatus::Ok)
.count();
let rate_limited_count = all_results
.iter()
.filter(|c| c.status == verifier::CheckStatus::RateLimited)
.count();
let suspicious_count = all_results
.iter()
.filter(|c| c.status == verifier::CheckStatus::Suspicious)
.count();
let warn_count = all_results
.iter()
.filter(|c| c.status == verifier::CheckStatus::Warn)
.count();
let err_count = all_results
.iter()
.filter(|c| c.status == verifier::CheckStatus::Error)
.count();
eprintln!(
"\nverify: {total_urls} total, {ok_count} ok, {rate_limited_count} rate_limited, {suspicious_count} suspicious, {warn_count} warn, {err_count} error"
);
// Print categorized URL lists to stdout for downstream processing
print_url_list(
"rate_limited",
&all_results,
verifier::CheckStatus::RateLimited,
);
print_url_list(
"suspicious",
&all_results,
verifier::CheckStatus::Suspicious,
);
print_url_list("errors", &all_results, verifier::CheckStatus::Error);
print_url_list("warn", &all_results, verifier::CheckStatus::Warn);
// Save cache once
if let Some(ref vc) = verify_cache
&& let Err(e) = vc.save()
{
eprintln!("cache save warning: {e}");
}
exit_code
}
/// Parse a single case file and collect its URLs (no HTTP).
fn collect_case_urls(
path: &str,
reg: &registry::EntityRegistry,
) -> Result<Vec<verifier::UrlEntry>, i32> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("{path}: error reading file: {e}");
return Err(2);
}
};
let (case, entities, rels) = match parse_full(&content, Some(reg)) {
Ok(result) => result,
Err(errors) => {
for err in &errors {
eprintln!("{path}:{err}");
}
return Err(1);
}
};
let mut collect_errors = Vec::new();
let urls = verifier::collect_urls(&case.sources, &entities, &rels, &mut collect_errors);
if !collect_errors.is_empty() {
for err in &collect_errors {
eprintln!("{path}:{err}");
}
return Err(1);
}
Ok(urls)
}
/// Print results for a single file/label and return whether any errors were found.
fn print_file_results(label: &str, results: &[&verifier::UrlCheck]) -> bool {
let mut has_error = false;
for check in results {
let detail = check.detail.as_deref().unwrap_or("");
match check.status {
verifier::CheckStatus::Ok => {}
verifier::CheckStatus::RateLimited => {
eprintln!(" 429 {} -- {detail}", check.url);
}
verifier::CheckStatus::Suspicious => {
eprintln!(" SUS {} -- {detail}", check.url);
}
verifier::CheckStatus::Warn => {
eprintln!(" warn {} -- {detail}", check.url);
}
verifier::CheckStatus::Error => {
has_error = true;
eprintln!(" ERROR {} -- {detail}", check.url);
}
}
}
let ok_count = results
.iter()
.filter(|c| c.status == verifier::CheckStatus::Ok)
.count();
let rate_limited_count = results
.iter()
.filter(|c| c.status == verifier::CheckStatus::RateLimited)
.count();
let suspicious_count = results
.iter()
.filter(|c| c.status == verifier::CheckStatus::Suspicious)
.count();
let warn_count = results
.iter()
.filter(|c| c.status == verifier::CheckStatus::Warn)
.count();
let err_count = results
.iter()
.filter(|c| c.status == verifier::CheckStatus::Error)
.count();
if rate_limited_count > 0 || suspicious_count > 0 || warn_count > 0 || err_count > 0 {
eprintln!(
"{label}: {ok_count} ok, {rate_limited_count} rate_limited, {suspicious_count} suspicious, {warn_count} warn, {err_count} error"
);
}
has_error
}
/// Print a categorized list of URLs to stdout for downstream processing.
fn print_url_list(label: &str, results: &[&verifier::UrlCheck], status: verifier::CheckStatus) {
let urls: Vec<&str> = results
.iter()
.filter(|c| c.status == status)
.map(|c| c.url.as_str())
.collect();
if urls.is_empty() {
return;
}
println!("\n# {label} ({} URLs)", urls.len());
for url in &urls {
println!("{url}");
}
}
fn load_verify_cache(label: &str, cache_path: Option<&str>) -> Option<crate::cache::VerifyCache> {
cache_path.map(|p| match crate::cache::VerifyCache::load(p) {
Ok(c) => {
eprintln!("{label}: using cache {p}");
c
}
Err(e) => {
eprintln!("{label}: cache load warning: {e}");
crate::cache::VerifyCache::load("/dev/null")
.unwrap_or_else(|_| crate::cache::VerifyCache::empty())
}
})
}
fn partition_cached(
urls: &[verifier::UrlEntry],
verify_cache: Option<&crate::cache::VerifyCache>,
) -> (Vec<verifier::UrlCheck>, Vec<verifier::UrlEntry>) {
let Some(vc) = verify_cache else {
return (Vec::new(), urls.to_vec());
};
let mut cached = Vec::new();
let mut uncached = Vec::new();
for entry in urls {
if let Some(cache_entry) = vc.get(entry.url()) {
let status = match cache_entry.status.as_str() {
"ok" => verifier::CheckStatus::Ok,
"rate_limited" => verifier::CheckStatus::RateLimited,
"suspicious" => verifier::CheckStatus::Suspicious,
"warn" => verifier::CheckStatus::Warn,
_ => verifier::CheckStatus::Error,
};
cached.push(verifier::UrlCheck {
url: entry.url().to_string(),
status,
detail: cache_entry.detail.clone(),
is_thumbnail: entry.is_thumbnail(),
});
} else {
uncached.push(entry.clone());
}
}
(cached, uncached)
}
// ── Check Staleness ─────────────────────────────────────────────────────────
/// Configuration for staleness checking.
pub struct StalenessConfig {
/// Months before an `under_investigation` case is considered stale.
pub investigation_months: u32,
/// Months before a `trial` case is considered stale.
pub trial_months: u32,
/// Months before an `appeal` case is considered stale.
pub appeal_months: u32,
}
impl Default for StalenessConfig {
fn default() -> Self {
Self {
investigation_months: 6,
trial_months: 12,
appeal_months: 12,
}
}
}
/// Run staleness checks on content files.
///
/// Returns exit code (0 = success, 1 = errors found).
pub fn check_staleness(path: Option<&str>, root: Option<&str>, config: &StalenessConfig) -> i32 {
let content_root = resolve_content_root(path, root);
let reg = match load_registry(&content_root) {
Ok(r) => r,
Err(code) => return code,
};
let case_files = match resolve_case_files(path, &content_root) {
Ok(f) => f,
Err(code) => return code,
};
if case_files.is_empty() {
eprintln!("no case files found");
return 1;
}
let thresholds = staleness::Thresholds {
investigation_months: config.investigation_months,
trial_months: config.trial_months,
appeal_months: config.appeal_months,
};
let now = chrono_today();
let mut all_findings: Vec<(String, staleness::Finding)> = Vec::new();
for case_path in &case_files {
let content = match std::fs::read_to_string(case_path) {
Ok(c) => c,
Err(e) => {
eprintln!("{case_path}: error reading file: {e}");
continue;
}
};
let (case, entities, _rels) = match parse_full(&content, Some(&reg)) {
Ok(result) => result,
Err(errors) => {
for err in &errors {
eprintln!("{case_path}:{err}");
}
continue;
}
};
let findings = staleness::check_case(&case, &entities, &thresholds, now);
for finding in findings {
all_findings.push((case_path.clone(), finding));
}
}
all_findings.sort_by_key(|a| a.1.severity);
let mut errors = 0u32;
let mut warnings = 0u32;
let mut infos = 0u32;
for (path, finding) in &all_findings {
eprintln!("{}: {path}: {}", finding.severity, finding.message);
match finding.severity {
staleness::Severity::Error => errors += 1,
staleness::Severity::Warning => warnings += 1,
staleness::Severity::Info => infos += 1,
}
}
eprintln!(
"staleness: {errors} error(s), {warnings} warning(s), {infos} info(s) across {} case(s)",
case_files.len()
);
i32::from(errors > 0)
}
/// Get today's date as (year, month, day).
fn chrono_today() -> (i32, u32, u32) {
let now = std::time::SystemTime::now();
let since_epoch = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let days = since_epoch.as_secs() / 86400;
days_to_date(days)
}
/// Configuration for entity enrichment checking.
#[derive(Debug, Clone, Default)]
pub struct EnrichmentConfig {
/// Filter by entity label (person, organization). None = all.
pub label_filter: Option<String>,
/// Print full entity lists (all names, not just top 10).
pub verbose: bool,
}
/// A missing-field entry for one entity.
struct MissingEntry {
name: String,
slug: String,
missing_desc: bool,
missing_urls: bool,
missing_thumb: bool,
}
/// Check people and organizations for missing enrichment fields.
///
/// Writes a machine-readable line per entity to stdout (for log files)
/// and a human-readable summary to stderr.
///
/// Returns exit code (0 = success).
pub fn check_entity_enrichment(
path: Option<&str>,
root: Option<&str>,
config: &EnrichmentConfig,
) -> i32 {
let content_root = resolve_content_root(path, root);
let reg = match load_registry(&content_root) {
Ok(r) => r,
Err(code) => return code,
};
if reg.is_empty() {
eprintln!("registry: no entities found");
return 0;
}
eprintln!("registry: {} entities loaded", reg.len());
let mut people_entries: Vec<MissingEntry> = Vec::new();
let mut org_entries: Vec<MissingEntry> = Vec::new();
for entry in reg.entries() {
let entity = &entry.entity;
let slug = reg.slug_for(entry).unwrap_or_default();
let has_desc = entity.fields.iter().any(|(k, _)| k == "description");
let has_urls = entity.fields.iter().any(|(k, _)| k == "urls");
let has_thumb = entity.fields.iter().any(|(k, _)| k == "thumbnail");
let missing_desc = !has_desc;
let missing_urls = !has_urls;
let missing_thumb = !has_thumb;
if !missing_desc && !missing_urls && !missing_thumb {
continue;
}
let me = MissingEntry {
name: entity.name.clone(),
slug,
missing_desc,
missing_urls,
missing_thumb,
};
match entity.label {
entity::Label::Person => {
people_entries.push(me);
}
entity::Label::Organization => {
org_entries.push(me);
}
_ => {}
}
}
let total_people = reg
.entries()
.iter()
.filter(|e| e.entity.label == entity::Label::Person)
.count();
let total_orgs = reg
.entries()
.iter()
.filter(|e| e.entity.label == entity::Label::Organization)
.count();
eprintln!();
eprintln!("=== Entity Enrichment Summary ===");
eprintln!();
match config.label_filter.as_deref() {
Some("person" | "people") => {
print_enrichment_group("People", total_people, &people_entries, config.verbose);
}
Some("organization" | "organizations" | "org" | "orgs") => {
print_enrichment_group("Organizations", total_orgs, &org_entries, config.verbose);
}
None | Some(_) => {
print_enrichment_group("People", total_people, &people_entries, config.verbose);
eprintln!();
print_enrichment_group("Organizations", total_orgs, &org_entries, config.verbose);
}
}
0
}
fn print_enrichment_group(label: &str, total: usize, entries: &[MissingEntry], verbose: bool) {
let missing_desc_count = entries.iter().filter(|e| e.missing_desc).count();
let missing_urls_count = entries.iter().filter(|e| e.missing_urls).count();
let missing_thumb_count = entries.iter().filter(|e| e.missing_thumb).count();
eprintln!("{label}:");
eprintln!(" Total: {total}");
eprintln!(" Incomplete: {}", entries.len());
eprintln!(" Missing description: {missing_desc_count}");
eprintln!(" Missing urls: {missing_urls_count}");
eprintln!(" Missing thumbnail: {missing_thumb_count}");
let limit = if verbose { usize::MAX } else { 10 };
if missing_desc_count > 0 {
eprintln!();
eprintln!(" Missing description:");
for entry in entries.iter().filter(|e| e.missing_desc).take(limit) {
eprintln!(" - {} ({})", entry.name, entry.slug);
}
let remaining = missing_desc_count.saturating_sub(limit);
if remaining > 0 {
eprintln!(" ... and {remaining} more (use --verbose for full list)");
}
}
if missing_urls_count > 0 {
eprintln!();
eprintln!(" Missing urls:");
for entry in entries.iter().filter(|e| e.missing_urls).take(limit) {
eprintln!(" - {} ({})", entry.name, entry.slug);
}
let remaining = missing_urls_count.saturating_sub(limit);
if remaining > 0 {
eprintln!(" ... and {remaining} more (use --verbose for full list)");
}
}
if missing_thumb_count > 0 {
eprintln!();
eprintln!(" Missing thumbnail:");
for entry in entries.iter().filter(|e| e.missing_thumb).take(limit) {
eprintln!(" - {} ({})", entry.name, entry.slug);
}
let remaining = missing_thumb_count.saturating_sub(limit);
if remaining > 0 {
eprintln!(" ... and {remaining} more (use --verbose for full list)");
}
}
// Machine-readable output to stdout (one line per entity, for log files)
for entry in entries {
let mut missing = Vec::new();
if entry.missing_desc {
missing.push("description");
}
if entry.missing_urls {
missing.push("urls");
}
if entry.missing_thumb {
missing.push("thumbnail");
}
println!(
"{}: {} ({}): missing {}",
label.to_lowercase(),
entry.name,
entry.slug,
missing.join(", ")
);
}
}
/// Convert days since Unix epoch to (year, month, day).
/// Uses the algorithm from Howard Hinnant's date library.
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::similar_names
)]
fn days_to_date(days: u64) -> (i32, u32, u32) {
let z = i64::from(u32::try_from(days).unwrap_or(u32::MAX)) + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = (i64::from(yoe) + era * 400) as i32;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_to_date_epoch() {
assert_eq!(days_to_date(0), (1970, 1, 1));
}
#[test]
fn days_to_date_known() {
// 2025-01-01 = day 20089
assert_eq!(days_to_date(20089), (2025, 1, 1));
}
#[test]
fn redirect_slugs_empty_on_missing_file() {
let slugs = load_redirect_slugs(Path::new("/nonexistent"));
assert!(slugs.is_empty());
}
#[test]
fn staleness_config_defaults() {
let config = StalenessConfig::default();
assert_eq!(config.investigation_months, 6);
assert_eq!(config.trial_months, 12);
assert_eq!(config.appeal_months, 12);
}
}