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,
},
Repos,
}
#[derive(Subcommand)]
enum GuardOp {
Status,
Apply,
Release,
}
#[derive(Subcommand)]
enum BenchOp {
HistoryShow(RepoArg),
Run(RepoArg),
}
#[derive(Subcommand)]
enum ReleaseOp {
GatePathPatches(RepoArg),
GateNexusFloor(RepoArg),
GateNoRegression(RepoArg),
GateDocsFresh(RepoArg),
GateRoundtrip(RepoArg),
GateAll(RepoArg),
Run(ReleaseRunArgs),
Publish(PublishArgs),
}
#[derive(Subcommand)]
enum DocsOp {
Init(RepoArg),
Render(RepoArg),
Check(RepoArg),
#[cfg(feature = "docs-export")]
Export(DocsExportArgs),
History(DocsHistoryArgs),
}
#[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>,
}
#[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,
}
#[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,
}
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)?,
}
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;
let report = match op {
GuardOp::Status => guard::status(&loaded.workspace_root, forbidden),
GuardOp::Apply => guard::apply(&loaded.workspace_root, forbidden)?,
GuardOp::Release => 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::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 {
repo_root: &repo_root,
workspace_root: &loaded.workspace_root,
run: 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::Publish(a) => {
let repo = repo_or_err(loaded, &a.repo)?;
let repo_root = config::Nornir::repo_dir(&loaded.workspace_root, &a.repo);
release::publish::publish_all(&repo_root, &repo.publish_order, a.dry_run)?;
}
}
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 { repo_root: &repo_root, workspace_root: &loaded.workspace_root, run: 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 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 {
repo_root: &repo_root,
workspace_root: &loaded.workspace_root,
run: 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:#}"),
}
}
}
}
println!("\n🎉 all {} repo(s) green: {}", order.len(), order.join(", "));
println!(
" next manual step: `nornir release publish <repo> [--dry-run]` to push to crates.io"
);
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)?,
DocsOp::History(a) => run_docs_history(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 {
repo_root: &repo_root,
workspace_root: &loaded.workspace_root,
run: 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 {
repo_root: &repo_root,
workspace_root: &loaded.workspace_root,
run: 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 {
repo_root: &repo_root,
workspace_root: &loaded.workspace_root,
run: 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 pkg_name = pkg
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.unwrap_or(&a.repo.repo)
.to_string();
let version = pkg
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("0.0.0")
.to_string();
let wh = docs::DocsWarehouse::open(&layout)?;
let record = wh.record("README", &version, format.extension(), &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
);
}
let _ = pkg_name;
println!(
"warehoused {} ({} bytes, sha256 {}…, archive {})",
record.archive_path,
record.bytes_len,
&record.sha256[..12],
layout.warehouse_dir().join("docs").join(&record.archive_path).display(),
);
println!(
"latest: {}",
layout.warehouse_dir().join("docs/latest").join(format!(
"README-{version}.{}", format.extension()
)).display()
);
Ok(())
}
fn run_docs_history(loaded: &Loaded, a: &DocsHistoryArgs) -> Result<()> {
let (layout, _, _) = docs_ctx_for(loaded, &a.repo.repo)?;
let wh = docs::DocsWarehouse::open(&layout)?;
let filter = docs::ExportFilter {
doc_name: a.doc.clone(),
version: a.version.clone(),
format: a.format.clone(),
limit: Some(a.limit),
};
let rows = wh.list(&filter)?;
if rows.is_empty() {
println!("no exports recorded in {}", wh.root().display());
return Ok(());
}
println!(
"{:>4} {:<19} {:<8} {:<7} {:>9} {:<12} {}",
"id", "generated_at", "doc", "format", "bytes", "sha256", "archive"
);
for r in &rows {
let stamp = r.generated_at.get(0..19).unwrap_or(&r.generated_at);
println!(
"{:>4} {:<19} {:<8} {:<7} {:>9} {:<12} {}",
r.id, stamp, r.doc_name, r.format, r.bytes_len, &r.sha256[..12], r.archive_path
);
}
println!("({} row{})", rows.len(), if rows.len() == 1 { "" } else { "s" });
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 idx = index::Index::open(&loaded.workspace_root)?;
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(())
}
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")
}
}
fn read_git_head(repo_root: &Path) -> Result<(String, String)> {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo_root)
.output()
.context("git rev-parse HEAD")?;
if !out.status.success() {
bail!("git rev-parse HEAD failed in {}", repo_root.display());
}
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
let out = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(repo_root)
.output()
.ok();
let branch = out
.and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).trim().to_string()) } else { None })
.unwrap_or_else(|| "unknown".into());
Ok((sha, branch))
}
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" }
}