#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
pub mod raster;
#[cfg(feature = "static-bundled")]
mod document_tui;
use bcode_model_provider_runtime::ProviderRuntime;
use bcode_plugin_sdk::path::display;
use bcode_plugin_sdk::prelude::*;
use bcode_tool::{
ListToolsRequest, OP_INVOKE_TOOL, OP_LIST_TOOLS, TOOL_SERVICE_INTERFACE_ID, ToolArtifact,
ToolDefinition, ToolInvocationLifecycleEvent, ToolInvocationLifecycleStage,
ToolInvocationRequest, ToolInvocationResponse, ToolInvocationResult, ToolList,
};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use thiserror::Error;
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
const DEFAULT_MAX_BYTES: usize = 20 * 1024 * 1024;
const MAX_BYTES: usize = 100 * 1024 * 1024;
const USER_AGENT: &str = concat!("Bcode/", env!("CARGO_PKG_VERSION"));
const DOCUMENT_PLUGIN_ID: &str = "bcode.document";
const DOCUMENT_REQUEST_SCHEMA: &str = "bcode.document.request";
const DOCUMENT_EXTRACT_SCHEMA: &str = "bcode.document.extract_result";
const DOCUMENT_STATUS_SCHEMA: &str = "bcode.document.status";
pub struct DocumentPlugin {
runtime: Result<ProviderRuntime, String>,
}
impl Default for DocumentPlugin {
fn default() -> Self {
Self {
runtime: ProviderRuntime::new().map_err(|error| error.to_string()),
}
}
}
impl RustPlugin for DocumentPlugin {
fn deactivate(&mut self) -> Result<(), PluginError> {
if let Ok(runtime) = &self.runtime {
runtime
.shutdown(Duration::from_secs(5))
.map_err(|error| PluginError::failed(error.to_string()))?;
}
Ok(())
}
fn invoke_service(&mut self, context: NativeServiceContext) -> ServiceResponse {
match context.request.interface_id.as_str() {
TOOL_SERVICE_INTERFACE_ID => self.invoke_tool_service(&context),
_ => ServiceResponse::error(
"unsupported_interface",
"unsupported document plugin service interface",
),
}
}
}
#[derive(Clone)]
struct ProgressReporter {
events: ServiceEventEmitter,
tool_call_id: String,
working_directory: PathBuf,
sequence: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
impl ProgressReporter {
fn new(events: ServiceEventEmitter, tool_call_id: String, working_directory: PathBuf) -> Self {
Self {
events,
tool_call_id,
working_directory,
sequence: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
}
}
fn emit_path(&self, label: &str, path: &Path) {
self.emit(format!(
"{label}: {}",
display(path, &self.working_directory)
));
}
fn emit(&self, message: impl Into<String>) {
let sequence = self
.sequence
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
.saturating_add(1);
let event = progress_lifecycle_event(&self.tool_call_id, sequence, message.into());
if let Ok(payload) = serde_json::to_vec(&event) {
self.events.emit(&payload);
}
}
}
fn progress_lifecycle_event(
invocation_id: &str,
sequence: u64,
message: String,
) -> ToolInvocationLifecycleEvent {
ToolInvocationLifecycleEvent {
invocation_id: invocation_id.to_owned(),
sequence,
stage: ToolInvocationLifecycleStage::Progress,
message: Some(message),
metadata: serde_json::Value::Null,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct DocumentPreparationDescriptor {
#[serde(default)]
workspace_root: Option<PathBuf>,
#[serde(default)]
source_path: Option<PathBuf>,
}
fn document_workspace_root(
request: &bcode_tool::ToolPreparationRequest,
) -> Result<Option<PathBuf>, String> {
let mut matching = request
.host_context
.iter()
.filter(|entry| entry.schema == bcode_tool::TOOL_WORKSPACE_CONTEXT_SCHEMA);
let Some(entry) = matching.next() else {
return Ok(None);
};
if matching.next().is_some() {
return Err("duplicate Document workspace host context".to_owned());
}
if entry.schema_version != bcode_tool::TOOL_WORKSPACE_CONTEXT_SCHEMA_VERSION {
return Err(format!(
"unsupported Document workspace host context version {}; expected {}",
entry.schema_version,
bcode_tool::TOOL_WORKSPACE_CONTEXT_SCHEMA_VERSION
));
}
let root = entry
.payload
.get("working_directory")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "Document workspace host context working_directory is missing".to_owned())?;
let root = PathBuf::from(root);
if !root.is_absolute() {
return Err("Document workspace working directory must be absolute".to_owned());
}
Ok(Some(root))
}
fn document_policy_operation(
request: &bcode_tool::ToolPreparationRequest,
definition: &ToolDefinition,
) -> Result<bcode_plugin_sdk::ToolPolicyPreparation, String> {
let workspace_root = document_workspace_root(request)?;
let (operation, source_path) = match definition.name.as_str() {
"document.status" => (bcode_plugin_sdk::ToolPolicyOperation::ReadOnly, None),
"document.extract" => {
let extract =
serde_json::from_value::<ExtractRequest>(request.invocation.arguments.clone())
.map_err(|error| format!("invalid Document extraction request: {error}"))?;
match source(&extract).map_err(|error| error.to_string())? {
DocumentSource::Url(url) => (
bcode_plugin_sdk::ToolPolicyOperation::Web { url: Some(url) },
None,
),
DocumentSource::Path(path) => {
let path = if path.is_absolute() {
path
} else {
workspace_root
.as_ref()
.ok_or_else(|| {
"Document relative path requires workspace host context".to_owned()
})?
.join(path)
};
(
bcode_plugin_sdk::ToolPolicyOperation::Read {
paths: vec![path.display().to_string()],
},
Some(path),
)
}
}
}
name => return Err(format!("unsupported document policy operation: {name}")),
};
let identity = match definition.name.as_str() {
"document.extract" => bcode_plugin_sdk::ToolPolicyIdentity {
aliases: vec!["read".to_string()],
compatibility_aliases: Vec::new(),
capabilities: Vec::new(),
permission_category: Some("read".to_string()),
},
"document.status" => bcode_plugin_sdk::ToolPolicyIdentity::default(),
name => return Err(format!("unsupported document policy operation: {name}")),
};
Ok(bcode_plugin_sdk::ToolPolicyPreparation::new(
definition.name == "document.extract",
operation,
)
.with_identity(identity)
.with_descriptor(
serde_json::to_value(DocumentPreparationDescriptor {
workspace_root,
source_path,
})
.map_err(|error| error.to_string())?,
))
}
fn apply_document_preparation(
request: &mut ExtractRequest,
descriptor: &DocumentPreparationDescriptor,
) -> Result<(), String> {
match source(request).map_err(|error| error.to_string())? {
DocumentSource::Path(_) => {
let source_path = descriptor.source_path.clone().ok_or_else(|| {
"Document preparation descriptor is missing the local source path".to_owned()
})?;
request.path = Some(source_path);
}
DocumentSource::Url(_) if descriptor.source_path.is_some() => {
return Err(
"Document preparation descriptor contains a source path for a URL request"
.to_owned(),
);
}
DocumentSource::Url(_) => {}
}
Ok(())
}
impl DocumentPlugin {
fn invoke_tool_service(&self, context: &NativeServiceContext) -> ServiceResponse {
let request = &context.request;
match request.operation.as_str() {
OP_LIST_TOOLS => list_tools(request),
bcode_tool::OP_PREPARE_TOOL => prepare_tool_service_response(
request,
[extract_tool_definition(), status_tool_definition()],
document_policy_operation,
),
OP_INVOKE_TOOL => self.invoke_tool(context),
_ => ServiceResponse::error(
"unsupported_operation",
"unsupported document tool service operation",
),
}
}
fn invoke_tool(&self, context: &NativeServiceContext) -> ServiceResponse {
let request = &context.request;
let invocation = match request.payload_json::<ToolInvocationRequest>() {
Ok(invocation) => invocation,
Err(error) => return invalid_request(&error),
};
if context.cancellation.is_cancelled() {
return json_response(&tool_error("document tool cancelled".to_string()));
}
let mut presentation = PrimaryPresentationPublisher::with_limits_and_cancellation(
context.events,
&invocation.tool_call_id,
DOCUMENT_PLUGIN_ID,
DOCUMENT_REQUEST_SCHEMA,
1,
bcode_tool::ToolPresentationRetention::RetainLatest,
context.transient_progress_limits,
context.cancellation.clone(),
);
let _ = presentation.replace(&request_visual_payload(
&invocation.name,
&invocation.arguments,
));
let response = match invocation.name.as_str() {
"document.extract" => self.invoke_extract(&invocation, context.events),
"document.status" => invoke_status(&invocation.tool_call_id),
_ => ToolInvocationResponse {
output: format!("unsupported document tool: {}", invocation.name),
is_error: true,
content: Vec::new(),
full_output: None,
result: None,
},
};
json_response(&response)
}
fn invoke_extract(
&self,
invocation: &ToolInvocationRequest,
events: ServiceEventEmitter,
) -> ToolInvocationResponse {
let mut request =
match serde_json::from_value::<ExtractRequest>(invocation.arguments.clone()) {
Ok(request) => request,
Err(error) => return tool_error(error.to_string()),
};
let descriptor = match serde_json::from_value::<DocumentPreparationDescriptor>(
invocation.preparation_descriptor.clone(),
) {
Ok(descriptor) => descriptor,
Err(error) => {
return tool_error(format!("invalid Document preparation descriptor: {error}"));
}
};
if let Err(error) = apply_document_preparation(&mut request, &descriptor) {
return tool_error(error);
}
let runtime = match &self.runtime {
Ok(runtime) => runtime,
Err(error) => return tool_error(format!("document runtime unavailable: {error}")),
};
let progress = ProgressReporter::new(
events,
invocation.tool_call_id.clone(),
descriptor
.workspace_root
.unwrap_or_else(|| PathBuf::from(".")),
);
progress.emit("document extraction started");
match runtime.block_on(extract_async(request, Some(progress))) {
Ok(Ok(response)) => json_tool_response_with_artifact(
&response,
&invocation.tool_call_id,
"extract",
DOCUMENT_EXTRACT_SCHEMA,
"Document extraction",
),
Ok(Err(error)) => tool_error(error.to_string()),
Err(error) => tool_error(error.to_string()),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ExtractRequest {
#[serde(default)]
url: Option<String>,
#[serde(default)]
path: Option<PathBuf>,
#[serde(default)]
max_bytes: Option<usize>,
#[serde(default)]
timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExtractResponse {
pub source: String,
pub content_type: String,
pub artifact_kind: String,
pub text: String,
pub truncated: bool,
pub extractor: String,
pub fallback_used: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct DocumentStatusResponse {
extract: ExtractStatus,
}
#[derive(Debug, Clone, Serialize)]
struct ExtractStatus {
available: bool,
extractors: Vec<ExtractorStatus>,
configured_order: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
struct ExtractorStatus {
name: String,
available: bool,
quality: String,
}
#[derive(Debug, Error)]
enum DocumentError {
#[error("provide exactly one of url or path")]
InvalidSource,
#[error("url must start with http:// or https://")]
InvalidUrl,
#[error("document source must be a PDF for this extractor")]
UnsupportedDocument,
#[error("network request failed: {0}")]
Network(#[from] reqwest::Error),
#[error("I/O failed: {0}")]
Io(#[from] std::io::Error),
#[error("native PDF extraction failed: {0}")]
NativeExtract(String),
#[error("pdftotext failed with status {status}: {stderr}")]
PdfToTextFailed { status: String, stderr: String },
}
pub fn extract_document_path(path: &Path) -> Result<ExtractResponse, String> {
let runtime = ProviderRuntime::new().map_err(|error| error.to_string())?;
runtime
.block_on(extract_async(
ExtractRequest {
url: None,
path: Some(path.to_path_buf()),
max_bytes: None,
timeout_ms: None,
},
None,
))
.map_err(|error| error.to_string())?
.map_err(|error| error.to_string())
}
pub async fn extract_document(request: ExtractRequest) -> Result<ExtractResponse, String> {
extract_async(request, None)
.await
.map_err(|error| error.to_string())
}
async fn extract_async(
request: ExtractRequest,
progress: Option<ProgressReporter>,
) -> Result<ExtractResponse, DocumentError> {
let source = source(&request)?;
let scratch = document_scratch_directory()?;
let max_bytes = request
.max_bytes
.unwrap_or(DEFAULT_MAX_BYTES)
.clamp(1, MAX_BYTES);
let document_path = match &source {
DocumentSource::Url(url) => {
if let Some(progress) = &progress {
progress.emit(format!("document download started: {url}"));
}
download_document(
url,
scratch.path(),
max_bytes,
request.timeout_ms,
progress.clone(),
)
.await?
}
DocumentSource::Path(path) => {
if let Some(progress) = &progress {
progress.emit_path("document source path", path);
}
path.clone()
}
};
if !is_pdf_path(&document_path) {
return Err(DocumentError::UnsupportedDocument);
}
if let Some(progress) = &progress {
progress.emit("document text extraction started");
}
let text_path = scratch.path().join("extracted.txt");
let extraction = extract_pdf_text(&document_path, &text_path, progress.as_ref())?;
let bytes = extraction.text.as_bytes();
let truncated = bytes.len() > max_bytes;
let text = String::from_utf8_lossy(&bytes[..bytes.len().min(max_bytes)]).to_string();
if let Some(progress) = &progress {
progress.emit(format!(
"document text extracted: {} bytes{}",
bytes.len(),
if truncated { " (truncated)" } else { "" }
));
}
Ok(ExtractResponse {
source: source.as_string(),
content_type: "application/pdf".to_string(),
artifact_kind: "document_extraction".to_string(),
text,
truncated,
extractor: extraction.extractor,
fallback_used: extraction.fallback_used,
})
}
struct PdfExtraction {
text: String,
extractor: String,
fallback_used: Option<String>,
}
fn extract_pdf_text(
document_path: &Path,
text_path: &Path,
progress: Option<&ProgressReporter>,
) -> Result<PdfExtraction, DocumentError> {
if let Some(progress) = &progress {
progress.emit("document extraction candidates started");
}
let native = extract_pdf_text_native(document_path);
let layout = pdftotext_available()
.then(|| extract_pdf_text_pdftotext(document_path, text_path))
.transpose()?;
match (native, layout) {
(Ok(native), Some(layout)) => {
let native_score = extraction_quality_score(&native);
let layout_score = extraction_quality_score(&layout);
let (text, extractor, fallback_used) = if layout_score > native_score {
(
layout,
"pdftotext".to_owned(),
Some(format!(
"selected_higher_quality_layout_{layout_score}_over_native_{native_score}"
)),
)
} else {
(
native,
"native".to_owned(),
(layout_score < native_score).then(|| {
format!(
"selected_higher_quality_native_{native_score}_over_layout_{layout_score}"
)
}),
)
};
if let Some(progress) = &progress {
progress.emit(format!(
"document extraction selected {extractor}: {} bytes",
text.len()
));
}
Ok(PdfExtraction {
text,
extractor,
fallback_used,
})
}
(Ok(text), None) if meaningful_text(&text) => Ok(PdfExtraction {
text,
extractor: "native".to_owned(),
fallback_used: None,
}),
(Ok(text), None) => Ok(PdfExtraction {
text,
extractor: "native".to_owned(),
fallback_used: Some("native_low_text".to_owned()),
}),
(Err(_), Some(text)) => Ok(PdfExtraction {
text,
extractor: "pdftotext".to_owned(),
fallback_used: Some("native_unavailable".to_owned()),
}),
(Err(error), None) => Err(error),
}
}
fn extraction_quality_score(text: &str) -> i64 {
let tokens = text.split_whitespace().collect::<Vec<_>>();
if tokens.is_empty() {
return i64::MIN;
}
let isolated = tokens
.iter()
.filter(|token| {
token
.chars()
.filter(|character| character.is_alphanumeric())
.count()
== 1
})
.count();
let suspicious_lines = text
.lines()
.filter(|line| {
let line_tokens = line.split_whitespace().collect::<Vec<_>>();
let isolated = line_tokens
.iter()
.filter(|token| token.chars().count() == 1)
.count();
line_tokens.len() >= 8 && isolated * 2 >= line_tokens.len()
})
.count();
let section_hits = ["experience", "education", "skills", "technologies"]
.iter()
.filter(|heading| text.to_ascii_lowercase().contains(**heading))
.count();
i64::try_from(tokens.len()).unwrap_or(i64::MAX)
+ i64::try_from(section_hits).unwrap_or_default() * 100
- i64::try_from(isolated).unwrap_or(i64::MAX) * 4
- i64::try_from(suspicious_lines).unwrap_or(i64::MAX) * 250
}
fn extract_pdf_text_native(document_path: &Path) -> Result<String, DocumentError> {
pdf_extract::extract_text(document_path)
.map_err(|error| DocumentError::NativeExtract(error.to_string()))
}
fn meaningful_text(text: &str) -> bool {
text.chars()
.filter(|character| !character.is_whitespace())
.count()
>= 20
}
#[derive(Debug, Clone)]
enum DocumentSource {
Url(String),
Path(PathBuf),
}
impl DocumentSource {
fn as_string(&self) -> String {
match self {
Self::Url(url) => url.clone(),
Self::Path(path) => path.display().to_string(),
}
}
}
fn source(request: &ExtractRequest) -> Result<DocumentSource, DocumentError> {
let url = request.url.as_ref().filter(|url| !url.is_empty());
let path = request
.path
.as_ref()
.filter(|path| !path.as_os_str().is_empty());
match (url, path) {
(Some(url), None) => {
if url.starts_with("http://") || url.starts_with("https://") {
Ok(DocumentSource::Url(url.clone()))
} else {
Err(DocumentError::InvalidUrl)
}
}
(None, Some(path)) => Ok(DocumentSource::Path(path.clone())),
_ => Err(DocumentError::InvalidSource),
}
}
async fn download_document(
url: &str,
artifact_root: &Path,
max_bytes: usize,
timeout_ms: Option<u64>,
progress: Option<ProgressReporter>,
) -> Result<PathBuf, DocumentError> {
let client = Client::builder()
.timeout(Duration::from_millis(
timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS).max(1),
))
.user_agent(USER_AGENT)
.build()?;
let response = client.get(url).send().await?;
let final_url = response.url().to_string();
if let Some(progress) = &progress {
progress.emit(format!("document response received: {final_url}"));
}
let bytes = response.bytes().await?;
if let Some(progress) = &progress {
progress.emit(format!("document received {} bytes", bytes.len()));
}
let extension = if final_url.to_ascii_lowercase().contains(".pdf") {
"pdf"
} else {
"bin"
};
let path = artifact_root.join(format!("{}.{extension}", stable_name(&final_url)));
std::fs::write(&path, &bytes[..bytes.len().min(max_bytes)])?;
if let Some(progress) = &progress {
progress.emit("document download staged for extraction");
}
Ok(path)
}
fn extract_pdf_text_pdftotext(
document_path: &Path,
text_path: &Path,
) -> Result<String, DocumentError> {
let output = Command::new("pdftotext")
.arg("-layout")
.arg(document_path)
.arg(text_path)
.output()?;
if output.status.success() {
std::fs::read_to_string(text_path).map_err(DocumentError::Io)
} else {
Err(DocumentError::PdfToTextFailed {
status: output.status.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
}
fn pdftotext_available() -> bool {
Command::new("pdftotext")
.arg("-v")
.output()
.is_ok_and(|output| output.status.success() || !output.stderr.is_empty())
}
fn is_pdf_path(path: &Path) -> bool {
path.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("pdf"))
}
fn stable_name(value: &str) -> String {
let mut output = String::new();
for character in value.chars().take(120) {
output.push(match character {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => character,
_ => '_',
});
}
output.trim_matches('_').to_string()
}
fn document_scratch_directory() -> Result<tempfile::TempDir, DocumentError> {
tempfile::Builder::new()
.prefix("bcode-document-")
.tempdir()
.map_err(DocumentError::Io)
}
fn list_tools(request: &ServiceRequest) -> ServiceResponse {
if let Err(error) = request.payload_json::<ListToolsRequest>() {
return invalid_request(&error);
}
json_response(&ToolList::with_runtime_stable_discovery(
vec![extract_tool_definition(), status_tool_definition()],
|_tool| bcode_tool::ToolDiscoveryPolicy::new(true),
))
}
fn extract_tool_definition() -> ToolDefinition {
ToolDefinition {
name: "document.extract".to_string(),
description: "Extract text from PDF documents using native Rust extraction with optional pdftotext fallback.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "HTTP(S) PDF URL. Provide exactly one nonempty url or path; omit or leave empty when using path." },
"path": { "type": "string", "description": "Local PDF path. Provide exactly one nonempty path or url; omit or leave empty when using url." },
"max_bytes": { "type": "integer", "minimum": 1, "maximum": MAX_BYTES },
"timeout_ms": { "type": "integer", "minimum": 1 }
}
}),
}
}
fn status_tool_definition() -> ToolDefinition {
ToolDefinition {
name: "document.status".to_string(),
description: "Report available document extraction backends.".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
}
}
fn request_visual_payload(operation: &str, arguments: &serde_json::Value) -> serde_json::Value {
let mut payload = arguments.as_object().cloned().unwrap_or_default();
payload.insert(
"operation".to_owned(),
serde_json::Value::String(operation.to_owned()),
);
serde_json::Value::Object(payload)
}
fn invoke_status(tool_call_id: &str) -> ToolInvocationResponse {
json_tool_response_with_artifact(
&status_response(),
tool_call_id,
"status",
DOCUMENT_STATUS_SCHEMA,
"Document extractors",
)
}
fn status_response() -> DocumentStatusResponse {
let extractors = vec![
ExtractorStatus {
name: "native".to_string(),
available: true,
quality: "built_in".to_string(),
},
ExtractorStatus {
name: "pdftotext".to_string(),
available: pdftotext_available(),
quality: "external_optional".to_string(),
},
];
DocumentStatusResponse {
extract: ExtractStatus {
available: true,
configured_order: vec!["native".to_string(), "pdftotext".to_string()],
extractors,
},
}
}
fn json_response<T: Serialize>(value: &T) -> ServiceResponse {
match ServiceResponse::json(value) {
Ok(response) => response,
Err(error) => ServiceResponse::error("encode_failed", error.to_string()),
}
}
fn invalid_request(error: &serde_json::Error) -> ServiceResponse {
ServiceResponse::error("invalid_request", error.to_string())
}
fn json_tool_response_with_artifact<T: Serialize>(
value: &T,
tool_call_id: &str,
artifact_suffix: &str,
schema: &str,
title: &str,
) -> ToolInvocationResponse {
match serde_json::to_string_pretty(value).and_then(|output| {
let payload = serde_json::to_value(value)?;
Ok((output, payload))
}) {
Ok((output, payload)) => ToolInvocationResponse {
output,
is_error: false,
content: Vec::new(),
full_output: None,
result: Some(ToolInvocationResult::Artifact {
artifact: Box::new(ToolArtifact {
artifact_id: format!("{tool_call_id}-document-{artifact_suffix}"),
producer_plugin_id: DOCUMENT_PLUGIN_ID.to_string(),
schema: schema.to_string(),
schema_version: 1,
tool_call_id: Some(tool_call_id.to_string()),
title: Some(title.to_string()),
metadata: payload,
refs: Vec::new(),
}),
}),
},
Err(error) => tool_error(error.to_string()),
}
}
const fn tool_error(output: String) -> ToolInvocationResponse {
ToolInvocationResponse {
output,
is_error: true,
content: Vec::new(),
full_output: None,
result: None,
}
}
#[cfg(feature = "static-bundled")]
#[must_use]
pub fn static_plugin() -> bcode_plugin_sdk::StaticPluginVtable {
bcode_plugin_sdk::static_plugin_vtable!(DocumentPlugin, include_str!("../bcode-plugin.toml"))
}
#[cfg(feature = "static-bundled")]
#[must_use]
pub fn document_tui_registry() -> bcode_plugin_sdk::tui::PluginTuiRegistry {
let mut registry = bcode_plugin_sdk::tui::PluginTuiRegistry::default();
registry.register_visual_adapter(
[
"document-request-card",
"document-extract-result-card",
"document-status-card",
],
Box::new(document_tui::DocumentTuiVisualAdapter),
);
registry
}
#[cfg(not(feature = "static-bundled"))]
bcode_plugin_sdk::export_plugin!(DocumentPlugin, include_str!("../bcode-plugin.toml"));
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deactivation_releases_runtime_work() {
let mut plugin = DocumentPlugin::default();
let task = plugin
.runtime
.as_ref()
.expect("runtime")
.spawn(std::future::pending::<()>());
plugin.deactivate().expect("shutdown");
assert!(task.is_finished());
assert!(plugin.runtime.as_ref().unwrap().block_on(async {}).is_err());
plugin.deactivate().expect("idempotent shutdown");
}
#[test]
fn document_tools_emit_request_contributions() {
for definition in [extract_tool_definition(), status_tool_definition()] {
let encoded = serde_json::to_value(definition).expect("tool definition encodes");
assert!(encoded.get("ui").is_none());
}
}
fn workspace_context(path: &Path) -> Vec<bcode_tool::ToolHostContextEntry> {
vec![bcode_tool::ToolHostContextEntry {
schema: bcode_tool::TOOL_WORKSPACE_CONTEXT_SCHEMA.to_owned(),
schema_version: bcode_tool::TOOL_WORKSPACE_CONTEXT_SCHEMA_VERSION,
payload: serde_json::json!({"working_directory": path}),
}]
}
#[test]
fn document_owner_prepares_mixed_permission_policy() {
for (definition, arguments, expected_permission) in [
(status_tool_definition(), serde_json::Value::Null, false),
(
extract_tool_definition(),
serde_json::json!({"path": "report.pdf"}),
true,
),
] {
let request = bcode_tool::ToolPreparationRequest {
invocation: bcode_tool::ToolInvocationDescriptor {
invocation_id: "call".to_owned(),
tool_name: definition.name.clone(),
arguments,
},
host_context: workspace_context(Path::new("/tmp/workspace")),
};
let policy = document_policy_operation(&request, &definition).expect("document policy");
assert_eq!(policy.requires_permission, expected_permission);
let descriptor =
serde_json::from_value::<DocumentPreparationDescriptor>(policy.descriptor)
.expect("Document descriptor");
assert_eq!(
descriptor.workspace_root,
Some(PathBuf::from("/tmp/workspace"))
);
if expected_permission {
assert_eq!(
descriptor.source_path,
Some(PathBuf::from("/tmp/workspace/report.pdf"))
);
assert_eq!(
policy.operation,
bcode_plugin_sdk::ToolPolicyOperation::Read {
paths: vec!["/tmp/workspace/report.pdf".to_owned()],
}
);
}
}
}
#[test]
fn document_relative_source_requires_workspace_context() {
let definition = extract_tool_definition();
let request = bcode_tool::ToolPreparationRequest {
invocation: bcode_tool::ToolInvocationDescriptor {
invocation_id: "call".to_owned(),
tool_name: definition.name.clone(),
arguments: serde_json::json!({"path": "report.pdf"}),
},
host_context: Vec::new(),
};
let error = document_policy_operation(&request, &definition)
.expect_err("relative source without workspace");
assert!(error.contains("requires workspace host context"));
}
#[test]
fn document_invocation_uses_prepared_local_source_path() {
let mut request = ExtractRequest {
url: None,
path: Some(PathBuf::from("report.pdf")),
max_bytes: None,
timeout_ms: None,
};
let descriptor = DocumentPreparationDescriptor {
workspace_root: Some(PathBuf::from("/tmp/workspace")),
source_path: Some(PathBuf::from("/tmp/workspace/report.pdf")),
};
apply_document_preparation(&mut request, &descriptor).expect("apply preparation");
assert_eq!(
request.path,
Some(PathBuf::from("/tmp/workspace/report.pdf"))
);
}
#[test]
fn document_invocation_rejects_missing_local_source_descriptor() {
let mut request = ExtractRequest {
url: None,
path: Some(PathBuf::from("report.pdf")),
max_bytes: None,
timeout_ms: None,
};
let error =
apply_document_preparation(&mut request, &DocumentPreparationDescriptor::default())
.expect_err("missing prepared source");
assert!(error.contains("missing the local source path"));
}
#[test]
fn progress_uses_neutral_invocation_lifecycle_contract() {
let event = progress_lifecycle_event("document-call", 5, "extracting".to_owned());
let encoded = serde_json::to_vec(&event).expect("lifecycle should encode");
let decoded: ToolInvocationLifecycleEvent =
serde_json::from_slice(&encoded).expect("lifecycle should decode");
assert_eq!(decoded.invocation_id, "document-call");
assert_eq!(decoded.sequence, 5);
assert_eq!(decoded.stage, ToolInvocationLifecycleStage::Progress);
assert_eq!(decoded.message.as_deref(), Some("extracting"));
}
#[test]
fn source_requires_exactly_one_input() {
assert!(
source(&ExtractRequest {
url: None,
path: None,
max_bytes: None,
timeout_ms: None
})
.is_err()
);
assert!(
source(&ExtractRequest {
url: Some("https://example.com/a.pdf".to_string()),
path: Some(PathBuf::from("a.pdf")),
max_bytes: None,
timeout_ms: None,
})
.is_err()
);
}
#[test]
fn empty_unused_source_survives_preparation_and_invocation() {
for (arguments, expected_operation, expected_source) in [
(
json!({"path": "report.pdf", "url": ""}),
bcode_plugin_sdk::ToolPolicyOperation::Read {
paths: vec!["/tmp/workspace/report.pdf".to_owned()],
},
"/tmp/workspace/report.pdf",
),
(
json!({"path": "", "url": "https://example.com/report.pdf"}),
bcode_plugin_sdk::ToolPolicyOperation::Web {
url: Some("https://example.com/report.pdf".to_owned()),
},
"https://example.com/report.pdf",
),
] {
let definition = extract_tool_definition();
let request = bcode_tool::ToolPreparationRequest {
invocation: bcode_tool::ToolInvocationDescriptor {
invocation_id: "call".to_owned(),
tool_name: definition.name.clone(),
arguments: arguments.clone(),
},
host_context: workspace_context(Path::new("/tmp/workspace")),
};
let policy = document_policy_operation(&request, &definition).expect("document policy");
assert!(policy.requires_permission);
assert_eq!(policy.operation, expected_operation);
let descriptor = serde_json::from_value(policy.descriptor).expect("descriptor");
let mut extract = serde_json::from_value(arguments).expect("extract request");
apply_document_preparation(&mut extract, &descriptor).expect("apply preparation");
assert_eq!(
source(&extract).expect("source").as_string(),
expected_source
);
}
}
#[test]
fn source_rejects_empty_inputs() {
for arguments in [
json!({"url": "", "path": ""}),
json!({"url": ""}),
json!({"path": ""}),
json!({"url": null, "path": ""}),
json!({"url": "", "path": null}),
] {
let request = serde_json::from_value(arguments).expect("extract request");
assert!(matches!(
source(&request),
Err(DocumentError::InvalidSource)
));
}
}
#[test]
fn source_accepts_http_pdf_url() {
let result = source(&ExtractRequest {
url: Some("https://example.com/a.pdf".to_string()),
path: None,
max_bytes: None,
timeout_ms: None,
})
.expect("url source");
assert_eq!(result.as_string(), "https://example.com/a.pdf");
}
#[test]
fn document_scratch_directory_is_removed_on_drop() {
let directory = document_scratch_directory().expect("document scratch directory");
let path = directory.path().to_path_buf();
std::fs::write(path.join("source.pdf"), b"fixture").expect("scratch fixture");
drop(directory);
assert!(!path.exists());
}
#[test]
fn extraction_payload_omits_scratch_and_host_artifact_paths() {
let response = ExtractResponse {
source: "https://example.com/report.pdf".to_owned(),
content_type: "application/pdf".to_owned(),
artifact_kind: "document_extraction".to_owned(),
text: "extracted".to_owned(),
truncated: false,
extractor: "native".to_owned(),
fallback_used: None,
};
let payload = serde_json::to_value(response).expect("extract response");
assert_eq!(payload["source"], "https://example.com/report.pdf");
for removed in ["artifact_scope", "document_path", "text_path"] {
assert!(
!payload
.as_object()
.expect("response object")
.contains_key(removed)
);
}
}
#[test]
fn stable_names_are_path_safe() {
assert_eq!(
stable_name("https://example.com/a file.pdf"),
"https___example.com_a_file.pdf"
);
}
#[test]
fn native_extractor_is_available_by_default() {
let status = status_response();
assert!(status.extract.available);
assert!(
status
.extract
.extractors
.iter()
.any(|extractor| extractor.name == "native" && extractor.available)
);
}
}