use anyhow::Result;
use serde_json::{Value, json};
use sha2::{Digest as _, Sha256};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
const KIT_SCRIPT: &[u8] = include_bytes!("../assets/docgen/document-kit.js");
const KIT_PDF_FONT: &[u8] = include_bytes!("../assets/docgen/NotoSans-Regular.ttf");
const RULES_JSON: &str = include_str!("../assets/docgen/rules.json");
pub(crate) fn rules() -> &'static Value {
static RULES: OnceLock<Value> = OnceLock::new();
RULES.get_or_init(|| {
serde_json::from_str(RULES_JSON).expect("assets/docgen/rules.json must be valid JSON")
})
}
#[derive(serde::Deserialize)]
pub(crate) struct PptMarks {
pub(crate) title: String,
pub(crate) hidden_slide: String,
pub(crate) diagram_text: String,
pub(crate) diagram_text_lost: String,
pub(crate) no_text: String,
}
pub(crate) fn ppt_marks() -> &'static PptMarks {
static MARKS: OnceLock<PptMarks> = OnceLock::new();
MARKS.get_or_init(|| {
serde::Deserialize::deserialize(&rules()["ppt_marks"])
.expect("assets/docgen/rules.json must carry the ppt_marks the reader prints")
})
}
#[derive(serde::Deserialize)]
pub(crate) struct PptSlideLabels {
pub(crate) slide: String,
pub(crate) notes: String,
}
pub(crate) fn ppt_slide_labels() -> &'static PptSlideLabels {
static LABELS: OnceLock<PptSlideLabels> = OnceLock::new();
LABELS.get_or_init(|| {
serde::Deserialize::deserialize(&rules()["ppt_slide_labels"])
.expect("assets/docgen/rules.json must carry the ppt_slide_labels a reader prints")
})
}
#[derive(serde::Deserialize)]
pub(crate) struct DocxMark {
pub(crate) pattern: String,
pub(crate) example: String,
}
pub(crate) fn docx_marks() -> &'static [DocxMark] {
static MARKS: OnceLock<Vec<DocxMark>> = OnceLock::new();
MARKS.get_or_init(|| {
let marks: Vec<DocxMark> = serde::Deserialize::deserialize(&rules()["docx_marks"])
.expect("assets/docgen/rules.json must carry the docx_marks a reader prints");
for mark in &marks {
regex::Regex::new(&mark.pattern)
.expect("a docx mark's pattern must be a regex the kit and this crate both accept");
}
marks
})
}
#[derive(serde::Deserialize)]
pub(crate) struct DocxPeripheralPart {
pub(crate) kind: String,
pub(crate) rel: String,
#[serde(default)]
pub(crate) part: Option<String>,
}
pub(crate) fn docx_peripheral_part(kind: &str) -> &'static DocxPeripheralPart {
static PARTS: OnceLock<Vec<DocxPeripheralPart>> = OnceLock::new();
PARTS
.get_or_init(|| {
serde::Deserialize::deserialize(&rules()["docx_peripheral_parts"]).expect(
"assets/docgen/rules.json must carry the docx_peripheral_parts an edit leaves alone",
)
})
.iter()
.find(|part| part.kind == kind)
.expect("assets/docgen/rules.json must name every peripheral part a docx edit leaves alone")
}
const KIT_DIR_NAME: &str = "mahbot-document-kit";
const KIT_SCRIPT_NAME: &str = "document-kit.js";
const KIT_FONT_NAME: &str = "NotoSans-Regular.ttf";
const KIT_SCRATCH_NAME: &str = "scratch";
const KIT_HASH_BYTES: usize = 8;
const OUTPUT_TAIL_CHARS: usize = 2000;
pub(crate) struct KitOutcome {
pub outputs: Vec<PathBuf>,
pub missing: Vec<String>,
pub placeholders: Option<usize>,
pub unsupported: Vec<String>,
pub notes: Vec<String>,
}
pub(crate) async fn run(request: Value) -> Result<KitOutcome> {
let value = invoke(request).await?;
Ok(KitOutcome {
outputs: string_array(&value, "outputs")
.into_iter()
.map(PathBuf::from)
.collect(),
missing: string_array(&value, "missing"),
placeholders: value
.get("placeholders")
.and_then(Value::as_u64)
.and_then(|count| usize::try_from(count).ok()),
unsupported: string_array(&value, "unsupported"),
notes: string_array(&value, "notes"),
})
}
pub(crate) async fn probe() -> Result<()> {
static PROBED: OnceLock<()> = OnceLock::new();
if PROBED.get().is_some() {
return Ok(());
}
managed_runtime()?;
let dir = kit_directory().await?;
let scratch = dir
.join(KIT_SCRATCH_NAME)
.join(format!("probe-{:016x}", rand::random::<u64>()));
let result = invoke(json!({ "op": "probe", "scratch": scratch })).await;
let _ = tokio::fs::remove_dir_all(&scratch).await;
result?;
let _ = PROBED.set(());
Ok(())
}
async fn invoke(mut request: Value) -> Result<Value> {
let runtime = managed_runtime()?;
let op = request
.get("op")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let dir = kit_directory().await?;
let nonce = format!("{:016x}", rand::random::<u64>());
let request_path = dir
.join(KIT_SCRATCH_NAME)
.join(format!("request-{nonce}.json"));
let result_path = dir
.join(KIT_SCRATCH_NAME)
.join(format!("result-{nonce}.json"));
request["result"] = Value::String(result_path.to_string_lossy().into_owned());
request["font"] = Value::String(dir.join(KIT_FONT_NAME).to_string_lossy().into_owned());
let outcome = async {
let body = serde_json::to_vec(&request).map_err(|e| {
crate::tools::internal_fault(&format!("failed to encode the document kit request: {e}"))
})?;
tokio::fs::write(&request_path, body).await.map_err(|e| {
crate::tools::internal_fault(&format!(
"failed to write the document kit request for `{op}`: {e}"
))
})?;
let output = run_kit(dir, &runtime, &request_path).await?;
read_result(&result_path, &output, &op).await
}
.await;
let _ = tokio::fs::remove_file(&request_path).await;
let _ = tokio::fs::remove_file(&result_path).await;
outcome
}
fn managed_runtime() -> Result<PathBuf> {
crate::tools::bun::bun_binary_path().ok_or_else(|| {
crate::tools::internal_fault(
"the document kit cannot run: the managed bun runtime is unavailable",
)
})
}
async fn run_kit(dir: &Path, runtime: &Path, request_path: &Path) -> Result<String> {
let args = vec![
"--no-install".to_string(),
"--no-env-file".to_string(),
dir.join(KIT_SCRIPT_NAME).to_string_lossy().into_owned(),
request_path.to_string_lossy().into_owned(),
];
crate::tools::shell::run_internal_program_with_timeout(dir, runtime, &args, "the document kit")
.await
}
async fn read_result(path: &Path, output: &str, op: &str) -> Result<Value> {
let bytes = match tokio::fs::read(path).await {
Ok(bytes) => bytes,
Err(e) => {
return Err(crate::tools::internal_fault(&format!(
"the document kit gave no result for `{op}` ({}){}",
e.kind(),
output_tail(output)
)));
}
};
let value: Value = match serde_json::from_slice(&bytes) {
Ok(value) => value,
Err(e) => {
return Err(crate::tools::internal_fault(&format!(
"the document kit returned an unreadable result for `{op}`: {e}{}",
output_tail(output)
)));
}
};
if value.get("ok").and_then(Value::as_bool) == Some(true) {
return Ok(value);
}
let message = value
.get("error")
.and_then(Value::as_str)
.unwrap_or_default()
.trim();
let reason = if message.is_empty() {
format!("no reason given{}", output_tail(output))
} else {
message.to_string()
};
if value.get("class").and_then(Value::as_str) == Some("usage") {
anyhow::bail!("usage: {reason}");
}
Err(crate::tools::internal_fault(&format!(
"the document kit could not complete `{op}`: {reason}"
)))
}
async fn kit_directory() -> Result<&'static Path> {
static DIR: OnceLock<PathBuf> = OnceLock::new();
static SWEPT: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
let root = crate::config::CONFIG.try_storage_root().ok_or_else(|| {
crate::tools::internal_fault(
"the document kit cannot be prepared: the storage root is not resolved",
)
})?;
let dir = DIR.get_or_init(|| root.join(KIT_DIR_NAME).join(content_version()));
materialize(dir).await.map_err(|e| {
crate::tools::internal_fault(&format!(
"failed to prepare the document kit at {}: {e}",
dir.display()
))
})?;
SWEPT
.get_or_init(|| async { sweep_kit_directory(dir).await })
.await;
Ok(dir)
}
fn content_version() -> String {
let mut hasher = Sha256::new();
hasher.update(KIT_SCRIPT);
hasher.update(KIT_PDF_FONT);
let hex = crate::util::hex_string(&hasher.finalize());
hex[..KIT_HASH_BYTES * 2].to_string()
}
async fn materialize(dir: &Path) -> std::io::Result<()> {
tokio::fs::create_dir_all(dir).await?;
write_if_missing(&dir.join(KIT_SCRIPT_NAME), KIT_SCRIPT).await?;
write_if_missing(&dir.join(KIT_FONT_NAME), KIT_PDF_FONT).await
}
async fn sweep_kit_directory(dir: &Path) {
let scratch = dir.join(KIT_SCRATCH_NAME);
let _ = tokio::fs::remove_dir_all(&scratch).await;
let _ = tokio::fs::create_dir_all(&scratch).await;
let Some(parent) = dir.parent() else {
return;
};
let Ok(mut entries) = tokio::fs::read_dir(parent).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
if entry.path() != dir {
let _ = tokio::fs::remove_dir_all(entry.path()).await;
}
}
}
async fn write_if_missing(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if tokio::fs::metadata(path)
.await
.is_ok_and(|m| m.len() == bytes.len() as u64)
{
return Ok(());
}
let tmp = path.with_extension(format!("{:016x}.part", rand::random::<u64>()));
tokio::fs::write(&tmp, bytes).await?;
match tokio::fs::rename(&tmp, path).await {
Ok(()) => Ok(()),
Err(_)
if tokio::fs::metadata(path)
.await
.is_ok_and(|m| m.len() == bytes.len() as u64) =>
{
let _ = tokio::fs::remove_file(&tmp).await;
Ok(())
}
Err(e) => {
let _ = tokio::fs::remove_file(&tmp).await;
Err(e)
}
}
}
fn output_tail(output: &str) -> String {
let tail = output.trim();
if tail.is_empty() {
String::new()
} else {
format!("\n{}", crate::util::truncate(tail, OUTPUT_TAIL_CHARS))
}
}
fn string_array(value: &Value, key: &str) -> Vec<String> {
value
.get(key)
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
const KIT_SOURCES: [&[u8]; 4] = [
include_bytes!("../assets/docgen/kit.js"),
RULES_JSON.as_bytes(),
include_bytes!("../assets/docgen/package.json"),
include_bytes!("../assets/docgen/bun.lock"),
];
const HEADER_BYTES: usize = 512;
#[tokio::test]
async fn a_kit_failure_carries_the_class_the_kit_reported() {
let dir = tempfile::tempdir().expect("tempdir");
let result = dir.path().join("result.json");
let usage =
br#"{"ok":false,"error":"no page 5: this document has 1 page(s)","class":"usage"}"#;
tokio::fs::write(&result, usage).await.expect("write");
let error = read_result(&result, "", "pdf_rotate")
.await
.expect_err("a failed result");
assert_eq!(
error.to_string(),
"usage: no page 5: this document has 1 page(s)"
);
tokio::fs::write(&result, br#"{"ok":false,"error":"the kit broke"}"#)
.await
.expect("write");
let error = read_result(&result, "", "create")
.await
.expect_err("a failed result");
let message = error.to_string();
assert!(message.starts_with("internal: "), "{message}");
assert!(message.contains("the kit broke"), "{message}");
tokio::fs::remove_file(&result).await.expect("remove");
let error = read_result(&result, "the run said nothing", "create")
.await
.expect_err("a failed result");
assert!(error.to_string().starts_with("internal: "), "{error}");
}
#[tokio::test]
async fn write_if_missing_keeps_a_complete_copy_and_replaces_a_partial_one() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("kit.js");
write_if_missing(&path, b"first").await.expect("write");
write_if_missing(&path, b"other").await.expect("write");
assert_eq!(
tokio::fs::read(&path).await.expect("read"),
b"first",
"a file of the expected length is this build's own copy"
);
write_if_missing(&path, b"a much longer body")
.await
.expect("write");
assert_eq!(
tokio::fs::read(&path).await.expect("read"),
b"a much longer body"
);
let mut entries = tokio::fs::read_dir(dir.path()).await.expect("read dir");
let mut names = Vec::new();
while let Some(entry) = entries.next_entry().await.expect("entry") {
names.push(entry.file_name().to_string_lossy().into_owned());
}
assert_eq!(names, ["kit.js"], "no temporary sibling is left behind");
}
#[tokio::test]
async fn the_kit_directory_is_swept_once() {
let root = tempfile::tempdir().expect("tempdir");
let parent = root.path().join(KIT_DIR_NAME);
let current = parent.join("current");
let stale = parent.join("stale");
let scratch = current.join(KIT_SCRATCH_NAME);
tokio::fs::create_dir_all(&stale).await.expect("create");
tokio::fs::write(stale.join("document-kit.js"), b"old")
.await
.expect("write");
tokio::fs::create_dir_all(scratch.join("probe-dead"))
.await
.expect("create");
tokio::fs::write(scratch.join("request-dead.json"), b"{}")
.await
.expect("write");
sweep_kit_directory(¤t).await;
assert!(current.exists(), "the version in use must survive");
assert!(!stale.exists(), "an older version must be dropped");
let mut names = Vec::new();
let mut entries = tokio::fs::read_dir(&scratch).await.expect("read scratch");
while let Some(entry) = entries.next_entry().await.expect("entry") {
names.push(entry.file_name().to_string_lossy().into_owned());
}
assert!(
names.is_empty(),
"the scratch of a killed run must be dropped, found: {names:?}"
);
}
#[test]
fn the_committed_bundle_is_the_build_of_the_committed_sources() {
let mut hasher = Sha256::new();
for source in KIT_SOURCES {
hasher.update(source);
}
let expected = crate::util::hex_string(&hasher.finalize());
let header = String::from_utf8_lossy(&KIT_SCRIPT[..HEADER_BYTES.min(KIT_SCRIPT.len())]);
assert!(
header.contains(&expected),
"assets/docgen/document-kit.js was not built from the committed kit.js/rules.json/\
package.json/bun.lock — run assets/docgen/build.sh and commit the bundle"
);
}
}