#![deny(unsafe_code)]
#![warn(clippy::pedantic)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::Context as _;
use clap::{Args, Parser, Subcommand, ValueEnum};
use ontomark_compile::check::{bodies, conflicts, gate, repair, validate};
use ontomark_compile::input::error::VaultError;
use ontomark_compile::input::page::{Vault, VaultKind};
use ontomark_compile::input::promotion::Blocker;
use ontomark_compile::input::vocabulary::Vocabulary;
use ontomark_compile::ir::graph::{self, VaultGraph};
use ontomark_compile::ir::model::Corpus;
use ontomark_compile::profile::{Layout, MissingVault, Profile};
use ontomark_compile::write::{apply, create, edit};
use serde_json::json;
mod embed;
mod migrate_cmd;
use migrate_cmd::{vault_kinds, VAULT_VALUES};
use ontomark_compile::{build, reason};
#[derive(Debug, Parser)]
#[command(name = "ontomark", version, about, long_about = None)]
struct Cli {
#[arg(long, global = true)]
repo: Option<PathBuf>,
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true, value_name = "estate|generic|PATH")]
profile: Option<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Validate(ValidateArgs),
Gate(GateArgs),
Conflicts(ConflictsArgs),
Build(BuildArgs),
Find(FindArgs),
Retrieve(RetrieveArgs),
Tree(TreeArgs),
Edit(EditArgs),
Create(CreateArgs),
Apply(ApplyArgs),
#[command(subcommand)]
Repair(RepairCommand),
Migrate {
#[command(subcommand)]
command: migrate_cmd::MigrateCommand,
},
}
#[derive(Debug, Subcommand)]
enum RepairCommand {
Fences(RepairArgs),
Bodies(BodiesArgs),
}
#[derive(Debug, Args)]
struct BodiesArgs {
#[arg(long, default_value = "all", value_parser = VAULT_VALUES)]
vault: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
check: bool,
#[arg(long)]
report: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct RepairArgs {
#[arg(long, default_value = "all", value_parser = VAULT_VALUES)]
vault: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
report: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct VaultSelector {
#[arg(long, default_value = "knowledge", value_parser = VAULT_VALUES)]
vault: String,
}
impl VaultSelector {
fn kinds(&self) -> Vec<VaultKind> {
vault_kinds(&self.vault)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ScopeArg {
Public,
Machine,
}
impl From<ScopeArg> for build::Scope {
fn from(scope: ScopeArg) -> Self {
match scope {
ScopeArg::Public => Self::Public,
ScopeArg::Machine => Self::Machine,
}
}
}
const TIER_VALUES: [&str; 2] = ["quick", "full"];
const SEVERITY_VALUES: [&str; 4] = ["high", "medium", "low", "all"];
#[derive(Debug, Args)]
struct ValidateArgs {
#[command(flatten)]
selector: VaultSelector,
#[arg(long)]
strict: bool,
}
#[derive(Debug, Args)]
struct FindArgs {
#[command(flatten)]
selector: VaultSelector,
#[arg(long)]
query: String,
#[arg(long, value_name = "T")]
r#type: Option<String>,
#[arg(long, default_value_t = 20)]
limit: usize,
#[arg(long)]
fuzzy: bool,
}
#[derive(Debug, Args)]
struct RetrieveArgs {
#[command(flatten)]
selector: VaultSelector,
#[arg(required = true)]
ids: Vec<String>,
#[arg(long, default_value = "is-a=1")]
expand: String,
#[arg(long, default_value_t = 50)]
max_documents: usize,
}
#[derive(Debug, Args)]
struct TreeArgs {
#[command(flatten)]
selector: VaultSelector,
id: String,
#[arg(long, default_value_t = 3)]
depth: usize,
#[arg(long, default_value = "is-a")]
predicate: String,
}
#[derive(Debug, Args)]
struct EditArgs {
#[command(flatten)]
selector: VaultSelector,
id: String,
#[arg(long = "set", value_name = "K=V")]
sets: Vec<String>,
#[arg(long = "unset", value_name = "K")]
unsets: Vec<String>,
#[arg(long)]
expect: Option<String>,
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct CreateArgs {
file: PathBuf,
#[arg(long = "set", value_name = "K=V")]
sets: Vec<String>,
#[arg(long = "unset", value_name = "K")]
unsets: Vec<String>,
#[arg(long)]
expect: Option<String>,
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct ApplyArgs {
file: PathBuf,
#[arg(long = "set", value_name = "K=V")]
sets: Vec<String>,
#[arg(long = "unset", value_name = "K")]
unsets: Vec<String>,
#[arg(long)]
expect: Option<String>,
}
#[derive(Debug, Args)]
struct GateArgs {
#[command(flatten)]
selector: VaultSelector,
#[arg(long, default_value = "quick", value_parser = TIER_VALUES)]
tier: String,
}
#[derive(Debug, Args)]
struct ConflictsArgs {
#[command(flatten)]
selector: VaultSelector,
#[arg(long, default_value = "high", value_parser = SEVERITY_VALUES)]
severity: String,
}
#[derive(Debug, Args)]
#[allow(clippy::struct_excessive_bools)] struct BuildArgs {
#[arg(long)]
out: PathBuf,
#[arg(long, default_value = "knowledge", value_parser = VAULT_VALUES)]
vault: String,
#[arg(long, value_enum, default_value_t = ScopeArg::Public)]
scope: ScopeArg,
#[arg(long)]
stats: bool,
#[arg(long)]
publish_out: Option<PathBuf>,
#[arg(long)]
with_markdown_mirror: bool,
#[arg(long)]
with_rvdb: bool,
#[arg(long, default_value = embed::DEFAULT_ENDPOINT, env = "ONTOMARK_EMBED_ENDPOINT")]
embed_endpoint: String,
#[arg(long)]
stale_after: Option<String>,
#[arg(long, env = "ONTOMARK_GENERATED_AT")]
generated_at: Option<String>,
#[arg(long)]
source_commit: Option<String>,
#[arg(long, requires = "source_commit")]
source_dirty: bool,
#[arg(long)]
no_redact_private_links: bool,
}
fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(e) => {
eprintln!("ontomark: {e:#}");
if e.downcast_ref::<MissingVault>().is_some() {
return ExitCode::from(2);
}
ExitCode::from(1)
}
}
}
struct Repo {
root: PathBuf,
vocab: Vocabulary,
estate_shape: bool,
}
fn resolve_repo(explicit: Option<&Path>) -> anyhow::Result<Repo> {
let start = match explicit {
Some(p) => p.to_path_buf(),
None => std::env::current_dir()?,
};
match Vocabulary::discover(&start) {
Ok((path, vocab)) => {
let root = path
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or(start);
Ok(Repo {
root,
vocab,
estate_shape: true,
})
}
Err(VaultError::NotAVault { .. }) => {
anyhow::ensure!(
start.is_dir(),
"{} is not a directory (no ontology/vocabulary.yaml above it either)",
start.display()
);
Ok(Repo {
root: start,
vocab: Vocabulary::generic(),
estate_shape: false,
})
}
Err(e) => Err(e).with_context(|| {
format!(
"looking for ontology/vocabulary.yaml from {}",
start.display()
)
}),
}
}
fn resolve_profile(spec: Option<&str>, repo: &Repo) -> anyhow::Result<Profile> {
let mut profile = match spec {
Some("estate") => ontomark_profile_estate::profile(),
Some("generic") => Profile::generic(),
Some(path) => {
let text =
std::fs::read_to_string(path).with_context(|| format!("reading profile {path}"))?;
return Profile::from_yaml(&text).with_context(|| format!("parsing profile {path}"));
}
None if repo.estate_shape => ontomark_profile_estate::profile(),
None => Profile::generic(),
};
if !repo.estate_shape {
profile.layout = Layout {
excluded: profile.layout.excluded,
..Layout::flat()
};
}
Ok(profile)
}
fn vault_root(root: &Path, profile: &Profile, kind: VaultKind) -> anyhow::Result<PathBuf> {
Ok(profile.layout.root_of(root, kind)?)
}
fn load(root: &Path, profile: &Profile, kinds: &[VaultKind]) -> anyhow::Result<Vec<Vault>> {
let excluded = profile.layout.excluded_dirs();
kinds
.iter()
.map(|kind| {
let dir = vault_root(root, profile, *kind)?;
Vault::load_excluding(&dir, *kind, &excluded)
.with_context(|| format!("loading {}", dir.display()))
})
.collect()
}
fn repair_scopes(
root: &Path,
profile: &Profile,
kinds: &[VaultKind],
) -> anyhow::Result<Vec<repair::Scope>> {
let roots: Vec<(VaultKind, PathBuf)> = kinds
.iter()
.map(|kind| Ok((*kind, vault_root(root, profile, *kind)?)))
.collect::<anyhow::Result<_>>()?;
let borrowed: Vec<(VaultKind, &Path)> = roots.iter().map(|(k, p)| (*k, p.as_path())).collect();
Ok(repair::scopes_for(
&borrowed,
&profile.layout.excluded_dirs(),
))
}
fn emit(json: bool, value: &serde_json::Value, prose: impl FnOnce()) -> anyhow::Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(value)?);
} else {
prose();
}
Ok(())
}
fn parse_expansion(raw: &str) -> anyhow::Result<BTreeMap<String, usize>> {
let mut out = BTreeMap::new();
for part in raw.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (key, depth) = part
.split_once('=')
.with_context(|| format!("`{part}` is not `edge-type=depth`"))?;
out.insert(
key.trim().to_owned(),
depth
.trim()
.parse()
.with_context(|| format!("`{depth}` is not a depth"))?,
);
}
Ok(out)
}
fn print_tree(node: &graph::TreeNode, indent: usize) {
println!("{:indent$}{}", "", node.id, indent = indent * 2);
for child in &node.children {
print_tree(child, indent + 1);
}
}
fn changes_of(sets: &[String], unsets: &[String]) -> Result<Vec<edit::Change>, String> {
let mut changes: Vec<edit::Change> = sets
.iter()
.map(|s| edit::Change::parse_set(s))
.collect::<Result<_, _>>()?;
changes.extend(
unsets
.iter()
.map(|k| edit::Change::Unset { key: k.clone() }),
);
Ok(changes)
}
fn print_changes(changes: &BTreeMap<String, [Option<String>; 2]>) {
for (key, [before, after]) in changes {
println!(
" {key}: {} -> {}",
before.as_deref().unwrap_or("(absent)"),
after.as_deref().unwrap_or("(removed)")
);
}
}
#[allow(clippy::too_many_lines)]
fn run() -> anyhow::Result<ExitCode> {
let cli = Cli::parse();
let repo = resolve_repo(cli.repo.as_deref())?;
let profile = resolve_profile(cli.profile.as_deref(), &repo)?;
let Repo { root, vocab, .. } = repo;
match &cli.command {
Command::Migrate { command } => migrate_cmd::run(command, &root, &profile, cli.json),
Command::Validate(args) => {
let mut failed = false;
let mut reports = serde_json::Map::new();
for vault in load(&root, &profile, &args.selector.kinds())? {
let corpus = Corpus::build(&vault, &vocab);
let report = validate::validate(&profile, &vault, &corpus, &vocab);
failed |=
!report.errors().is_empty() || (args.strict && !report.warnings().is_empty());
if !cli.json {
println!(
"[{}] {} pages, {} with ontology, {} public — {} errors, {} warnings",
vault.kind.role_name(),
report.total_pages,
report.pages_with_ontology,
report.public_pages,
report.errors().len(),
report.warnings().len()
);
for issue in report.errors().iter().take(30) {
println!(" {issue}");
}
}
reports.insert(
vault.kind.role_name().to_owned(),
serde_json::to_value(report.detailed())?,
);
}
emit(cli.json, &serde_json::Value::Object(reports), || {})?;
Ok(ExitCode::from(u8::from(failed)))
}
Command::Gate(args) => {
let tier = gate::Tier::parse(&args.tier);
let classifier = ontomark_compile::whelk::WhelkEl;
let mut verdicts: Vec<(&'static str, gate::Verdict)> = Vec::new();
for vault in load(&root, &profile, &args.selector.kinds())? {
let corpus = Corpus::build(&vault, &vocab);
let report = validate::validate(&profile, &vault, &corpus, &vocab);
let graph = (tier == gate::Tier::Full && report.errors().is_empty())
.then(|| build::turtle::build_graph(&profile, &corpus, &vocab, false));
let verdict = gate::run(
&report,
&corpus,
graph
.as_ref()
.map(|g| (g, &classifier as &dyn reason::Classifier)),
tier,
);
verdicts.push((vault.kind.role_name(), verdict));
}
let worst = verdicts
.iter()
.map(|(_, v)| v.exit_code())
.max()
.unwrap_or(0);
let several = verdicts.len() > 1;
let value = if several {
let mut map = serde_json::Map::new();
for (name, verdict) in &verdicts {
map.insert((*name).to_owned(), serde_json::to_value(verdict)?);
}
serde_json::Value::Object(map)
} else {
serde_json::to_value(verdicts.first().map(|(_, v)| v))?
};
emit(cli.json, &value, || {
for (name, verdict) in &verdicts {
if several {
println!(
"[gate:{} {name}] {} — {}",
verdict.tier,
verdict.gate.to_uppercase(),
verdict.caveat
);
} else {
println!(
"[gate:{}] {} — {}",
verdict.tier,
verdict.gate.to_uppercase(),
verdict.caveat
);
}
for check in &verdict.checks {
println!(" [{}] {}: {}", check.status, check.name, check.detail);
}
}
})?;
Ok(ExitCode::from(u8::try_from(worst).unwrap_or(1)))
}
Command::Conflicts(args) => {
let mut reports: Vec<(&'static str, conflicts::Report)> = Vec::new();
for vault in load(&root, &profile, &args.selector.kinds())? {
let corpus = Corpus::build(&vault, &vocab);
reports.push((
vault.kind.role_name(),
conflicts::analyse(&corpus, &args.severity),
));
}
let worst = reports
.iter()
.map(|(_, r)| r.exit_code())
.max()
.unwrap_or(0);
let several = reports.len() > 1;
let value = if several {
let mut map = serde_json::Map::new();
for (name, report) in &reports {
map.insert((*name).to_owned(), serde_json::to_value(report)?);
}
serde_json::Value::Object(map)
} else {
serde_json::to_value(reports.first().map(|(_, r)| r))?
};
emit(cli.json, &value, || {
for (name, report) in &reports {
if several {
println!("Conflicts in {name}: {} total", report.summary.total);
} else {
println!("Conflicts: {} total", report.summary.total);
}
for conflict in report.conflicts.iter().take(40) {
println!(
" [{:?}] {}: {}",
conflict.severity, conflict.kind, conflict.detail
);
}
}
})?;
Ok(ExitCode::from(u8::try_from(worst).unwrap_or(1)))
}
Command::Build(args) => {
let kinds = vault_kinds(&args.vault);
let machine = args.scope == ScopeArg::Machine;
let kind = if kinds == [VaultKind::Working] && !machine {
VaultKind::Working
} else {
VaultKind::Knowledge
};
let options = build::Options {
with_working_publish: kinds.contains(&VaultKind::Working)
&& kind == VaultKind::Knowledge,
publish_out: args.publish_out.clone(),
vault_root: vault_root(&root, &profile, kind)?,
out: args.out.clone(),
repo_root: root.clone(),
with_markdown_mirror: args.with_markdown_mirror,
generated_at: args.generated_at.clone(),
source: args
.source_commit
.clone()
.map(|commit| (commit, args.source_dirty)),
stale_after: args.stale_after.clone(),
redact_private_links: args.no_redact_private_links.then_some(false),
scope: args.scope.into(),
};
let embedder: Option<Box<dyn build::records::Embedder>> = args.with_rvdb.then(|| {
Box::new(embed::Xinference::new(args.embed_endpoint.clone()))
as Box<dyn build::records::Embedder>
});
let report = match build::run_with(
&options,
&profile,
&vocab,
&ontomark_compile::whelk::WhelkEl,
embedder.as_deref(),
) {
Ok(report) => report,
Err(e) => {
if let Some(secrets) = e.downcast_ref::<build::publish::SecretsFound>() {
eprintln!("ontomark: {secrets}");
return Ok(ExitCode::from(2));
}
if let Some(clash) = e.downcast_ref::<build::OutputCollision>() {
eprintln!("ontomark: {clash}");
return Ok(ExitCode::from(2));
}
if let Some(cap) = e.downcast_ref::<reason::ReasoningError>() {
eprintln!("ontomark: {cap}; no bundle written");
return Ok(ExitCode::from(2));
}
if let Some(conflict) = e.downcast_ref::<build::ScopeConflict>() {
eprintln!("ontomark: {conflict}");
return Ok(ExitCode::from(2));
}
if let Some(unsat) = e.downcast_ref::<build::Unsatisfiable>() {
eprintln!("ontomark: {unsat}");
return Ok(ExitCode::from(2));
}
return Err(e);
}
};
let value = serde_json::to_value(&report)?;
emit(cli.json || args.stats, &value, || {
println!(
"{} -> {} ({} artefact(s) written)",
report.generation.id,
report.out.display(),
report.written
);
for (vault, counts) in &report.published {
println!(
" published {vault}: {} of {} page(s)",
counts.published, counts.considered
);
}
})?;
anyhow::ensure!(
report.written > 0,
"the build wrote no artefacts to {}; refusing to report success",
report.out.display()
);
Ok(ExitCode::SUCCESS)
}
Command::Find(args) => {
let mut hits = Vec::new();
for vault in load(&root, &profile, &args.selector.kinds())? {
let graph = VaultGraph::build(&vault, &vocab);
hits.extend(graph.find(
&args.query,
args.r#type.as_deref(),
args.limit,
args.fuzzy,
));
}
graph::sort_hits(&mut hits);
hits.truncate(args.limit);
let value = serde_json::to_value(&hits)?;
emit(cli.json, &value, || {
for hit in &hits {
println!("{:.2} {}", hit.score, hit.id);
}
})?;
Ok(ExitCode::SUCCESS)
}
Command::Retrieve(args) => {
let expand = parse_expansion(&args.expand)?;
let vault = load(&root, &profile, &args.selector.kinds())?
.into_iter()
.next()
.context("no vault selected")?;
let graph = VaultGraph::build(&vault, &vocab);
let result = graph.retrieve(&args.ids, &expand, args.max_documents);
let value = serde_json::to_value(&result)?;
emit(cli.json, &value, || {
for seed in &result.seeds {
println!("seed {}", seed.id);
}
for node in &result.expanded {
println!(
" +{} {} (via {} from {})",
node.depth, node.id, node.via, node.from
);
}
if result.truncated {
println!("(truncated at --max-documents)");
}
})?;
Ok(ExitCode::SUCCESS)
}
Command::Tree(args) => {
let vault = load(&root, &profile, &args.selector.kinds())?
.into_iter()
.next()
.context("no vault selected")?;
let graph = VaultGraph::build(&vault, &vocab);
let tree = graph
.tree(&args.id, &args.predicate, args.depth)
.with_context(|| format!("no page matching `{}`", args.id))?;
let value = serde_json::to_value(&tree)?;
emit(cli.json, &value, || print_tree(&tree, 0))?;
Ok(ExitCode::SUCCESS)
}
Command::Edit(args) => {
let Some(expect_raw) = &args.expect else {
anyhow::bail!(
"refused: --expect must declare docs and blocks; \
e.g. --expect docs=1,blocks=1"
);
};
let expect = edit::Expectation::parse(expect_raw).map_err(anyhow::Error::msg)?;
let changes = changes_of(&args.sets, &args.unsets).map_err(anyhow::Error::msg)?;
let vault = load(&root, &profile, &args.selector.kinds())?
.into_iter()
.next()
.context("no vault selected")?;
let page = vault
.get(&args.id)
.with_context(|| format!("no page with id `{}`", args.id))?;
let outcome = edit::apply(page, &changes, expect).map_err(anyhow::Error::msg)?;
if !args.dry_run {
std::fs::write(&page.path, &outcome.rendered)
.with_context(|| format!("writing {}", page.path.display()))?;
}
let value = serde_json::to_value(&outcome)?;
emit(cli.json, &value, || {
println!(
"{}: {} document(s), {} key(s){}",
outcome.page,
outcome.docs,
outcome.blocks,
if args.dry_run { " (dry run)" } else { "" }
);
print_changes(&outcome.changes);
})?;
Ok(ExitCode::SUCCESS)
}
Command::Create(args) => {
let expect = edit::Expectation::parse(args.expect.as_deref().unwrap_or(""))
.map_err(anyhow::Error::msg)?;
let changes = changes_of(&args.sets, &args.unsets).map_err(anyhow::Error::msg)?;
let staged = std::fs::read_to_string(&args.file)
.with_context(|| format!("reading {}", args.file.display()))?;
let vault = load(&root, &profile, &[VaultKind::Knowledge])?
.into_iter()
.next()
.context("no knowledge vault")?;
let refuse = |error: &create::CreateError| -> anyhow::Result<ExitCode> {
let value = json!({
"created": false,
"file": args.file.display().to_string(),
"code": error.code(),
"message": error.to_string(),
"blockers": error.blockers(),
});
emit(cli.json, &value, || {})?;
eprintln!("ontomark: {error}");
Ok(ExitCode::from(2))
};
let outcome = match create::prepare(&profile, &vault, &vocab, &staged, &changes, expect)
{
Ok(outcome) => outcome,
Err(error) => return refuse(&error),
};
if !args.dry_run {
if let Err(e) = create::write(&outcome) {
return match e.downcast_ref::<create::CreateError>() {
Some(error) => refuse(error),
None => Err(e),
};
}
}
let mut value = serde_json::to_value(&outcome)?;
value["dry_run"] = json!(args.dry_run);
emit(cli.json, &value, || {
println!(
"{} {}{}",
if args.dry_run {
"would create"
} else {
"created"
},
outcome.path,
if args.dry_run { " (dry run)" } else { "" }
);
print_changes(&outcome.changes);
})?;
Ok(ExitCode::SUCCESS)
}
Command::Apply(args) => {
let refuse =
|code: &str, message: &str, blockers: &[Blocker]| -> anyhow::Result<ExitCode> {
let value = json!({
"applied": false,
"code": code,
"message": message,
"blockers": blockers,
});
emit(cli.json, &value, || {})?;
eprintln!("ontomark: {message}");
Ok(ExitCode::from(2))
};
let refuse_with = |error: &apply::ApplyError| {
refuse(error.code(), &error.to_string(), error.blockers())
};
let Some(expect_raw) = &args.expect else {
return refuse_with(&apply::ApplyError::Expect(
"--expect must declare docs=1 for an apply".into(),
));
};
let expect = match edit::Expectation::parse(expect_raw) {
Ok(expect) => expect,
Err(e) => return refuse_with(&apply::ApplyError::Expect(e)),
};
let changes = match changes_of(&args.sets, &args.unsets) {
Ok(changes) => changes,
Err(e) => return refuse("ARGUMENT", &format!("refused: {e}"), &[]),
};
let text = std::fs::read_to_string(&args.file)
.with_context(|| format!("reading {}", args.file.display()))?;
let proposal = match apply::read_proposal(&text) {
Ok(proposal) => proposal,
Err(error) => return refuse_with(&error),
};
let vault = load(&root, &profile, &[VaultKind::Knowledge])?
.into_iter()
.next()
.context("no knowledge vault")?;
let outcome =
match apply::prepare(&profile, &vault, &vocab, &proposal, &changes, expect) {
Ok(outcome) => outcome,
Err(error) => return refuse_with(&error),
};
if let Err(e) = apply::write(&outcome) {
return match e.downcast_ref::<apply::ApplyError>() {
Some(error) => refuse_with(error),
None => Err(e),
};
}
let value = serde_json::to_value(&outcome)?;
emit(cli.json, &value, || {
println!("applied {} to {}", outcome.digest, outcome.path);
print_changes(&outcome.changes);
})?;
Ok(ExitCode::SUCCESS)
}
Command::Repair(RepairCommand::Bodies(args)) => {
let scopes = repair_scopes(&root, &profile, &vault_kinds(&args.vault))?;
let report = bodies::run(&bodies::Options {
scopes,
dry_run: args.dry_run || args.check,
})?;
if let Some(path) = &args.report {
let mut text = serde_json::to_string_pretty(&report)?;
text.push('\n');
std::fs::write(path, text)
.with_context(|| format!("writing {}", path.display()))?;
}
let value = serde_json::to_value(&report)?;
emit(cli.json, &value, || {
let verb = if args.check {
"need converting"
} else if args.dry_run {
"would be converted"
} else {
"converted"
};
println!(
"{} page(s) examined, {} {verb}",
report.pages_examined, report.pages_converted
);
for change in report.changes.iter().take(10) {
println!(" {}/{}", change.vault, change.id);
}
if report.changes.len() > 10 {
println!(
" … {} more; pass --report for the full list",
report.changes.len() - 10
);
}
})?;
if args.check && report.pages_converted > 0 {
return Ok(ExitCode::FAILURE);
}
Ok(ExitCode::SUCCESS)
}
Command::Repair(RepairCommand::Fences(args)) => {
let scopes = repair_scopes(&root, &profile, &vault_kinds(&args.vault))?;
let report = repair::run(&repair::Options {
scopes,
dry_run: args.dry_run,
})?;
if let Some(path) = &args.report {
let mut text = serde_json::to_string_pretty(&report)?;
text.push('\n');
std::fs::write(path, text)
.with_context(|| format!("writing {}", path.display()))?;
}
let value = serde_json::to_value(&report)?;
emit(cli.json, &value, || {
println!(
"{} page(s) examined, {} repaired: {} spurious opener(s) removed, \
{} closing fence(s) inserted{}",
report.pages_examined,
report.pages_repaired,
report.openers_removed,
report.closers_inserted,
if args.dry_run { " (dry run)" } else { "" }
);
for change in report.changes.iter().take(10) {
println!(" {}/{}: {:?}", change.vault, change.id, change.action);
}
if report.changes.len() > 10 {
println!(
" … {} more; pass --report for the full list",
report.changes.len() - 10
);
}
if !report.ambiguous.is_empty() {
println!(
" {} page(s) NOT repaired — the unmatched marker closes the code \
above it, so the missing one is earlier in the file and a human \
has to place it. These still fail UNTERMINATED_FENCE, by design.",
report.ambiguous.len()
);
for a in report.ambiguous.iter().take(5) {
println!(" {}/{}:{}", a.vault, a.id, a.line);
}
}
})?;
Ok(ExitCode::SUCCESS)
}
}
}