use std::fs;
use std::path::Path;
use clap::Subcommand;
use spec_spine_core::shard::{self, BY_PACKAGE_DIR, BY_SPEC_DIR};
use spec_spine_core::{
DiagnosticCounts, Freshness, IndexCheckReport, UnwitnessedCounts, Versioning,
annotate_unreadable, check_slice_freshness, committed_diagnostics, coverage_with_inventory,
empty_universe, index, index_dir, index_freshness_report, index_shard_files,
load_committed_index, load_committed_registry, partition_orphans, read_document,
render_markdown, slices_path, verdict_tally,
};
use spec_spine_types::{
Config, CoverageReport, Enumeration, Error, Inventory, InventoryProvenance, Verdict,
verdict::verb,
};
use crate::load_repo_config;
use crate::out;
#[derive(Subcommand)]
pub enum IndexAction {
Check {
#[arg(long, value_name = "NAME")]
slice: Option<String>,
#[arg(long)]
fail_on_unresolved: bool,
#[arg(long)]
json: bool,
},
Render,
Orphans {
#[arg(long)]
json: bool,
},
Diagnostics {
#[arg(long)]
json: bool,
},
Owner {
path: String,
#[arg(long)]
json: bool,
},
Coverage {
#[arg(long)]
json: bool,
#[arg(long)]
fail_on_untraced: bool,
#[arg(long, value_name = "FILE")]
paths_from: Option<std::path::PathBuf>,
},
}
pub fn run(repo: &Path, action: Option<&IndexAction>) -> Result<u8, Error> {
let cfg = load_repo_config(repo)?;
match action {
Some(IndexAction::Render) => {
let idx = load_committed_index(&cfg, repo)?;
out!("{}", render_markdown(&cfg, &idx));
Ok(0)
}
Some(IndexAction::Orphans { json }) => {
let idx = load_committed_index(&cfg, repo)?;
let records = load_committed_registry(&cfg, repo)
.map(|r| r.specs)
.unwrap_or_default();
let report = partition_orphans(&idx, &records);
if *json {
out!("{}", read_document(&report, Versioning::Stamp)?);
} else if !report.orphaned.is_empty() || !report.in_flight.is_empty() {
outln!("orphaned (claims nothing that resolves, and is not in flight):");
if report.orphaned.is_empty() {
outln!(" (none)");
}
for id in &report.orphaned {
outln!(" {id}");
}
outln!();
outln!("in flight (claims nothing that resolves yet; draft or pending):");
if report.in_flight.is_empty() {
outln!(" (none)");
}
for id in &report.in_flight {
outln!(" {id}");
}
}
Ok(0)
}
Some(IndexAction::Diagnostics { json }) => {
let diags = committed_diagnostics(&cfg, repo)?;
if *json {
out!("{}", read_document(&diags, Versioning::Stamp)?);
} else {
for d in &diags {
let at = d.path.as_deref().unwrap_or("-");
outln!(" {} [{}] [{}] {}", d.code, d.spec_id, at, d.message);
}
}
Ok(0)
}
Some(IndexAction::Owner { path, json }) => {
let report = spec_spine_core::owner(&cfg, repo, path)?;
if *json {
out!("{}", read_document(&report, Versioning::Stamp)?);
} else {
outln!("{}", report.path);
if report.owners.is_empty() {
outln!(" (no spec owns this path)");
}
let width = report
.owners
.iter()
.map(|o| o.spec_id.chars().count())
.max()
.unwrap_or(0);
for o in &report.owners {
outln!(
" {:<width$} {:<9} {}",
o.spec_id,
owner_kind_label(o.kind),
o.claim,
width = width
);
}
}
Ok(0)
}
Some(IndexAction::Coverage {
json,
fail_on_untraced,
paths_from,
}) => {
let inventory = if cfg.coverage.governed_scope.is_empty() {
None
} else {
Some(match paths_from {
Some(file) => supplied_inventory(file)?,
None => tracked_inventory(repo)?,
})
};
let report = coverage_with_inventory(&cfg, repo, inventory.as_ref())?;
if *json {
out!("{}", read_document(&report, Versioning::Stamp)?);
} else {
out!("{}", render_coverage(&report));
}
if let (true, Some(reason)) = (*fail_on_untraced, empty_universe(&report)) {
eprintln!(
"coverage: {}.\n--fail-on-untraced asserts that every source file has a \
specific owning spec, and there are none to assert about. Nothing was \
verified.",
reason.explain()
);
return Ok(1);
}
Ok(if *fail_on_untraced && !report.is_fully_claimed() {
1
} else {
0
})
}
Some(IndexAction::Check {
slice,
fail_on_unresolved,
json,
}) => {
let (freshness, partition, subject) = match slice {
Some(name) => (
check_slice_freshness(&cfg, repo, name)?,
None,
format!("slice '{name}'"),
),
None => {
let report = index_freshness_report(&cfg, repo)?;
(report.freshness(), Some(report), "index".to_string())
}
};
let counts = verdict_tally(&cfg, repo);
let unwitnessed = spec_spine_core::unwitnessed_counts(&cfg, repo);
let code = if partition.as_ref().is_some_and(|p| !p.blocking.is_empty()) {
1
} else if matches!(freshness, Freshness::Fresh) {
if *fail_on_unresolved && counts.has_unresolved() {
1
} else {
0
}
} else {
2
};
if *json {
let report = serde_json::to_value(IndexCheckReport::with_unwitnessed(
&freshness,
counts.clone(),
unwitnessed,
))
.map_err(|e| Error::Schema(e.to_string()))?;
out::verdict(&Verdict::report(verb::INDEX_CHECK, code, report))?;
return Ok(code);
}
match freshness {
Freshness::Fresh if code == 1 => {
outln!(
"{subject} is fresh; --fail-on-unresolved refuses{}",
counts_suffix(&counts)
);
report_unwitnessed(&unwitnessed);
}
Freshness::Fresh => {
outln!("{subject} is fresh{}", counts_suffix(&counts));
report_unwitnessed(&unwitnessed);
}
Freshness::Stale { .. }
if partition.as_ref().is_some_and(|p| !p.blocking.is_empty()) =>
{
let p = partition.as_ref().expect("guarded above");
if !p.stale.is_empty() {
eprintln!("{subject} is STALE (run `spec-spine index` to refresh)");
if let Freshness::Stale { actual, .. } = p.stale_verdict() {
eprintln!("{}", annotate_unreadable(&actual, &counts.unreadable));
}
}
eprintln!(
"{subject}: UNRESOLVED CLAIM: {}",
p.unresolved_claim_summary()
);
for line in p.unresolved_claim_lines() {
eprintln!("{line}");
}
}
Freshness::Stale { expected, actual } => {
eprintln!("{subject} is STALE (run `spec-spine index` to refresh)");
if slice.is_some() {
eprintln!(" expected: {expected}");
eprintln!(" actual: {actual}");
} else {
eprintln!("{}", annotate_unreadable(&actual, &counts.unreadable));
}
if !counts.is_empty() {
eprintln!(
" the stale ledger also records {}",
counts_summary(&counts)
);
}
}
}
Ok(code)
}
None => {
let outcome = index(&cfg, repo)?;
let dir = index_dir(&cfg, repo);
let (by_spec, by_package) = index_shard_files(&outcome.shards)?;
let run = shard::DerivedWrites::new(repo)
.sync_dir(&dir.join(BY_SPEC_DIR), by_spec)
.sync_dir(&dir.join(BY_PACKAGE_DIR), by_package);
slices_output(run, &cfg, repo, &outcome.index.build.slice_hashes)?
.remove(&dir, "index.json")
.apply()?;
let idx = &outcome.index;
for diag in idx
.diagnostics
.errors
.iter()
.chain(idx.diagnostics.warnings.iter())
{
let at = diag.path.as_deref().unwrap_or("-");
eprintln!(" {} [{}] {}", diag.code, at, diag.message);
}
outln!(
"indexed {} package(s), {} mapping(s) -> {} ({} error diagnostic(s), {} warning(s))",
idx.packages.len(),
idx.traceability.mappings.len(),
dir.display(),
idx.diagnostics.errors.len(),
idx.diagnostics.warnings.len()
);
Ok(0)
}
}
}
fn counts_suffix(counts: &DiagnosticCounts) -> String {
if counts.is_empty() {
return String::new();
}
format!(" ({})", counts_summary(counts))
}
fn enumeration_label(e: Enumeration) -> &'static str {
match e {
Enumeration::Tracked => "tracked",
Enumeration::Supplied => "supplied",
Enumeration::Walk => "walk",
}
}
fn counts_summary(counts: &DiagnosticCounts) -> String {
let by_code = counts
.by_code
.iter()
.map(|(code, n)| format!("{n} {code}"))
.collect::<Vec<_>>()
.join(", ");
format!(
"{} warning(s), {} error(s): {by_code}",
counts.warnings, counts.errors
)
}
fn supplied_inventory(file: &Path) -> Result<Inventory, Error> {
let text = fs::read_to_string(file)
.map_err(|e| Error::Io(format!("read --paths-from {}: {e}", file.display())))?;
Ok(Inventory {
provenance: InventoryProvenance::Supplied,
paths: text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect(),
})
}
fn tracked_inventory(repo: &Path) -> Result<Inventory, Error> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args([
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
])
.output()
.map_err(|e| {
Error::Io(format!(
"[coverage] governed_scope needs the tracked-file list, and git could not be \
run ({e}); run inside a git repository, or pass `--paths-from FILE`"
))
})?;
if !out.status.success() {
return Err(Error::Io(format!(
"[coverage] governed_scope needs the tracked-file list, and `git ls-files` exited \
{:?}: {}; run inside a git repository, or pass `--paths-from FILE`",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
)));
}
let mut paths: Vec<String> = out
.stdout
.split(|b| *b == 0)
.filter(|p| !p.is_empty())
.map(|p| String::from_utf8_lossy(p).into_owned())
.filter(|p| fs::symlink_metadata(repo.join(p)).is_ok())
.collect();
paths.sort();
paths.dedup();
Ok(Inventory {
provenance: InventoryProvenance::Tracked,
paths,
})
}
fn render_coverage(report: &CoverageReport) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let total = report.source_files;
if total == 0 {
out.push_str("coverage: no source files under any discovered package\n");
return out;
}
let pct = (report.claimed_files as f64) * 100.0 / (total as f64);
let _ = writeln!(
out,
"coverage: {}/{total} source files specifically claimed ({pct:.1}%); {} floor-only, {} unclaimed",
report.claimed_files,
report.floor_only_files.len(),
report.unclaimed_files.len()
);
if let (Some(declared), Some(enumeration)) = (&report.declared_scope_files, report.enumeration)
{
let unclaimed = declared
.iter()
.filter(|f| report.unclaimed_files.contains(f) || report.floor_only_files.contains(f))
.count();
let _ = writeln!(
out,
" declared scope ({}): {} file(s) outside the package totals, {} claimed, {} unclaimed",
enumeration_label(enumeration),
declared.len(),
declared.len() - unclaimed,
unclaimed
);
}
if !report.planned_territory.is_empty() {
let _ = writeln!(
out,
" planned (declared, not yet written): {}",
report.planned_territory.len()
);
for entry in &report.planned_territory {
let _ = writeln!(out, " {entry}");
}
}
if !report.near_miss_headers.is_empty() {
let _ = writeln!(
out,
" near-miss comment headers (claimed nothing): {}",
report.near_miss_headers.len()
);
for m in &report.near_miss_headers {
let spec = m
.spec_id
.as_deref()
.map(|s| format!(" (names {s})"))
.unwrap_or_default();
let _ = writeln!(out, " {}:{} {}{spec}", m.path, m.line, m.reason.as_str());
}
}
for p in &report.packages {
let path = if p.path.is_empty() {
"."
} else {
p.path.as_str()
};
let floor = p
.floor_spec
.as_deref()
.map(|s| format!("floor {s}"))
.unwrap_or_else(|| "no floor".to_string());
let _ = writeln!(
out,
" {path} ({floor}): {}/{} claimed, {} floor-only, {} unclaimed",
p.claimed_files, p.source_files, p.floor_only, p.unclaimed
);
}
if !report.floor_only_files.is_empty() {
out.push_str("\nfloor-only (owned only by a package floor; claim in a spec):\n");
for f in &report.floor_only_files {
let _ = writeln!(out, " {f}");
}
}
if !report.unclaimed_files.is_empty() {
out.push_str("\nunclaimed (no owning spec):\n");
for f in &report.unclaimed_files {
let _ = writeln!(out, " {f}");
}
}
out
}
fn slices_output(
run: shard::DerivedWrites,
cfg: &Config,
repo: &Path,
slice_hashes: &std::collections::BTreeMap<String, String>,
) -> Result<shard::DerivedWrites, Error> {
let path = slices_path(cfg, repo);
let dir = path.parent().unwrap_or(repo);
let name = "slices.json";
if slice_hashes.is_empty() {
return Ok(run.remove(dir, name));
}
let json = serde_json::to_string_pretty(slice_hashes)
.map_err(|e| Error::Schema(e.to_string()))?
+ "\n";
Ok(run.write(dir, name, json))
}
fn owner_kind_label(kind: spec_spine_core::OwnerKind) -> &'static str {
match kind {
spec_spine_core::OwnerKind::Unit => "unit",
spec_spine_core::OwnerKind::Floor => "floor",
spec_spine_core::OwnerKind::Header => "header",
spec_spine_core::OwnerKind::Inherited => "inherited",
}
}
fn report_unwitnessed(u: &UnwitnessedCounts) {
if u.total == 0 {
return;
}
if u.allowed == 0 {
outln!(" unwitnessed claims: {}", u.total);
} else {
outln!(
" unwitnessed claims: {} ({} allowed by [lint] unwitnessed_allowed)",
u.total,
u.allowed
);
}
}