use std::path::{Path, PathBuf};
use anyhow::{anyhow, bail, Context, Result};
use clap::{Args, Parser, Subcommand};
use nornir::bench;
use nornir::config::{self, Loaded};
use nornir::docs;
use nornir::guard;
use nornir::index;
use nornir::introspect;
use nornir::release;
use nornir::warehouse;
#[derive(Parser)]
#[command(name = "nornir", version, about = "Companion to cargo: release, bench, docs, guard.")]
struct Cli {
#[arg(long, global = true)]
config: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Guard {
#[command(subcommand)]
op: GuardOp,
},
Bench {
#[command(subcommand)]
op: BenchOp,
},
Release {
#[command(subcommand)]
op: ReleaseOp,
},
Docs {
#[command(subcommand)]
op: DocsOp,
},
Introspect {
#[command(subcommand)]
op: IntrospectOp,
},
Warehouse {
#[command(subcommand)]
op: WarehouseOp,
},
Index {
#[command(subcommand)]
op: IndexOp,
},
#[cfg(feature = "vector")]
Vector {
#[command(subcommand)]
op: VectorOp,
},
Knowledge {
#[command(subcommand)]
op: KnowledgeOp,
},
Repos,
}
#[derive(Subcommand)]
enum KnowledgeOp {
Scan {
repo: String,
#[arg(long)]
persist: bool,
},
Query {
repo: String,
kind: String,
arg: String,
#[arg(long)]
to: Option<String>,
#[arg(long, default_value_t = 50)]
limit: usize,
},
}
#[derive(Subcommand)]
enum GuardOp {
Status,
Apply,
Verify,
Release,
}
#[derive(Subcommand)]
enum BenchOp {
HistoryShow(RepoArg),
Run(RepoArg),
}
#[derive(Subcommand)]
enum ReleaseOp {
GatePathPatches(RepoArg),
GatePathDepVersions(RepoArg),
GateCrateMetadata(RepoArg),
GateLinksConflicts(RepoArg),
GateNexusFloor(RepoArg),
GateNoRegression(RepoArg),
GateDocsFresh(RepoArg),
GateRoundtrip(RepoArg),
GateAll(RepoArg),
Trace {
repo: String,
#[arg(long, default_value = "")]
workspace: String,
#[arg(long)]
json: bool,
},
Run(ReleaseRunArgs),
Publish(PublishArgs),
WaitForIndex {
krate: String,
version: String,
#[arg(long, default_value_t = 300)]
timeout_secs: u64,
},
TarballStats {
repo: String,
krate: String,
},
StripPatchBlocks(RepoArg),
Changelog {
repo: String,
range: String,
},
ImpactedCrates {
repo: String,
#[arg(long, default_value = "main")]
base: String,
},
YankCascade {
repo: String,
#[arg(long)]
undo: bool,
#[arg(long)]
dry_run: bool,
},
MirrorToHolger {
repo: String,
krate: String,
version: String,
#[arg(long)]
holger_base_url: String,
#[arg(long)]
token: Option<String>,
},
BumpVersion {
repo: String,
pkg: String,
new_version: String,
#[arg(long)]
bump_consumers: bool,
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand)]
enum DocsOp {
Init(RepoArg),
Render(RepoArg),
Check(RepoArg),
#[cfg(feature = "docs-export")]
Export(DocsExportArgs),
#[cfg(feature = "docs-export")]
Book(DocsBookArgs),
History(DocsHistoryArgs),
Index(DocsIndexArgs),
Search(DocsSearchArgs),
}
#[derive(Args)]
struct DocsIndexArgs {
#[command(flatten)]
repo: RepoArg,
#[arg(long)]
skip_snapshot: bool,
}
#[derive(Args)]
struct DocsSearchArgs {
#[command(flatten)]
repo: RepoArg,
query: String,
#[arg(long)]
sha: Option<String>,
#[arg(long, default_value_t = 10)]
limit: usize,
}
#[derive(Args)]
struct DocsHistoryArgs {
#[command(flatten)]
repo: RepoArg,
#[arg(long)]
doc: Option<String>,
#[arg(long)]
version: Option<String>,
#[arg(long)]
format: Option<String>,
#[arg(long, default_value_t = 20)]
limit: usize,
}
#[cfg(feature = "docs-export")]
#[derive(Args)]
struct DocsExportArgs {
#[command(flatten)]
repo: RepoArg,
#[arg(long, default_value = "pdf")]
format: String,
#[arg(long)]
out: Option<PathBuf>,
}
#[cfg(feature = "docs-export")]
#[derive(Args)]
struct DocsBookArgs {
#[command(flatten)]
repo: RepoArg,
#[arg(long, default_value = "pdf")]
format: String,
#[arg(long)]
out: Option<PathBuf>,
}
#[derive(Args)]
struct DocsFileArgs {
#[command(flatten)]
repo: RepoArg,
}
#[allow(dead_code)] type _DocsFileArgsRef = DocsFileArgs;
#[derive(Subcommand)]
enum IntrospectOp {
Depgraph(RepoArg),
Symbols(SymbolsArgs),
SymbolLookup(SymbolLookupArgs),
DefinedIn(DefinedInArgs),
Callgraph(SymbolsArgs),
CallgraphLlvm(CallgraphLlvmArgs),
Callers(CallQueryArgs),
Callees(CallQueryArgs),
PathBetween(PathArgs),
}
#[derive(Args)]
struct CallQueryArgs {
binary: PathBuf,
name: String,
}
#[derive(Args)]
struct PathArgs {
binary: PathBuf,
from: String,
to: String,
}
#[derive(Args)]
struct SymbolsArgs {
binary: PathBuf,
}
#[derive(Args)]
struct CallgraphLlvmArgs {
path: PathBuf,
#[arg(long)]
crates: Option<String>,
}
#[derive(Args)]
struct SymbolLookupArgs {
binary: PathBuf,
pattern: String,
#[arg(long, default_value_t = 20)]
limit: usize,
}
#[derive(Args)]
struct DefinedInArgs {
binary: PathBuf,
file: String,
#[arg(long, default_value_t = 50)]
limit: usize,
}
#[derive(Subcommand)]
enum WarehouseOp {
ImportJsonl(RepoArg),
Query(WarehouseQueryArgs),
}
#[derive(Args)]
struct WarehouseQueryArgs {
repo: String,
#[arg(long)]
machine: Option<String>,
#[arg(long)]
last: Option<usize>,
}
#[derive(Subcommand)]
enum IndexOp {
Build,
Search(IndexSearchArgs),
Stats,
Snapshot(IndexSnapshotArgs),
Restore(IndexRestoreArgs),
}
#[derive(Args)]
struct IndexSnapshotArgs {
#[arg(long)]
repo: Option<String>,
#[arg(long, default_value = "workspace_holger")]
workspace: String,
}
#[derive(Args)]
struct IndexRestoreArgs {
#[arg(long)]
repo: Option<String>,
#[arg(long)]
sha: Option<String>,
#[arg(long)]
into: Option<PathBuf>,
}
#[derive(Args)]
struct IndexSearchArgs {
query: String,
#[arg(long)]
corpus: Option<String>,
#[arg(long)]
repo: Option<String>,
#[arg(long, default_value_t = 10)]
limit: usize,
}
#[cfg(feature = "vector")]
#[derive(Subcommand)]
enum VectorOp {
Index(VectorIndexArgs),
Search(VectorSearchArgs),
Stats,
}
#[cfg(feature = "vector")]
#[derive(Args)]
struct VectorIndexArgs {
repo: String,
}
#[cfg(feature = "vector")]
#[derive(Args)]
struct VectorSearchArgs {
query: String,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
sha: Option<String>,
#[arg(long, default_value = "semantic")]
mode: String,
#[arg(long, default_value_t = 10)]
limit: usize,
}
#[derive(Args)]
struct RepoArg {
repo: String,
}
#[derive(Args)]
struct PublishArgs {
repo: String,
#[arg(long)]
dry_run: bool,
}
#[derive(Args)]
struct ReleaseRunArgs {
#[arg(long)]
repo: Option<String>,
#[arg(long, default_value = "workspace_holger")]
workspace: String,
#[arg(long)]
skip_tests: bool,
#[arg(long)]
skip_bench: bool,
#[arg(long)]
skip_gates: bool,
#[arg(long)]
skip_render_docs: bool,
#[arg(long)]
skip_snapshot: bool,
#[arg(long)]
skip_push: bool,
#[arg(long)]
skip_publish: bool,
#[arg(long)]
dry_run_publish: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let loaded = load_config(cli.config.as_deref())?;
match cli.cmd {
Cmd::Repos => {
for name in loaded.nornir.repo.keys() {
println!("{name}");
}
}
Cmd::Guard { op } => run_guard(op, &loaded)?,
Cmd::Bench { op } => run_bench(op, &loaded)?,
Cmd::Release { op } => run_release(op, &loaded)?,
Cmd::Docs { op } => run_docs(op, &loaded)?,
Cmd::Introspect { op } => run_introspect(op, &loaded)?,
Cmd::Warehouse { op } => run_warehouse(op, &loaded)?,
Cmd::Index { op } => run_index(op, &loaded)?,
#[cfg(feature = "vector")]
Cmd::Vector { op } => run_vector(op, &loaded)?,
Cmd::Knowledge { op } => run_knowledge(op, &loaded)?,
}
Ok(())
}
fn run_knowledge(op: KnowledgeOp, loaded: &Loaded) -> Result<()> {
match op {
KnowledgeOp::Scan { repo, persist } => {
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &repo);
let res = nornir::knowledge::scan_all(&repo_root, &repo)?;
println!(
"ok: symbols={} calls={} features={} files={}",
res.symbols.symbols.len(),
res.symbols.calls.len(),
res.symbols.features.len(),
res.git.files.len(),
);
let mut top = res.git.files.iter().collect::<Vec<_>>();
top.sort_by_key(|r| -r.commits_total);
for r in top.iter().take(10) {
println!(
" {:60} commits={} 30d={} authors={}",
r.file, r.commits_total, r.commits_30d, r.authors_total
);
}
if persist {
let warehouse_root = loaded.warehouse_root();
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(&warehouse_root)
.with_context(|| format!("open warehouse at {}", warehouse_root.display()))?;
wh.append_symbol_scan(&res.symbols)?;
wh.append_git_heat_scan(&res.git)?;
println!("persisted: symbol_snapshot={} git_snapshot={}",
res.symbols.snapshot_id, res.git.snapshot_id);
}
}
KnowledgeOp::Query { repo, kind, arg, to, limit } => {
let warehouse_root = loaded.warehouse_root();
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(&warehouse_root)
.with_context(|| format!("open warehouse at {}", warehouse_root.display()))?;
let view = nornir::knowledge::query::load_latest(&wh, &repo)?;
match kind.as_str() {
"callers" => {
let hits = view.callers_of(&arg);
println!("callers of `{arg}` ({}):", hits.len());
for c in hits.iter().take(limit) {
println!(" {} → {} [{}:{}]", c.caller_path, c.callee_ident, c.file, c.line);
}
}
"callees" => {
let hits = view.callees_of(&arg);
println!("callees of `{arg}` ({}):", hits.len());
for c in hits.iter().take(limit) {
println!(" {} → {} [{}:{}]", c.caller_path, c.callee_ident, c.file, c.line);
}
}
"defined-in" => {
let hits = view.defined_in(&arg);
println!("symbols defined in `*{arg}` ({}):", hits.len());
for s in hits.iter().take(limit) {
println!(" {:8} {}::{} [{}:{}]", s.item_kind, s.module_path, s.item_name, s.file, s.line);
}
}
"lookup" => {
let hits = view.symbol_lookup(&arg, limit);
println!("symbols matching `{arg}` ({} shown):", hits.len());
for s in &hits {
let sig = s.signature.as_deref().unwrap_or("");
println!(" {:8} {} [{}:{}] {}", s.item_kind, s.item_name, s.file, s.line, sig);
}
}
"path" => {
let to = to.as_deref()
.context("`path` query needs a target: pass --to <function>")?;
match view.call_path(&arg, to) {
Some(p) => {
println!("call path `{arg}` → `{to}` ({} hops):", p.len().saturating_sub(1));
println!(" {}", p.join(" → "));
}
None => println!("no call path from `{arg}` to `{to}`"),
}
}
other => bail!("unknown query kind `{other}` (want: callers|callees|defined-in|lookup|path)"),
}
}
}
Ok(())
}
fn load_config(explicit: Option<&Path>) -> Result<Loaded> {
if let Some(p) = explicit {
return config::load_explicit(p);
}
let cwd = std::env::current_dir()?;
config::discover(&cwd)
}
fn run_guard(op: GuardOp, loaded: &Loaded) -> Result<()> {
let forbidden = &loaded.nornir.guard.forbidden;
if let GuardOp::Verify = op {
let recorded = guard::read_manifest(&loaded.workspace_root)?;
let report = guard::verify(&loaded.workspace_root, &recorded);
println!("recorded_at: {}", recorded.recorded_at);
for v in &report {
if v.ok() {
println!("{:<8} {}", "ok", v.rel);
} else {
println!("{:<8} {} {:?}", "DRIFT", v.rel, v.drift);
}
}
guard::intact(&loaded.workspace_root, &recorded)?;
return Ok(());
}
let report = match op {
GuardOp::Status => guard::status(&loaded.workspace_root, forbidden),
GuardOp::Apply => guard::apply_and_record(&loaded.workspace_root, forbidden)?,
GuardOp::Verify => unreachable!("handled above"),
GuardOp::Release => {
if std::env::var_os("NORNIR_GUARD_UNLOCK_TOKEN").is_none() {
anyhow::bail!(
"guard release (unlock) requires the human-held NORNIR_GUARD_UNLOCK_TOKEN \
env var; refusing to chmod +w protected paths"
);
}
guard::release(&loaded.workspace_root, forbidden)?
}
};
println!("{:<10} {:<10} {:<10} path", "exists", "writable", "changed");
for s in &report {
println!(
"{:<10} {:<10} {:<10} {}",
yn(s.exists), yn(s.writable), yn(s.changed), s.path.display()
);
}
Ok(())
}
fn run_bench(op: BenchOp, loaded: &Loaded) -> Result<()> {
match op {
BenchOp::HistoryShow(a) => {
let repo = repo_or_err(loaded, &a.repo)?;
let path = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo)
.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
for r in bench::history::read_all(&path)? {
println!("{}", serde_json::to_string(&r)?);
}
}
BenchOp::Run(a) => {
let _repo = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
println!("⚡ running nornir-bench in {}", repo_root.display());
let mut run = match release::pipeline::run_bench_example(&repo_root)? {
None => {
println!(
"skipped: neither {}/examples/nornir-bench.rs nor xtask/examples/nornir-bench.rs exists",
repo_root.display(),
);
return Ok(());
}
Some(r) => r,
};
if run.machine.trim().is_empty() {
run.machine = std::env::var("NORNIR_MACHINE").unwrap_or_else(|_| {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".into())
});
}
let wh = warehouse::open(&loaded.nornir.storage, &loaded.workspace_root)?;
let id = wh.append_bench_run(&a.repo, &run)?;
println!(
"✓ {} result(s), {} test(s) persisted into bench_runs as {}",
run.results.len(),
run.tests.len(),
id,
);
for r in &run.results {
let mut kv: Vec<String> = r
.metrics
.iter()
.filter_map(|(k, v)| v.as_f64().map(|f| format!("{k}={f:.2}")))
.collect();
kv.sort();
println!(" • {:30} {}", r.name, kv.join(" "));
}
for t in run.tests.iter().filter(|t| !t.passed) {
println!(" ✗ test {}{}", t.name, t.message.as_deref().map(|m| format!(": {m}")).unwrap_or_default());
}
}
}
Ok(())
}
fn run_release(op: ReleaseOp, loaded: &Loaded) -> Result<()> {
match op {
ReleaseOp::GatePathPatches(a) => {
let _ = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
release::gate::no_path_patches(&repo_root)?;
println!("ok: no [patch.crates-io] znippy entries in {}", repo_root.display());
}
ReleaseOp::GatePathDepVersions(a) => {
let _ = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
let findings = release::gate::path_dep_audit(&repo_root)?;
let bad: Vec<_> = findings.iter().filter(|f| !f.ok()).collect();
for f in &findings {
let tag = if f.ok() { "ok " } else { "MISS" };
println!(" {tag} {} → {} (path={}, version={})",
f.manifest.display(), f.dep_name, f.dep_path,
f.version_req.as_deref().unwrap_or("<none>"));
}
if !bad.is_empty() {
return Err(anyhow!("{} path-dep(s) missing version=", bad.len()));
}
println!("ok: all {} path-dep(s) carry a version=", findings.len());
}
ReleaseOp::GateCrateMetadata(a) => {
let _ = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
let checks = release::gate::crate_metadata_check(&repo_root)?;
let bad: Vec<_> = checks.iter().filter(|c| !c.ok()).collect();
for c in &checks {
let tag = if c.ok() { "ok " } else { "MISS" };
println!(" {tag} {}@{} readme={} license={} repo={} desc={}",
c.crate_name, c.version,
c.has_readme, c.has_license, c.has_repository, c.has_description);
}
if !bad.is_empty() {
return Err(anyhow!("{} crate(s) missing publish-required metadata", bad.len()));
}
println!("ok: all {} crate(s) carry readme+license+repository+description", checks.len());
}
ReleaseOp::GateLinksConflicts(a) => {
let _ = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
let decls = release::gate::links_declarations_scan(&repo_root)?;
let conflicts = release::gate::detect_links_conflicts(&decls);
println!("scanned {} links= declarations", decls.len());
if !conflicts.is_empty() {
for c in &conflicts {
println!(" CONFLICT links={}: {:?}", c.links_value, c.crates);
}
return Err(anyhow!("{} links= conflict(s) detected", conflicts.len()));
}
println!("ok: no links= conflicts");
}
ReleaseOp::GateNexusFloor(a) => {
let _ = repo_or_err(loaded, &a.repo)?;
let run = last_run(loaded, &a.repo)?;
release::gate::nexus_floor(&run)?;
println!("ok: nexus_floor on v{}", run.version);
}
ReleaseOp::GateNoRegression(a) => {
let repo = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
let history = history_path(&repo_root, repo);
let run = last_run(loaded, &a.repo)?;
let pct = if repo.gates.max_regression_pct > 0.0 { repo.gates.max_regression_pct } else { 10.0 };
release::gate::no_regression(&run, &history, pct)?;
println!("ok: no_regression ≤{:.1}% on v{}", pct, run.version);
}
ReleaseOp::GateDocsFresh(a) => {
let _ = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
let run = last_run(loaded, &a.repo).ok();
let ctx = docs::Ctx::new(&repo_root, &loaded.workspace_root, run.as_ref());
let readme = repo_root.join("README.md");
if readme.exists() {
let _ = docs::check_file(&readme, &ctx)?;
}
docs::assemble_and_check(&repo_root, run.as_ref().ok_or_else(|| anyhow!("no bench runs"))?)?;
println!("ok: docs_fresh on {}", repo_root.display());
}
ReleaseOp::GateRoundtrip(a) => {
let repo = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
if repo.gates.integration_roundtrip.is_empty() {
println!("ok: roundtrip not configured for {}", a.repo);
return Ok(());
}
let kinds: Vec<&str> = repo.gates.integration_roundtrip.iter().map(|s| s.as_str()).collect();
release::gate::integration_roundtrip_via_cargo_test(&repo_root, &kinds)?;
println!("ok: roundtrip {:?}", kinds);
}
ReleaseOp::GateAll(a) => {
run_gate_all(loaded, &a.repo)?;
}
ReleaseOp::Run(a) => {
run_release_run(loaded, &a)?;
}
ReleaseOp::Trace { repo, workspace, json } => {
let warehouse_root = loaded.warehouse_root();
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(&warehouse_root)
.with_context(|| format!("open iceberg warehouse at {}", warehouse_root.display()))?;
let graph = wh
.block_on(nornir::warehouse::dep_graph::query_dep_graph_snapshots(&wh, &repo, None))
.ok()
.and_then(|snaps| snaps.into_iter().last())
.map(|snap| {
nornir::warehouse::dep_graph::WorkspaceGraph::from_query_parts(
Default::default(),
snap.edges,
)
});
let trace = wh.block_on(nornir::release::regression::trace_gate_async(
&wh, &workspace, &repo, graph.as_ref(),
))?;
if json {
println!("{}", serde_json::to_string_pretty(&trace)?);
} else {
print_regression_trace(&trace);
}
}
ReleaseOp::Publish(a) => {
let repo = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
let outcomes =
release::publish::publish_all(&repo_root, &repo.publish_order, a.dry_run)?;
for (k, o) in &outcomes {
println!(" {k:40} {o:?}");
}
}
ReleaseOp::WaitForIndex { krate, version, timeout_secs } => {
let waited_ms = release::publish::wait_for_index(
&krate, &version,
std::time::Duration::from_secs(timeout_secs),
)?;
println!("ok: {krate}@{version} visible on crates.io after {waited_ms} ms");
}
ReleaseOp::TarballStats { repo, krate } => {
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &repo);
let s = release::publish::tarball_stats(&repo_root, &krate)?;
let warn = if s.tarball_bytes > release::publish::DEFAULT_TARBALL_BYTES_THRESHOLD {
" WARN: above 5 MB threshold"
} else { "" };
let lf = s.largest_file.as_deref().unwrap_or("(none)");
let lb = s.largest_file_bytes.unwrap_or(0);
println!(
"ok: {}@{} tarball {} bytes, {} files, largest={lf} ({lb} bytes){warn}",
s.crate_name, s.version, s.tarball_bytes, s.file_count
);
}
ReleaseOp::StripPatchBlocks(a) => {
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
let (files, blocks) = release::cargo::strip_patch_crates_io_recursive(&repo_root)?;
println!("ok: stripped {blocks} [patch.crates-io] block(s) across {files} Cargo.toml(s)");
}
ReleaseOp::Changelog { repo, range } => {
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &repo);
let md = release::cargo::changelog_markdown(&repo_root, &range)?;
print!("{md}");
}
ReleaseOp::ImpactedCrates { repo, base } => {
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &repo);
let changed = release::cargo::changed_crates_since(&repo_root, &base)?;
if changed.is_empty() {
println!("ok: no changed crates since {base}");
return Ok(());
}
let warehouse_root = loaded.warehouse_root();
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(&warehouse_root)
.with_context(|| format!("open iceberg warehouse at {}", warehouse_root.display()))?;
let snapshots = wh.block_on(
nornir::warehouse::dep_graph::query_dep_graph_snapshots(&wh, &repo, None),
)?;
let mut edges: Vec<(String, String)> = Vec::new();
if let Some(snap) = snapshots.last() {
for e in &snap.edges {
for via in &e.via {
edges.push((e.from.clone(), via.clone()));
edges.push((via.clone(), e.to.clone()));
}
}
}
let impacted = release::cargo::impacted_crates(&changed, &edges);
for c in &impacted {
println!("{c}");
}
eprintln!("# {} changed → {} impacted", changed.len(), impacted.len());
}
ReleaseOp::YankCascade { repo, undo, dry_run } => {
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &repo);
let list_path = repo_root.join(".nornir/yank-cascade.txt");
let txt = std::fs::read_to_string(&list_path)
.with_context(|| format!("read {}", list_path.display()))?;
let mut order = Vec::new();
for line in txt.lines() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') { continue; }
let mut sp = l.split_whitespace();
let n = sp.next().ok_or_else(|| anyhow::anyhow!("bad line: {l}"))?;
let v = sp.next().ok_or_else(|| anyhow::anyhow!("missing version: {l}"))?;
order.push((n.to_string(), v.to_string()));
}
let results = release::cargo::yank_cascade(&repo_root, &order, undo, dry_run)?;
for (k, v, s) in &results {
println!(" {k:40} {v:12} {s:?}");
}
}
ReleaseOp::MirrorToHolger { repo, krate, version, holger_base_url, token } => {
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &repo);
let tok = token.or_else(|| std::env::var("HOLGER_TOKEN").ok());
let bytes = release::cargo::mirror_to_holger(
&repo_root, &krate, &version, &holger_base_url, tok.as_deref(),
)?;
println!("ok: mirrored {krate}@{version} ({bytes} bytes) to {holger_base_url}");
}
ReleaseOp::BumpVersion { repo, pkg, new_version, bump_consumers, dry_run } => {
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &repo);
let plan = release::cargo::plan_version_bump(
&repo_root, &pkg, &new_version, bump_consumers,
)?;
if plan.edits.is_empty() {
println!("ok: no references to `{pkg}` found under {}", repo_root.display());
return Ok(());
}
for e in &plan.edits {
let rel = e.cargo_toml.strip_prefix(&repo_root).unwrap_or(&e.cargo_toml);
println!(
" {:50} {:?} {} {}→{}",
rel.display(), e.location, e.pkg, e.old_version, e.new_version
);
}
if dry_run {
println!("dry-run: {} edit(s) across {} file(s)",
plan.edits.len(),
plan.edits.iter().map(|e| &e.cargo_toml).collect::<std::collections::BTreeSet<_>>().len()
);
} else {
let n = release::cargo::apply_bump_plan(&plan)?;
println!("ok: applied {} edit(s) across {n} file(s)", plan.edits.len());
}
}
}
Ok(())
}
fn history_path(repo_root: &Path, repo: &config::Repo) -> PathBuf {
repo_root.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history })
}
fn last_run(loaded: &Loaded, name: &str) -> Result<bench::BenchRun> {
let wh = warehouse::open(&loaded.nornir.storage, &loaded.workspace_root)?;
let filter = warehouse::BenchFilter::for_repo(name);
if let Ok(mut runs) = wh.query_bench_runs(&filter) {
if let Some(latest) = runs.pop() {
return Ok(latest);
}
}
let repo = repo_or_err(loaded, name)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, name);
let path = history_path(&repo_root, repo);
let runs = bench::history::read_all(&path)?;
runs.into_iter()
.last()
.ok_or_else(|| anyhow!("no bench runs in iceberg or {}", path.display()))
}
fn run_gate_all(loaded: &Loaded, repo_name: &str) -> Result<()> {
let repo = repo_or_err(loaded, repo_name)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, repo_name);
let g = &repo.gates;
let mut passed: Vec<String> = Vec::new();
let mut failed: Vec<(String, String)> = Vec::new();
macro_rules! push {
($n:expr, $r:expr) => {
match $r {
Ok(()) => passed.push($n.into()),
Err(e) => failed.push(($n.into(), format!("{e:#}"))),
}
};
}
if g.no_path_patches {
push!("no_path_patches", release::gate::no_path_patches(&repo_root));
}
let last = last_run(loaded, repo_name);
if g.nexus_floor {
push!("nexus_floor", last.as_ref().map_err(|e| anyhow!("{e:#}")).and_then(|r| release::gate::nexus_floor(r)));
}
if g.no_regression {
let pct = if g.max_regression_pct > 0.0 { g.max_regression_pct } else { 10.0 };
let hp = history_path(&repo_root, repo);
push!("no_regression",
last.as_ref().map_err(|e| anyhow!("{e:#}")).and_then(|r| release::gate::no_regression(r, &hp, pct)));
}
if !g.integration_roundtrip.is_empty() {
let kinds: Vec<&str> = g.integration_roundtrip.iter().map(|s| s.as_str()).collect();
push!("integration_roundtrip",
release::gate::integration_roundtrip_via_cargo_test(&repo_root, &kinds));
}
if g.docs_fresh {
let r: Result<()> = (|| {
let run = last.as_ref().map_err(|e| anyhow!("{e:#}"))?;
let ctx = docs::Ctx::new(&repo_root, &loaded.workspace_root, Some(run));
let readme = repo_root.join("README.md");
if readme.exists() { docs::check_file(&readme, &ctx)?; }
docs::assemble_and_check(&repo_root, run)
})();
push!("docs_fresh", r);
}
println!("=== gate-all: {repo_name} ===");
for n in &passed { println!(" ✓ {n}"); }
for (n, e) in &failed { println!(" ✗ {n}: {e}"); }
if !failed.is_empty() {
bail!("{} gate(s) failed", failed.len());
}
println!("{} gate(s) passed", passed.len());
Ok(())
}
fn print_regression_trace(t: &nornir::release::regression::Trace) {
let short = |s: &str| s.chars().take(12).collect::<String>();
println!("regression trace · repo `{}`", t.repo);
if t.frames.is_empty() {
println!(" (no recorded releases for this repo)");
return;
}
match (&t.last_good, &t.first_bad) {
(Some(g), None) => {
println!(" ✓ green — last good {} ({})", short(&g.git_sha), g.gate_status);
}
(Some(g), Some(b)) => {
println!(" ✓ last good : {} ({})", short(&g.git_sha), g.gate_status);
println!(" ✗ first bad : {} ({})", short(&b.git_sha), b.gate_status);
println!(" ⇒ bisect the commits in {}..{}", short(&g.git_sha), short(&b.git_sha));
}
(None, Some(b)) => {
println!(" ✗ never green on record; first bad {} ({})", short(&b.git_sha), b.gate_status);
}
(None, None) => {}
}
if !t.suspect_shas.is_empty() {
let s: Vec<String> = t.suspect_shas.iter().map(|x| short(x)).collect();
println!(" suspect release SHA(s): {}", s.join(", "));
}
if !t.suspects.is_empty() {
println!(" ranked suspects (nearest dep first):");
for s in &t.suspects {
let tag = if s.dep_distance == 0 {
"self".to_string()
} else {
format!("dep+{}", s.dep_distance)
};
let g = s.last_good_sha.as_deref().map(short).unwrap_or_else(|| "—".into());
let b = s.first_bad_sha.as_deref().map(short).unwrap_or_else(|| "—".into());
println!(" [{tag}] {} {g} → {b}", s.repo);
}
}
println!(" timeline (oldest→newest):");
for f in &t.frames {
println!(" {} {} {}", if f.good { "✓" } else { "✗" }, short(&f.git_sha), f.gate_status);
}
}
fn run_release_run(loaded: &Loaded, args: &ReleaseRunArgs) -> Result<()> {
use nornir::release::progress::{now, ProgressWriter, ReleaseEvent};
use nornir::warehouse::dep_graph::{query_dep_graph_snapshots, topo_order_from_edges};
use nornir::warehouse::iceberg::IcebergWarehouse;
let log_dir = loaded.workspace_root.join("workspace_holger/.nornir/logs");
let run_id = chrono::Utc::now().timestamp().to_string();
let progress = ProgressWriter::open(&log_dir, &run_id)
.with_context(|| format!("open progress writer in {}", log_dir.display()))
.ok();
if let Some(p) = &progress {
println!("📡 live events: {}", p.path().display());
p.emit(&ReleaseEvent::RunStart {
ts: now(),
run_id: run_id.clone(),
workspace: args.workspace.clone(),
});
}
let progress_end = progress.clone();
let run_id_for_end = run_id.clone();
let all_repos: Vec<String> = loaded.nornir.repo.keys().cloned().collect();
let selected: Vec<String> = if let Some(name) = &args.repo {
if !loaded.nornir.repo.contains_key(name) {
bail!("unknown repo `{name}`; configured: {:?}", all_repos);
}
vec![name.clone()]
} else {
all_repos.clone()
};
let storage = &loaded.nornir.storage;
let warehouse_root = if storage.local_path.is_empty() {
loaded.workspace_root.join("workspace_holger/.nornir/warehouse")
} else {
loaded.workspace_root.join(&storage.local_path).join("warehouse")
};
let wh = IcebergWarehouse::open(&warehouse_root)
.with_context(|| format!("open iceberg warehouse at {}", warehouse_root.display()))?;
let snapshots = wh
.block_on(query_dep_graph_snapshots(&wh, &args.workspace, None))
.with_context(|| format!("query dep_graph_edges for workspace `{}`", args.workspace))?;
let latest = snapshots.into_iter().last();
let order: Vec<String> = match (&latest, args.repo.is_some()) {
(_, true) => selected.clone(), (Some(snap), false) => {
println!(
"📚 using dep-graph snapshot {} ({}, {} edge(s))",
snap.snapshot_id,
snap.timestamp.to_rfc3339(),
snap.edges.len()
);
topo_order_from_edges(&selected, &snap.edges)
}
(None, false) => {
println!(
"⚠ no dep_graph_edges rows for workspace `{}` — falling back to nornir.toml repo order",
args.workspace
);
selected.clone()
}
};
println!("▶ release run order: {}", order.join(" → "));
for (idx, name) in order.iter().enumerate() {
let repo_cfg = repo_or_err(loaded, name)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, name);
println!(
"\n━━━ [{}/{}] {name} @ {} ━━━",
idx + 1,
order.len(),
repo_root.display(),
);
if let Some(p) = &progress {
use nornir::release::progress::{now, ReleaseEvent};
p.emit(&ReleaseEvent::RepoStart {
ts: now(),
repo: name.clone(),
sha: String::new(),
});
p.emit(&ReleaseEvent::PhaseStart {
ts: now(),
repo: name.clone(),
phase: "test".to_string(),
});
}
if !args.skip_tests {
let t0 = std::time::Instant::now();
let (passed, failed, ok) =
release::pipeline::run_cargo_test(&repo_root, Some(name), progress.as_ref())
.with_context(|| format!("cargo test for `{name}`"))?;
println!(" tests: {passed} passed, {failed} failed");
if let Some(p) = &progress {
use nornir::release::progress::{now, ReleaseEvent};
p.emit(&ReleaseEvent::PhaseEnd {
ts: now(),
repo: name.clone(),
phase: "test".to_string(),
ok,
duration_ms: t0.elapsed().as_millis() as u64,
});
p.emit(&ReleaseEvent::RepoEnd {
ts: now(),
repo: name.clone(),
ok,
});
}
if !ok {
if let Some(p) = &progress {
use nornir::release::progress::{now, ReleaseEvent};
p.emit(&ReleaseEvent::RunEnd {
ts: now(),
run_id: run_id.clone(),
ok: false,
});
}
bail!("`{name}` test phase failed — aborting release run");
}
} else {
println!(" tests: skipped (--skip-tests)");
}
if !args.skip_bench {
println!(" ⚡ running nornir-bench in {}", repo_root.display());
match release::pipeline::run_bench_example(&repo_root)
.with_context(|| format!("run nornir-bench for `{name}`"))?
{
None => println!(" bench: skipped (no examples/nornir-bench.rs)"),
Some(mut run) => {
if run.machine.trim().is_empty() {
run.machine = std::env::var("NORNIR_MACHINE").unwrap_or_else(|_| {
std::fs::read_to_string("/etc/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".into())
});
}
let n_failed = run.tests.iter().filter(|t| !t.passed).count();
let id = wh
.block_on(wh.append_bench_run_async(name, &run))
.with_context(|| format!("persist bench run for `{name}`"))?;
println!(
" bench: {} result(s), {} test fail(s), persisted as {}",
run.results.len(),
n_failed,
id
);
for r in &run.results {
let mut kv: Vec<String> = r
.metrics
.iter()
.filter_map(|(k, v)| v.as_f64().map(|f| format!("{k}={f:.2}")))
.collect();
kv.sort();
println!(" • {:30} {}", r.name, kv.join(" "));
}
if n_failed > 0 {
for t in run.tests.iter().filter(|t| !t.passed) {
println!(
" ✗ test {}{}",
t.name,
t.message
.as_deref()
.map(|m| format!(": {m}"))
.unwrap_or_default()
);
}
bail!("`{name}` bench reported {n_failed} failing test(s) — aborting");
}
}
}
} else {
println!(" bench: skipped (--skip-bench)");
}
if !args.skip_render_docs {
match last_run(loaded, name) {
Ok(run) => {
let ctx = docs::Ctx::new(&repo_root, &loaded.workspace_root, Some(&run));
let readme = repo_root.join("README.md");
if readme.exists() {
match docs::assemble_file(&readme, &ctx) {
Ok(_) => println!(
" docs: rendered README.md from iceberg bench v{}",
run.version
),
Err(e) => println!(" docs: ⚠ render skipped: {e:#}"),
}
} else {
println!(" docs: README.md absent — skipping render");
}
}
Err(e) => println!(" docs: ⚠ no bench row to render from: {e:#}"),
}
} else {
println!(" docs: skipped (--skip-render-docs)");
}
if !args.skip_gates {
let _ = repo_cfg; run_gate_all(loaded, name)
.with_context(|| format!("gate-all for `{name}`"))?;
} else {
println!(" gates: skipped (--skip-gates)");
}
if !args.skip_snapshot {
let index_dir = loaded.workspace_root.join(".nornir/cache/index");
if !index_dir.exists() {
println!(" ⏃ urðr: workspace index absent at {} — skipping snapshot", index_dir.display());
} else {
let (sha, branch) = read_git_head(&repo_root).unwrap_or_else(|_| ("unknown".into(), "unknown".into()));
match nornir::index::snapshot::snapshot_to_iceberg(
&wh,
&args.workspace,
name,
&sha,
&branch,
&index_dir,
) {
Ok(snap) => println!(
" ⏃ urðr: snapshot {} pinned ({} blob(s), {} bytes, sha={})",
snap.snapshot_id,
snap.blob_count,
snap.total_bytes,
&snap.git_sha[..snap.git_sha.len().min(12)],
),
Err(e) => println!(" ⏃ urðr: ⚠ snapshot skipped: {e:#}"),
}
}
}
if !args.skip_push {
use nornir::release::progress::{now, ReleaseEvent};
let t0 = std::time::Instant::now();
if let Some(p) = &progress {
p.emit(&ReleaseEvent::PhaseStart {
ts: now(),
repo: name.clone(),
phase: "push".to_string(),
});
}
let msg = format!("release({name}): regenerate docs from bench run");
let push_ok = match release::publish::commit_release(&repo_root, &msg) {
Ok(Some(sha)) => {
println!(" 📝 committed release docs: {}", &sha[..sha.len().min(12)]);
match release::publish::push(&repo_root, false) {
Ok(true) => { println!(" ⤴ pushed to origin"); true }
Ok(false) => true,
Err(e) => { println!(" ⚠ push step failed (continuing): {e:#}"); false }
}
}
Ok(None) => {
println!(" 📝 docs unchanged — nothing to commit");
true
}
Err(e) => {
println!(" ⚠ commit failed (continuing): {e:#}");
false
}
};
if let Some(p) = &progress {
p.emit(&ReleaseEvent::PhaseEnd {
ts: now(),
repo: name.clone(),
phase: "push".to_string(),
ok: push_ok,
duration_ms: t0.elapsed().as_millis() as u64,
});
}
} else {
println!(" push: skipped (--skip-push)");
}
if !args.skip_publish && !repo_cfg.publish_order.is_empty() {
use nornir::release::progress::{now, ReleaseEvent};
let t0 = std::time::Instant::now();
if let Some(p) = &progress {
p.emit(&ReleaseEvent::PhaseStart {
ts: now(),
repo: name.clone(),
phase: "publish".to_string(),
});
}
let pub_ok = match release::publish::publish_all(
&repo_root,
&repo_cfg.publish_order,
args.dry_run_publish,
) {
Ok(outcomes) => {
for (k, o) in &outcomes {
println!(" 📦 {k:40} {o:?}");
}
true
}
Err(e) => {
println!(" ✗ publish failed: {e:#}");
false
}
};
if let Some(p) = &progress {
p.emit(&ReleaseEvent::PhaseEnd {
ts: now(),
repo: name.clone(),
phase: "publish".to_string(),
ok: pub_ok,
duration_ms: t0.elapsed().as_millis() as u64,
});
}
if !pub_ok {
if let Some(p) = &progress_end {
p.emit(&ReleaseEvent::RunEnd {
ts: now(),
run_id: run_id_for_end.clone(),
ok: false,
});
}
bail!("`{name}` publish phase failed — aborting release run");
}
} else if repo_cfg.publish_order.is_empty() {
println!(" publish: no publish_order configured for `{name}` — skipping");
} else {
println!(" publish: skipped (--skip-publish)");
}
}
println!("\n🎉 all {} repo(s) green: {}", order.len(), order.join(", "));
if let Some(p) = &progress_end {
p.emit(&ReleaseEvent::RunEnd {
ts: now(),
run_id: run_id_for_end,
ok: true,
});
}
Ok(())
}
fn run_docs(op: DocsOp, loaded: &Loaded) -> Result<()> {
match op {
DocsOp::Init(a) => run_docs_init(loaded, &a)?,
DocsOp::Render(a) => run_docs_render(loaded, &a)?,
DocsOp::Check(a) => run_docs_check(loaded, &a)?,
#[cfg(feature = "docs-export")]
DocsOp::Export(a) => run_docs_export(loaded, a)?,
#[cfg(feature = "docs-export")]
DocsOp::Book(a) => run_docs_book(loaded, a)?,
DocsOp::History(a) => run_docs_history(loaded, &a)?,
DocsOp::Index(a) => run_docs_index(loaded, &a)?,
DocsOp::Search(a) => run_docs_search(loaded, &a)?,
}
Ok(())
}
fn docs_ctx_for<'a>(
loaded: &'a Loaded,
repo_name: &str,
) -> Result<(docs::RepoLayout, PathBuf, Option<nornir::bench::BenchRun>)> {
let repo = repo_or_err(loaded, repo_name)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, repo_name);
let history = repo_root.join(if repo.history.is_empty() {
"bench_history.jsonl"
} else {
&repo.history
});
let runs = bench::history::read_all(&history).unwrap_or_default();
let last = runs.last().cloned();
Ok((docs::RepoLayout::new(&repo_root), repo_root, last))
}
fn run_docs_init(loaded: &Loaded, a: &RepoArg) -> Result<()> {
let (layout, _, _) = docs_ctx_for(loaded, &a.repo)?;
let srcs = docs::init_repo(&layout)?;
println!("initialised {}", layout.nornir_dir().display());
for s in &srcs {
println!(" source: {}", s.display());
}
println!("next: `nornir docs render {}`", a.repo);
Ok(())
}
fn run_docs_render(loaded: &Loaded, a: &RepoArg) -> Result<()> {
let (layout, repo_root, last) = docs_ctx_for(loaded, &a.repo)?;
let ctx = docs::Ctx::new(&repo_root, &loaded.workspace_root, last.as_ref());
let reports = docs::render_all(&layout, &ctx)?;
if reports.is_empty() {
println!(
"no sources under {} — run `nornir docs init {}` first",
layout.nornir_dir().display(),
a.repo
);
return Ok(());
}
for r in &reports {
let verb = if r.changed { "wrote" } else { "unchanged" };
println!(
"{verb}: {} ({} bytes, {} section{})",
r.output.display(),
r.bytes,
r.sections.len(),
if r.sections.len() == 1 { "" } else { "s" }
);
}
Ok(())
}
fn run_docs_check(loaded: &Loaded, a: &RepoArg) -> Result<()> {
let (layout, repo_root, last) = docs_ctx_for(loaded, &a.repo)?;
let ctx = docs::Ctx::new(&repo_root, &loaded.workspace_root, last.as_ref());
docs::render_check_all(&layout, &ctx)?;
println!("ok: every doc in {} matches its source", a.repo);
Ok(())
}
#[cfg(feature = "docs-export")]
fn run_docs_export(loaded: &Loaded, a: DocsExportArgs) -> Result<()> {
let (layout, repo_root, last) = docs_ctx_for(loaded, &a.repo.repo)?;
let ctx = docs::Ctx::new(&repo_root, &loaded.workspace_root, last.as_ref());
let _ = docs::render_all(&layout, &ctx)?;
let format = docs::DocFormat::parse(&a.format)?;
let bytes = docs::export_repo(&repo_root, format)?;
let cargo = std::fs::read_to_string(repo_root.join("Cargo.toml")).unwrap_or_default();
let parsed: toml::Value = toml::from_str(&cargo).unwrap_or(toml::Value::Table(Default::default()));
let pkg = parsed.get("package");
let version = pkg
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("0.0.0")
.to_string();
let ext = format.extension();
let out = layout.export_path("README", ext);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out, &bytes)?;
let workspace = workspace_name(loaded);
let (git_sha, _) = read_git_head(&repo_root).unwrap_or_else(|_| ("unknown".into(), "unknown".into()));
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(&iceberg_warehouse_root(loaded))?;
let record = docs::record_doc_export(
&wh, &workspace, &a.repo.repo, "README", &version, ext, &git_sha, &bytes,
)?;
if let Some(out_path) = a.out {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, &bytes)?;
println!("wrote {} ({} bytes, format={})", out_path.display(), bytes.len(), a.format);
}
println!("wrote {} ({} bytes, format={})", out.display(), bytes.len(), a.format);
println!(
"historized README {} ({} bytes, sha256 {}…, git {})",
record.version,
record.byte_len,
&record.sha256[..12.min(record.sha256.len())],
&record.git_sha[..record.git_sha.len().min(12)],
);
Ok(())
}
#[cfg(feature = "docs-export")]
fn run_docs_book(loaded: &Loaded, a: DocsBookArgs) -> Result<()> {
let (layout, repo_root, last) = docs_ctx_for(loaded, &a.repo.repo)?;
let ctx = docs::Ctx::new(&repo_root, &loaded.workspace_root, last.as_ref());
let _ = docs::render_all(&layout, &ctx)?;
let format = docs::DocFormat::parse(&a.format)?;
let (bytes, sources) = docs::build_book(&repo_root, &ctx, format)?;
println!("assembled {} source(s) into the book:", sources.len());
for s in &sources {
let shown = s.strip_prefix(&repo_root).unwrap_or(s);
println!(" + {}", shown.display());
}
let version = docs::resolve_version(&repo_root);
let ext = format.extension();
let out = layout.export_path("book", ext);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out, &bytes)?;
println!("wrote {} ({} bytes, format={})", out.display(), bytes.len(), a.format);
if let Some(extra) = a.out {
if let Some(parent) = extra.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&extra, &bytes)?;
println!("wrote {} ({} bytes, format={})", extra.display(), bytes.len(), a.format);
}
let workspace = workspace_name(loaded);
let (git_sha, _) = read_git_head(&repo_root).unwrap_or_else(|_| ("unknown".into(), "unknown".into()));
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(&iceberg_warehouse_root(loaded))?;
let record = docs::record_doc_export(
&wh, &workspace, &a.repo.repo, "book", &version, ext, &git_sha, &bytes,
)?;
println!(
"historized book {} ({} bytes, sha256 {}…, git {})",
record.version,
record.byte_len,
&record.sha256[..12.min(record.sha256.len())],
&record.git_sha[..record.git_sha.len().min(12)],
);
Ok(())
}
fn run_docs_history(loaded: &Loaded, a: &DocsHistoryArgs) -> Result<()> {
let _ = repo_or_err(loaded, &a.repo.repo)?;
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(&iceberg_warehouse_root(loaded))?;
let filter = docs::ExportFilter {
doc_name: a.doc.clone(),
version: a.version.clone(),
format: a.format.clone(),
limit: Some(a.limit),
};
let rows = docs::list_doc_exports(&wh, &a.repo.repo, &filter)?;
if rows.is_empty() {
println!("no exports historized for {}", a.repo.repo);
return Ok(());
}
println!(
"{:<19} {:<8} {:<7} {:>9} {:<12} {:<12} {}",
"generated_at", "doc", "format", "bytes", "sha256", "git", "export_id"
);
for r in &rows {
let stamp = r.generated_at.get(0..19).unwrap_or(&r.generated_at);
println!(
"{:<19} {:<8} {:<7} {:>9} {:<12} {:<12} {}",
stamp,
r.doc_name,
r.format,
r.byte_len,
&r.sha256[..12.min(r.sha256.len())],
&r.git_sha[..12.min(r.git_sha.len())],
r.export_id,
);
}
println!("({} row{})", rows.len(), if rows.len() == 1 { "" } else { "s" });
Ok(())
}
fn run_docs_index(loaded: &Loaded, a: &DocsIndexArgs) -> Result<()> {
let (layout, repo_root, last) = docs_ctx_for(loaded, &a.repo.repo)?;
let ctx = docs::Ctx::new(&repo_root, &loaded.workspace_root, last.as_ref());
let _ = docs::render_all(&layout, &ctx)?;
let (stats, dir) = docs::build_docs_index(&repo_root, &a.repo.repo)?;
println!(
"docs-index {} :: scanned={} added={} updated={} unchanged={} too_large={} errors={}",
dir.display(),
stats.scanned,
stats.added,
stats.updated,
stats.skipped_unchanged,
stats.skipped_too_large,
stats.errors,
);
if !a.skip_snapshot {
let workspace = workspace_name(loaded);
let (sha, branch) =
read_git_head(&repo_root).unwrap_or_else(|_| ("unknown".into(), "unknown".into()));
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(&iceberg_warehouse_root(loaded))?;
match docs::snapshot_docs_index(&wh, &workspace, &a.repo.repo, &sha, &branch, &repo_root) {
Ok(snap) => println!(
"✓ historized docs-index {} ({} blob(s), {} bytes, sha={})",
snap.snapshot_id,
snap.blob_count,
snap.total_bytes,
&snap.git_sha[..snap.git_sha.len().min(12)],
),
Err(e) => eprintln!("docs-index snapshot skipped: {e}"),
}
}
Ok(())
}
fn run_docs_search(loaded: &Loaded, a: &DocsSearchArgs) -> Result<()> {
let _ = repo_or_err(loaded, &a.repo.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo.repo);
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(&iceberg_warehouse_root(loaded))?;
let hits = docs::search_docs(
&repo_root,
&wh,
&a.repo.repo,
a.sha.as_deref(),
&a.query,
a.limit,
)?;
for h in &hits {
println!("{:>6.2} {}\n {}", h.score, h.path, h.snippet);
}
if hits.is_empty() {
eprintln!("# no hits");
}
Ok(())
}
fn run_introspect(op: IntrospectOp, loaded: &Loaded) -> Result<()> {
match op {
IntrospectOp::Depgraph(a) => {
let _ = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
let g = introspect::depgraph::extract(&repo_root)?;
print!("{}", g.to_mermaid());
}
IntrospectOp::Symbols(a) => {
let syms = introspect::artifact::extract_symbols(&a.binary, &loaded.workspace_root)?;
for s in &syms {
println!("{}", serde_json::to_string(s)?);
}
eprintln!("# {} symbols", syms.len());
}
IntrospectOp::SymbolLookup(a) => {
let syms = introspect::artifact::extract_symbols(&a.binary, &loaded.workspace_root)?;
let hits = introspect::artifact::lookup(&syms, &a.pattern);
for s in hits.iter().take(a.limit) {
println!(
"{}:{} {}",
s.file,
s.line.map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
s.name_demangled
);
}
eprintln!("# {} matches (showing {})", hits.len(), hits.len().min(a.limit));
}
IntrospectOp::DefinedIn(a) => {
let syms = introspect::artifact::extract_symbols(&a.binary, &loaded.workspace_root)?;
let hits = introspect::artifact::defined_in(&syms, &a.file);
for s in hits.iter().take(a.limit) {
println!(
"{}:{} {}",
s.file,
s.line.map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
s.name_demangled
);
}
eprintln!("# {} matches (showing {})", hits.len(), hits.len().min(a.limit));
}
IntrospectOp::Callgraph(a) => {
let edges = introspect::callgraph_dwarf::extract_callgraph(&a.binary, &loaded.workspace_root)?;
for e in &edges {
println!("{}", serde_json::to_string(e)?);
}
eprintln!("# {} inline edges", edges.len());
}
IntrospectOp::CallgraphLlvm(a) => {
let crates_owned: Option<Vec<String>> = a.crates
.as_ref()
.map(|s| s.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect());
let crates_ref: Option<Vec<&str>> = crates_owned.as_ref().map(|v| v.iter().map(|s| s.as_str()).collect());
let edges = if a.path.is_dir() {
introspect::callgraph_llvm::extract_from_dir(&a.path, crates_ref.as_deref())?
} else {
introspect::callgraph_llvm::extract_from_files(&[a.path.clone()], crates_ref.as_deref())?
};
for e in &edges {
println!("{}", serde_json::to_string(e)?);
}
eprintln!("# {} direct edges", edges.len());
}
IntrospectOp::Callers(a) => {
let edges = introspect::callgraph_dwarf::extract_callgraph(&a.binary, &loaded.workspace_root)?;
let cg = introspect::callgraph_dwarf::Callgraph::from_edges(&edges);
for n in cg.callers_of(&a.name) { println!("{n}"); }
}
IntrospectOp::Callees(a) => {
let edges = introspect::callgraph_dwarf::extract_callgraph(&a.binary, &loaded.workspace_root)?;
let cg = introspect::callgraph_dwarf::Callgraph::from_edges(&edges);
for n in cg.callees_of(&a.name) { println!("{n}"); }
}
IntrospectOp::PathBetween(a) => {
let edges = introspect::callgraph_dwarf::extract_callgraph(&a.binary, &loaded.workspace_root)?;
let cg = introspect::callgraph_dwarf::Callgraph::from_edges(&edges);
match cg.path_between(&a.from, &a.to) {
Some(path) => for n in path { println!("{n}"); },
None => eprintln!("no path from {} to {}", a.from, a.to),
}
}
}
Ok(())
}
fn run_warehouse(op: WarehouseOp, loaded: &Loaded) -> Result<()> {
let wh = warehouse::open(&loaded.nornir.storage, &loaded.workspace_root)?;
match op {
WarehouseOp::ImportJsonl(a) => {
let repo = repo_or_err(loaded, &a.repo)?;
let path = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo)
.join(if repo.history.is_empty() { "bench_history.jsonl" } else { &repo.history });
let runs = bench::history::read_all(&path)?;
let mut imported = 0usize;
for r in &runs {
if r.machine.trim().is_empty() {
eprintln!("skipping run dated {}: no machine", r.date);
continue;
}
wh.append_bench_run(&a.repo, r)?;
imported += 1;
}
println!("imported {imported}/{} runs from {}", runs.len(), path.display());
}
WarehouseOp::Query(args) => {
let filter = warehouse::BenchFilter {
repo: Some(args.repo),
machine: args.machine,
limit: args.last,
};
for r in wh.query_bench_runs(&filter)? {
println!("{}", serde_json::to_string(&r)?);
}
}
}
Ok(())
}
fn run_index(op: IndexOp, loaded: &Loaded) -> Result<()> {
let open_for_read = || -> Result<index::Index> {
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(
&iceberg_warehouse_root(loaded),
)?;
let (idx, _restored) =
index::Index::open_or_restore(&loaded.workspace_root, &wh, "_workspace", None)?;
Ok(idx)
};
match op {
IndexOp::Build => {
let repos: Vec<String> = loaded.nornir.repo.keys().cloned().collect();
let idx = index::Index::open(&loaded.workspace_root)?.with_repo_scope(repos);
let stats = idx.build()?;
println!(
"scanned={} added={} updated={} unchanged={} too_large={} errors={}",
stats.scanned,
stats.added,
stats.updated,
stats.skipped_unchanged,
stats.skipped_too_large,
stats.errors,
);
}
IndexOp::Search(a) => {
let idx = open_for_read()?;
let corpus = match a.corpus.as_deref() {
None => None,
Some(s) => Some(
index::Corpus::parse(s)
.ok_or_else(|| anyhow!("unknown corpus: {s}"))?,
),
};
let hits = idx.search(&a.query, corpus, a.repo.as_deref(), a.limit)?;
for h in &hits {
println!(
"{:>6.2} [{}/{}] {}\n {}",
h.score,
h.corpus,
if h.repo.is_empty() { "-" } else { &h.repo },
h.path,
h.snippet
);
}
}
IndexOp::Stats => {
let idx = open_for_read()?;
let s = idx.stats()?;
println!("total: {}", s.total);
for (c, n) in &s.by_corpus {
println!(" {:<14} {}", c, n);
}
}
IndexOp::Snapshot(a) => {
let (index_dir, repo_label, sha, branch) = resolve_index_target(loaded, a.repo.as_deref())?;
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(
&iceberg_warehouse_root(loaded),
)?;
let snap = nornir::index::snapshot::snapshot_to_iceberg(
&wh,
&a.workspace,
&repo_label,
&sha,
&branch,
&index_dir,
)?;
println!(
"✓ snapshot {} ({}, {} blob(s), {} bytes, sha={})",
snap.snapshot_id,
snap.repo,
snap.blob_count,
snap.total_bytes,
&snap.git_sha[..snap.git_sha.len().min(12)],
);
}
IndexOp::Restore(a) => {
let (default_dir, repo_label, _, _) = resolve_index_target(loaded, a.repo.as_deref())?;
let into = a.into.unwrap_or(default_dir);
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(
&iceberg_warehouse_root(loaded),
)?;
let snap = nornir::index::snapshot::restore_from_iceberg(
&wh,
&repo_label,
a.sha.as_deref(),
&into,
)?;
println!(
"✓ restored snapshot {} into {} ({} blob(s), {} bytes, sha={})",
snap.snapshot_id,
into.display(),
snap.blob_count,
snap.total_bytes,
&snap.git_sha[..snap.git_sha.len().min(12)],
);
}
}
Ok(())
}
#[cfg(feature = "vector")]
fn run_vector(op: VectorOp, loaded: &Loaded) -> Result<()> {
use nornir::vector::store;
let wh = || -> Result<nornir::warehouse::iceberg::IcebergWarehouse> {
nornir::warehouse::iceberg::IcebergWarehouse::open(&iceberg_warehouse_root(loaded))
};
match op {
VectorOp::Index(a) => {
let _ = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
let (sha, branch) = read_git_head(&repo_root)
.unwrap_or_else(|_| ("unknown".into(), "unknown".into()));
let files = store::collect_rust_sources(&repo_root);
if files.is_empty() {
println!("no .rs files under {}", repo_root.display());
return Ok(());
}
let embedder = load_vector_embedder()?;
println!(
"vectorizing {} ({} files) @ {} with {} …",
a.repo,
files.len(),
&sha[..sha.len().min(12)],
vector_backend_name(),
);
let opts = nornir::vector::chunk::ChunkOptions::default();
let snap = store::index_repo(
&wh()?,
&store::RepoRef {
workspace: "workspace_holger",
repo: &a.repo,
git_sha: &sha,
branch: &branch,
complete: true,
},
&files,
&opts,
&*embedder,
)?;
println!(
"✓ snapshot {} — {} occurrences, {} new vector(s) embedded",
snap.snapshot_id, snap.occurrences, snap.new_vectors
);
}
VectorOp::Search(a) => run_vector_search(loaded, &a)?,
VectorOp::Stats => {
let counts = store::warehouse_stats(&wh()?)?;
println!("embeddings: {}", counts.embeddings);
println!("index snapshots: {}", counts.snapshots);
println!("manifest occurrences:{}", counts.occurrences);
}
}
Ok(())
}
#[cfg(feature = "vector")]
fn run_vector_search(loaded: &Loaded, a: &VectorSearchArgs) -> Result<()> {
let mode = a.mode.to_ascii_lowercase();
match mode.as_str() {
"lexical" => {
let idx = {
let wh = nornir::warehouse::iceberg::IcebergWarehouse::open(
&iceberg_warehouse_root(loaded),
)?;
let (i, _) = index::Index::open_or_restore(
&loaded.workspace_root,
&wh,
"_workspace",
None,
)?;
i
};
let hits = idx.search(&a.query, None, a.repo.as_deref(), a.limit)?;
for h in &hits {
println!(
"{:>6.2} [{}/{}] {}",
h.score,
h.corpus,
if h.repo.is_empty() { "-" } else { &h.repo },
h.path
);
}
}
"semantic" | "hybrid" => run_vector_semantic(loaded, a, mode == "hybrid")?,
other => bail!("unknown --mode `{other}` (expected lexical|semantic|hybrid)"),
}
Ok(())
}
#[cfg(all(feature = "vector", any(feature = "embed-tract", feature = "embed-ort")))]
fn run_vector_semantic(loaded: &Loaded, a: &VectorSearchArgs, hybrid: bool) -> Result<()> {
use nornir::vector::store;
let repo = a
.repo
.as_deref()
.ok_or_else(|| anyhow!("semantic/hybrid search needs --repo (embeddings are per-repo)"))?;
let wh =
nornir::warehouse::iceberg::IcebergWarehouse::open(&iceberg_warehouse_root(loaded))?;
let embedder = load_vector_embedder()?;
let mp = embedder.profile().id();
let q = embedder.embed(std::slice::from_ref(&a.query))?;
let sem = store::search(&wh, repo, a.sha.as_deref(), &mp, &q[0], a.limit)?;
if !hybrid {
for (score, occ) in &sem {
println!("{:>6.3} {}:{}-{}", score, occ.file, occ.start_line, occ.end_line);
}
return Ok(());
}
let (i, _) = index::Index::open_or_restore(&loaded.workspace_root, &wh, "_workspace", None)?;
let lex = i.search(&a.query, None, Some(repo), a.limit.max(10))?;
let mut score: std::collections::HashMap<String, f32> = std::collections::HashMap::new();
const K: f32 = 60.0; for (rank, (_s, occ)) in sem.iter().enumerate() {
*score.entry(occ.file.clone()).or_default() += 1.0 / (K + rank as f32 + 1.0);
}
for (rank, h) in lex.iter().enumerate() {
*score.entry(h.path.clone()).or_default() += 1.0 / (K + rank as f32 + 1.0);
}
let mut ranked: Vec<(String, f32)> = score.into_iter().collect();
ranked.sort_by(|x, y| y.1.total_cmp(&x.1));
ranked.truncate(a.limit);
for (path, s) in &ranked {
println!("{:>6.4} {}", s, path);
}
Ok(())
}
#[cfg(all(feature = "vector", not(any(feature = "embed-tract", feature = "embed-ort"))))]
fn run_vector_semantic(_loaded: &Loaded, _a: &VectorSearchArgs, _hybrid: bool) -> Result<()> {
bail!(
"semantic/hybrid search needs an embedder: rebuild nornir with \
`--features embed-tract` (CPU) or `--features embed-ort` (GPU)"
)
}
#[cfg(all(feature = "vector", any(feature = "embed-tract", feature = "embed-ort")))]
fn vector_backend_name() -> &'static str {
nornir::vector::embedder_backend()
}
#[cfg(all(feature = "vector", not(any(feature = "embed-tract", feature = "embed-ort"))))]
fn vector_backend_name() -> &'static str {
"none (no embedder feature)"
}
#[cfg(all(feature = "vector", any(feature = "embed-tract", feature = "embed-ort")))]
fn load_vector_embedder() -> Result<Box<dyn nornir::vector::store::Embedder>> {
nornir::vector::load_embedder()
}
#[cfg(all(feature = "vector", not(any(feature = "embed-tract", feature = "embed-ort"))))]
fn load_vector_embedder() -> Result<Box<dyn nornir::vector::store::Embedder>> {
bail!(
"this nornir was built without an embedder: rebuild with \
`--features embed-tract` (CPU) or `--features embed-ort` (GPU)"
)
}
fn resolve_index_target(
loaded: &Loaded,
repo: Option<&str>,
) -> Result<(PathBuf, String, String, String)> {
let (dir, label, git_root) = match repo {
Some(name) => {
let _ = repo_or_err(loaded, name)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, name);
(
repo_root.join(".nornir/cache/index"),
name.to_string(),
repo_root,
)
}
None => {
let ws_index = loaded.workspace_root.join(".nornir/cache/index");
(
ws_index,
"_workspace".to_string(),
loaded.workspace_root.join("workspace_holger"),
)
}
};
let (sha, branch) = read_git_head(&git_root).unwrap_or_else(|_| ("unknown".into(), "unknown".into()));
Ok((dir, label, sha, branch))
}
fn iceberg_warehouse_root(loaded: &Loaded) -> PathBuf {
let storage = &loaded.nornir.storage;
if storage.local_path.is_empty() {
loaded.workspace_root.join("workspace_holger/.nornir/warehouse")
} else {
loaded.workspace_root.join(&storage.local_path).join("warehouse")
}
}
#[allow(dead_code)]
fn workspace_name(loaded: &Loaded) -> String {
loaded
.workspace_root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("_workspace")
.to_string()
}
fn read_git_head(repo_root: &Path) -> Result<(String, String)> {
nornir::gitio::head_sha_and_branch(repo_root)
.with_context(|| format!("read git HEAD in {}", repo_root.display()))
}
fn repo_or_err<'a>(loaded: &'a Loaded, name: &str) -> Result<&'a config::Repo> {
loaded
.nornir
.repo
.get(name)
.with_context(|| format!("no [repo.{name}] in {}", loaded.config_path.display()))
}
fn yn(b: bool) -> &'static str {
if b { "yes" } else { "no" }
}