Packages
rbt_weave
0.2.64
0.2.67
0.2.66
0.2.65
0.2.64
0.2.63
0.2.62
0.2.61
0.2.60
0.2.59
0.2.58
0.2.57
0.2.56
0.2.55
0.2.54
0.2.53
0.2.52
0.2.51
0.2.50
0.2.49
0.2.48
0.2.47
0.2.46
0.2.45
0.2.44
0.2.43
0.2.42
0.2.41
0.2.40
0.2.39
0.2.38
0.2.37
0.2.36
0.2.35
0.2.34
0.2.33
0.2.32
0.2.31
0.2.30
0.2.29
0.2.28
0.2.27
0.2.26
0.2.25
0.2.24
0.2.23
0.2.22
0.2.21
0.2.20
0.2.19
0.2.18
0.2.17
0.2.16
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Graph conflict detection and image processing NIFs for OSINT knowledge graphs, powered by Rust.
Current section
Files
Jump to
Current section
Files
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, ®, &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(®, 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: ®istry::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: ®istry::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, ®);
(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(®);
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)", ®_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: ®istry::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(®)) {
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);
}
}