use std::io::{self, Write};
use std::path::PathBuf;
use anyhow::Context;
use clap::{Parser, ValueEnum};
use rayon::prelude::*;
use wikrs::diag::Severity;
use wikrs::{diag, dump, extract, output, parser, render};
const BATCH_PAGES: usize = 4096;
const BATCH_BYTES: usize = 32 << 20;
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
enum Format {
Text,
Jsonl,
Sections,
Markdown,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Engine {
Strip,
Ast,
}
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
enum FailOn {
Warning,
Unsupported,
}
struct Rendered {
title: String,
text: String,
diags: Option<Vec<diag::Diagnostic>>,
}
#[derive(Debug, Parser)]
#[command(name = "wikrs", version, about)]
struct Cli {
#[arg(long)]
input: PathBuf,
#[arg(long)]
index: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(long, value_enum, default_value_t = Engine::Ast)]
engine: Engine,
#[arg(long)]
stats: bool,
#[arg(long, value_enum)]
fail_on: Option<FailOn>,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let ast_only = matches!(cli.format, Format::Sections | Format::Markdown);
if ast_only && matches!(cli.engine, Engine::Strip) {
anyhow::bail!(
"--format {:?} needs the AST; use --engine ast (the default)",
cli.format
);
}
if ast_only && cli.stats {
anyhow::bail!("--stats measures plain-text conversion; use --format text or jsonl");
}
if cli.fail_on.is_some() && matches!(cli.engine, Engine::Strip) {
anyhow::bail!("--fail-on needs diagnostics; use --engine ast (the default)");
}
let mut pages = match &cli.index {
Some(index) => dump::open_multistream(&cli.input, index)?,
None => dump::open(&cli.input)?,
};
let stdout = io::stdout();
let mut w = io::BufWriter::new(stdout.lock());
let (mut total, mut clean, mut read) = (0usize, 0usize, 0usize);
let (mut zero_diag, mut warned, mut unsupported) = (0usize, 0usize, 0usize);
let mut failing = 0usize;
loop {
let mut batch: Vec<dump::Page> = Vec::with_capacity(BATCH_PAGES);
let mut bytes = 0usize;
for res in pages.by_ref() {
let page = res.with_context(|| {
format!(
"reading dump {} (after {read} page(s))",
cli.input.display()
)
})?;
read += 1;
if !page.is_article() {
continue;
}
bytes += page.text.len();
batch.push(page);
if batch.len() >= BATCH_PAGES || bytes >= BATCH_BYTES {
break;
}
}
if batch.is_empty() {
break;
}
let rendered: Vec<Rendered> = batch
.into_par_iter()
.map(|p| match cli.engine {
Engine::Strip => Rendered {
text: extract::strip(&p.text),
title: p.title,
diags: None,
},
Engine::Ast => {
let parsed = parser::parse(&p.text);
let text = match cli.format {
Format::Sections => {
output::to_sections_jsonl(&p.title, &parsed.nodes, &parsed.diagnostics)
}
Format::Markdown => {
output::to_markdown(&p.title, &render::markdown(&parsed.nodes))
}
Format::Text | Format::Jsonl => render::plain(&parsed.nodes),
};
Rendered {
title: p.title,
text,
diags: Some(parsed.diagnostics),
}
}
})
.collect();
for r in &rendered {
total += 1;
if let Some(diags) = &r.diags {
if diags.is_empty() {
zero_diag += 1;
}
if diags
.iter()
.any(|d| matches!(d.severity, Severity::Warning))
{
warned += 1;
}
if diags
.iter()
.any(|d| matches!(d.severity, Severity::Unsupported | Severity::Error))
{
unsupported += 1;
}
let hit = match cli.fail_on {
Some(FailOn::Warning) => !diags.is_empty(),
Some(FailOn::Unsupported) => diags
.iter()
.any(|d| !matches!(d.severity, Severity::Warning)),
None => false,
};
if hit {
failing += 1;
}
}
if cli.stats {
if extract::looks_clean(&r.text) {
clean += 1;
}
} else {
match cli.format {
Format::Text | Format::Sections | Format::Markdown => {
writeln!(w, "{}", r.text)?
}
Format::Jsonl => writeln!(
w,
"{}",
output::to_jsonl(&r.title, &r.text, r.diags.as_deref())
)?,
}
}
}
}
if cli.stats {
let pct = if total == 0 {
0.0
} else {
100.0 * clean as f64 / total as f64
};
eprintln!("pages={total} clean={clean} ({pct:.1}% clean conversion)");
if matches!(cli.engine, Engine::Ast) {
eprintln!("zero-diag={zero_diag} warned={warned} unsupported={unsupported}");
}
} else {
w.flush()?;
if warned + unsupported > 0 {
eprintln!(
"wikrs: {warned} page(s) with warnings, {unsupported} page(s) with \
unsupported constructs ({zero_diag}/{total} zero-diagnostic)"
);
}
}
if let (Some(tier), true) = (cli.fail_on, failing > 0) {
let name = match tier {
FailOn::Warning => "warning",
FailOn::Unsupported => "unsupported",
};
anyhow::bail!("{failing} page(s) with {name}+ diagnostics (--fail-on {name})");
}
Ok(())
}