use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use crate::config::DocsVerifyConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeBlock {
pub lang: String,
pub code: String,
pub verify: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyOutcome {
Pass,
Fail { detail: String },
Skipped { reason: String },
Errored { reason: String },
}
pub fn extract_verifiable(body: &str) -> Vec<CodeBlock> {
let mut out = Vec::new();
let mut lines = body.lines();
while let Some(line) = lines.next() {
let Some(info) = line.trim_start().strip_prefix("```") else { continue };
let mut tokens = info
.split(|c: char| c.is_whitespace() || c == ',')
.filter(|t| !t.is_empty());
let lang = tokens.next().unwrap_or("").to_ascii_lowercase();
let flags: Vec<String> = tokens.map(|t| t.to_ascii_lowercase()).collect();
let verify = flags.iter().any(|f| f == "verify") && !flags.iter().any(|f| f == "no-verify");
let mut code = String::new();
for l in lines.by_ref() {
if l.trim_start().starts_with("```") {
break;
}
code.push_str(l);
code.push('\n');
}
if !lang.is_empty() {
out.push(CodeBlock { lang, code, verify });
}
}
out
}
static TEMP_SEQ: AtomicUsize = AtomicUsize::new(0);
pub fn run_block(block: &CodeBlock, cfg: &DocsVerifyConfig) -> VerifyOutcome {
let Some(cmd_tmpl) = cfg.runners.get(&block.lang) else {
return VerifyOutcome::Skipped { reason: format!("no runner configured for `{}`", block.lang) };
};
let ext = cfg.extensions.get(&block.lang).map(String::as_str).unwrap_or("txt");
let dir = std::env::temp_dir();
let stem = format!("inkhaven-tdoc-{}-{}", std::process::id(), TEMP_SEQ.fetch_add(1, Ordering::Relaxed));
let code_path: PathBuf = dir.join(format!("{stem}.{ext}"));
let out_path: PathBuf = dir.join(format!("{stem}.out"));
if let Err(e) = std::fs::write(&code_path, &block.code) {
return VerifyOutcome::Errored { reason: format!("write temp file: {e}") };
}
let cmd = cmd_tmpl
.replace("{file}", &code_path.to_string_lossy())
.replace("{dir}", &dir.to_string_lossy());
let outcome = (|| -> VerifyOutcome {
let out_file = match std::fs::File::create(&out_path) {
Ok(f) => f,
Err(e) => return VerifyOutcome::Errored { reason: format!("create output file: {e}") },
};
let err_file = match out_file.try_clone() {
Ok(f) => f,
Err(e) => return VerifyOutcome::Errored { reason: format!("clone output file: {e}") },
};
let mut child = match Command::new("sh")
.arg("-c")
.arg(&cmd)
.stdin(std::process::Stdio::null())
.stdout(out_file)
.stderr(err_file)
.spawn()
{
Ok(c) => c,
Err(e) => return VerifyOutcome::Errored { reason: format!("spawn runner: {e}") },
};
let deadline = Instant::now() + Duration::from_secs(cfg.timeout_seconds.max(1));
let (status, timed_out) = loop {
match child.try_wait() {
Ok(Some(s)) => break (Some(s), false),
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
break (None, true);
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => return VerifyOutcome::Errored { reason: format!("wait runner: {e}") },
}
};
let detail = std::fs::read_to_string(&out_path).unwrap_or_default();
let detail = tail(&detail, 40);
match status {
Some(s) if s.success() => VerifyOutcome::Pass,
Some(_) => VerifyOutcome::Fail { detail },
None if timed_out => VerifyOutcome::Fail {
detail: format!("timed out after {}s\n{detail}", cfg.timeout_seconds),
},
None => VerifyOutcome::Errored { reason: "runner ended abnormally".into() },
}
})();
let _ = std::fs::remove_file(&code_path);
let _ = std::fs::remove_file(&out_path);
outcome
}
pub fn emit_finding(para_id: uuid::Uuid, lang: &str, detail: &str) {
use crate::pane::output::{emit, kinds, Lifetime, Message, Severity};
let msg = Message::new(
kinds::DOC_VERIFY,
Severity::Warning,
Lifetime::UntilActedOn,
serde_json::json!({
"text": format!("code example failed `{lang}` verification"),
"category": "doc-verify",
"lang": lang,
"detail": detail,
}),
)
.with_source_paragraph(para_id);
emit(&msg);
}
fn tail(s: &str, n: usize) -> String {
let lines: Vec<&str> = s.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn cfg(runners: &[(&str, &str)]) -> DocsVerifyConfig {
let mut c = DocsVerifyConfig { enabled: true, ..Default::default() };
c.runners = runners.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
c
}
#[test]
fn extract_parses_language_and_verify_flag() {
let body = "#figure(caption: [x])[\n```rust verify\nfn main() {}\n```\n]\n\
prose\n```python\nprint(1)\n```\n```sh no-verify\necho hi\n```";
let blocks = extract_verifiable(body);
assert_eq!(blocks.len(), 3);
assert_eq!(blocks[0].lang, "rust");
assert!(blocks[0].verify);
assert!(blocks[0].code.contains("fn main"));
assert_eq!(blocks[1].lang, "python");
assert!(!blocks[1].verify, "no flag → not verified");
assert!(!blocks[2].verify, "no-verify wins");
}
#[test]
fn run_block_reports_pass_fail_and_skip() {
let c = cfg(&[("pass", "true"), ("boom", "false")]);
let mk = |lang: &str| CodeBlock { lang: lang.into(), code: "x\n".into(), verify: true };
assert_eq!(run_block(&mk("pass"), &c), VerifyOutcome::Pass);
assert!(matches!(run_block(&mk("boom"), &c), VerifyOutcome::Fail { .. }));
assert!(matches!(run_block(&mk("cobol"), &c), VerifyOutcome::Skipped { .. }));
}
#[test]
fn run_block_substitutes_file_and_captures_output() {
let mut c = DocsVerifyConfig { enabled: true, ..Default::default() };
c.extensions = BTreeMap::from([("txt".to_string(), "txt".to_string())]);
c.runners = BTreeMap::from([("txt".to_string(), "grep MARKER {file}".to_string())]);
let hit = CodeBlock { lang: "txt".into(), code: "has MARKER here\n".into(), verify: true };
let miss = CodeBlock { lang: "txt".into(), code: "nothing\n".into(), verify: true };
assert_eq!(run_block(&hit, &c), VerifyOutcome::Pass);
assert!(matches!(run_block(&miss, &c), VerifyOutcome::Fail { .. }));
}
#[test]
fn run_block_times_out() {
let mut c = DocsVerifyConfig { enabled: true, timeout_seconds: 1, ..Default::default() };
c.runners = BTreeMap::from([("slow".to_string(), "sleep 5".to_string())]);
let b = CodeBlock { lang: "slow".into(), code: "x\n".into(), verify: true };
match run_block(&b, &c) {
VerifyOutcome::Fail { detail } => assert!(detail.contains("timed out")),
other => panic!("expected timeout Fail, got {other:?}"),
}
}
}