#![deny(clippy::print_stderr)]
#![deny(clippy::print_stdout)]
#![cfg_attr(test, allow(clippy::print_stderr, clippy::print_stdout))]
#![deny(unsafe_code)]
mod commands;
mod input;
mod logging;
mod output;
mod style;
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
#[cfg(feature = "embeddings")]
use commands::embed_command;
use commands::overrides::ExtractionOverrides;
#[cfg(feature = "api")]
use commands::serve_command;
#[cfg(any(
feature = "embeddings",
feature = "layout-detection",
feature = "paddle-ocr",
feature = "tree-sitter",
feature = "ner-onnx"
))]
use commands::warm_command;
use commands::{
BatchInputFormat, batch_command, clear_command, extract_command, load_config, manifest_command, stats_command,
validate_file_exists, validate_output_dir,
};
#[cfg(feature = "core-cli")]
use commands::{chunk_command, validate_chunk_params};
#[cfg(feature = "mcp")]
use commands::{mcp_command, resolve_mcp_allowed_hosts};
use input::{
apply_json_overrides, resolve_batch_inputs, resolve_extract_input, validate_batch_input_uris,
validate_extract_input,
};
use serde_json::json;
use std::path::PathBuf;
use std::time::Instant;
use xberg::{OutputFormat as ContentOutputFormat, detect_mime_type};
#[derive(Parser)]
#[command(name = "xberg")]
#[command(version, about, long_about = None)]
struct Cli {
#[arg(long, global = true)]
log_level: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Extract {
#[cfg_attr(
feature = "url-surface",
arg(value_name = "URI", required_unless_present_any = ["url", "stdin"])
)]
#[cfg_attr(
not(feature = "url-surface"),
arg(value_name = "URI", required_unless_present = "stdin")
)]
uri: Option<String>,
#[cfg(feature = "url-surface")]
#[arg(long, conflicts_with_all = ["uri", "stdin"])]
url: Option<String>,
#[cfg_attr(
feature = "url-surface",
arg(long, conflicts_with_all = ["uri", "url"])
)]
#[cfg_attr(not(feature = "url-surface"), arg(long, conflicts_with = "uri"))]
stdin: bool,
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long, conflicts_with = "config")]
no_config_discovery: bool,
#[arg(long)]
config_json: Option<String>,
#[arg(long)]
config_json_base64: Option<String>,
#[arg(short, long)]
mime_type: Option<String>,
#[arg(short, long, default_value = "text")]
format: WireFormat,
#[arg(long)]
output_dir: Option<PathBuf>,
#[command(flatten)]
overrides: ExtractionOverrides,
},
Batch {
paths: Vec<PathBuf>,
#[arg(long)]
input: Option<PathBuf>,
#[arg(long, value_enum)]
input_format: Option<BatchInputFormat>,
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long, conflicts_with = "config")]
no_config_discovery: bool,
#[arg(long)]
config_json: Option<String>,
#[arg(long)]
config_json_base64: Option<String>,
#[arg(short, long, default_value = "json")]
format: WireFormat,
#[arg(long)]
output_dir: Option<PathBuf>,
#[command(flatten)]
overrides: ExtractionOverrides,
#[arg(long)]
file_configs: Option<PathBuf>,
},
Detect {
path: PathBuf,
#[arg(short, long, default_value = "text")]
format: WireFormat,
},
Formats {
#[arg(short, long, default_value = "text")]
format: WireFormat,
},
Version {
#[arg(short, long, default_value = "text")]
format: WireFormat,
},
Cache {
#[command(subcommand)]
command: CacheCommands,
},
#[cfg(feature = "api")]
Serve {
#[arg(short = 'H', long)]
host: Option<String>,
#[arg(short, long)]
port: Option<u16>,
#[arg(short, long)]
config: Option<PathBuf>,
},
#[cfg(feature = "mcp")]
Mcp {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long, default_value = "stdio")]
transport: String,
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long, default_value = "8001")]
port: u16,
#[arg(long = "allowed-host")]
allowed_host: Vec<String>,
},
#[cfg(feature = "api")]
Api {
#[command(subcommand)]
command: ApiCommands,
},
#[cfg(feature = "embeddings")]
Embed {
#[arg(long)]
text: Vec<String>,
#[arg(long, default_value = "balanced")]
preset: String,
#[arg(long, default_value = "local")]
provider: String,
#[arg(long)]
model: Option<String>,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
plugin: Option<String>,
#[arg(short, long, default_value = "json")]
format: WireFormat,
},
#[cfg(feature = "core-cli")]
Chunk {
#[arg(long)]
text: Option<String>,
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
chunk_size: Option<usize>,
#[arg(long)]
chunk_overlap: Option<usize>,
#[arg(long, default_value = "text")]
chunker_type: String,
#[arg(long)]
chunking_tokenizer: Option<String>,
#[arg(long)]
topic_threshold: Option<f32>,
#[arg(short, long, default_value = "json")]
format: WireFormat,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
#[cfg(feature = "api")]
#[derive(Subcommand)]
enum ApiCommands {
Schema,
}
#[derive(Subcommand)]
enum CacheCommands {
Stats {
#[arg(short, long)]
cache_dir: Option<PathBuf>,
#[arg(short, long, default_value = "text")]
format: WireFormat,
},
Clear {
#[arg(short, long)]
cache_dir: Option<PathBuf>,
#[arg(short, long, default_value = "text")]
format: WireFormat,
},
Manifest {
#[arg(short, long, default_value = "json")]
format: WireFormat,
},
#[cfg(any(
feature = "embeddings",
feature = "layout-detection",
feature = "paddle-ocr",
feature = "tree-sitter",
feature = "ner-onnx"
))]
Warm {
#[arg(short, long)]
cache_dir: Option<PathBuf>,
#[arg(short, long, default_value = "text")]
format: WireFormat,
#[cfg(feature = "embeddings")]
#[arg(long)]
all_embeddings: bool,
#[cfg(feature = "embeddings")]
#[arg(long, value_name = "PRESET")]
embedding_model: Option<String>,
#[cfg(feature = "layout-detection")]
#[arg(
long,
help = "Download all table structure models including SLANeXT variants (~730MB)"
)]
all_table_models: bool,
#[cfg(feature = "tree-sitter")]
#[arg(long)]
all_grammars: bool,
#[cfg(feature = "tree-sitter")]
#[arg(long, value_name = "GROUPS", value_delimiter = ',')]
grammar_groups: Option<Vec<String>>,
#[cfg(feature = "tree-sitter")]
#[arg(long, value_name = "LANGUAGES", value_delimiter = ',')]
grammars: Option<Vec<String>>,
#[cfg(feature = "ner-onnx")]
#[arg(long)]
ner: bool,
#[cfg(feature = "ner-onnx")]
#[arg(long, value_name = "MODEL")]
ner_model: Option<String>,
#[cfg(feature = "ner-onnx")]
#[arg(long)]
all_ner_models: bool,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum WireFormat {
Text,
Json,
Toon,
}
impl std::str::FromStr for WireFormat {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"text" => Ok(WireFormat::Text),
"json" => Ok(WireFormat::Json),
"toon" => Ok(WireFormat::Toon),
_ => Err(format!("Invalid format: {}. Use 'text', 'json', or 'toon'", s)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
enum ContentOutputFormatArg {
Plain,
Markdown,
Djot,
Html,
Json,
}
impl From<ContentOutputFormatArg> for ContentOutputFormat {
fn from(arg: ContentOutputFormatArg) -> Self {
match arg {
ContentOutputFormatArg::Plain => ContentOutputFormat::Plain,
ContentOutputFormatArg::Markdown => ContentOutputFormat::Markdown,
ContentOutputFormatArg::Djot => ContentOutputFormat::Djot,
ContentOutputFormatArg::Html => ContentOutputFormat::Html,
ContentOutputFormatArg::Json => ContentOutputFormat::Json,
}
}
}
#[expect(
clippy::print_stdout,
reason = "detect/formats/version/api-schema results are the CLI's stdout output contract"
)]
fn main() -> Result<()> {
let process_start = commands::extract::stage_timing_requested().then(Instant::now);
let cli = Cli::parse();
let env_filter = logging::build_env_filter(cli.log_level.as_deref());
let _ = tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(std::io::stderr)
.try_init();
match cli.command {
Commands::Extract {
uri,
#[cfg(feature = "url-surface")]
url,
stdin,
config: config_path,
no_config_discovery,
config_json,
config_json_base64,
mime_type,
format,
output_dir,
overrides,
} => {
let input = resolve_extract_input(
uri,
#[cfg(feature = "url-surface")]
url,
stdin,
)?;
validate_extract_input(&input)?;
if let Some(ref dir) = output_dir {
validate_output_dir(dir)?;
}
overrides.validate()?;
let mut config = load_config(config_path, !no_config_discovery)?;
apply_json_overrides(&mut config, config_json, config_json_base64)?;
overrides.apply(&mut config);
extract_command(input, config, mime_type, format, output_dir, process_start)?;
}
Commands::Batch {
paths,
input,
input_format,
config: config_path,
no_config_discovery,
config_json,
config_json_base64,
format,
output_dir,
overrides,
file_configs,
} => {
let input_uris = resolve_batch_inputs(paths, input, input_format)?;
validate_batch_input_uris(&input_uris)?;
if let Some(ref dir) = output_dir {
validate_output_dir(dir)?;
}
overrides.validate()?;
let mut config = load_config(config_path, !no_config_discovery)?;
apply_json_overrides(&mut config, config_json, config_json_base64)?;
overrides.apply(&mut config);
let file_configs_map = if let Some(file_configs_path) = file_configs {
let file_configs_json = std::fs::read_to_string(&file_configs_path)
.with_context(|| format!("Failed to read file configs from '{}'", file_configs_path.display()))?;
let map: std::collections::HashMap<String, serde_json::Value> =
serde_json::from_str(&file_configs_json).with_context(|| {
format!(
"Failed to parse file configs JSON from '{}'",
file_configs_path.display()
)
})?;
Some(map)
} else {
None
};
batch_command(input_uris, file_configs_map, config, format, output_dir)?;
}
Commands::Detect { path, format } => {
validate_file_exists(&path)?;
let path_str = path.to_string_lossy().to_string();
let mime_type = detect_mime_type(path_str.clone(), true).with_context(|| {
format!(
"Failed to detect MIME type for file '{}'. Ensure the file is readable.",
path.display()
)
})?;
match format {
WireFormat::Text => {
println!("{}", style::success(&mime_type));
}
WireFormat::Json => {
let output = json!({
"path": path_str,
"mime_type": mime_type,
});
println!(
"{}",
serde_json::to_string_pretty(&output)
.context("Failed to serialize MIME type detection result to JSON")?
);
}
WireFormat::Toon => {
let output = json!({
"path": path_str,
"mime_type": mime_type,
});
println!(
"{}",
serde_toon::to_string(&output)
.context("Failed to serialize MIME type detection result to TOON")?
);
}
}
}
Commands::Formats { format } => {
let formats = xberg::core::mime::list_supported_formats();
match format {
WireFormat::Text => {
println!("{:<15} {}", style::label("EXTENSION"), style::label("MIME TYPE"));
println!("{}", style::dim(&format!("{:<15} ---------", "---------")));
for f in &formats {
println!("{:<15} {}", style::success(&format!(".{}", f.extension)), f.mime_type);
}
}
WireFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&formats).context("Failed to serialize formats to JSON")?
);
}
WireFormat::Toon => {
println!(
"{}",
serde_toon::to_string(&formats).context("Failed to serialize formats to TOON")?
);
}
}
}
Commands::Version { format } => {
let version = env!("CARGO_PKG_VERSION");
let name = env!("CARGO_PKG_NAME");
match format {
WireFormat::Text => {
println!("{} {}", style::label(name), style::success(version));
}
WireFormat::Json => {
let output = json!({
"name": name,
"version": version,
});
println!(
"{}",
serde_json::to_string_pretty(&output)
.context("Failed to serialize version information to JSON")?
);
}
WireFormat::Toon => {
let output = json!({
"name": name,
"version": version,
});
println!(
"{}",
serde_toon::to_string(&output).context("Failed to serialize version information to TOON")?
);
}
}
}
#[cfg(feature = "api")]
Commands::Serve {
host: cli_host,
port: cli_port,
config: config_path,
} => {
let mut extraction_config = load_config(config_path.clone(), true)?;
extraction_config.apply_env_overrides()?;
serve_command(cli_host, cli_port, extraction_config, config_path)?;
}
#[cfg(feature = "mcp")]
Commands::Mcp {
config: config_path,
transport,
#[cfg(feature = "mcp-http")]
host,
#[cfg(feature = "mcp-http")]
port,
#[cfg(not(feature = "mcp-http"))]
host,
#[cfg(not(feature = "mcp-http"))]
port,
allowed_host,
} => {
let allowed_hosts = resolve_mcp_allowed_hosts(&allowed_host, config_path.as_deref())?;
let mut config = load_config(config_path, true)?;
config.apply_env_overrides()?;
mcp_command(config, transport, host, port, allowed_hosts)?;
}
Commands::Cache { command } => match command {
CacheCommands::Stats { cache_dir, format } => {
stats_command(cache_dir, format)?;
}
CacheCommands::Clear { cache_dir, format } => {
clear_command(cache_dir, format)?;
}
CacheCommands::Manifest { format } => {
manifest_command(format)?;
}
#[cfg(any(
feature = "embeddings",
feature = "layout-detection",
feature = "paddle-ocr",
feature = "tree-sitter",
feature = "ner-onnx"
))]
CacheCommands::Warm {
cache_dir,
format,
#[cfg(feature = "embeddings")]
all_embeddings,
#[cfg(feature = "embeddings")]
embedding_model,
#[cfg(feature = "layout-detection")]
all_table_models,
#[cfg(feature = "tree-sitter")]
all_grammars,
#[cfg(feature = "tree-sitter")]
grammar_groups,
#[cfg(feature = "tree-sitter")]
grammars,
#[cfg(feature = "ner-onnx")]
ner,
#[cfg(feature = "ner-onnx")]
ner_model,
#[cfg(feature = "ner-onnx")]
all_ner_models,
} => {
warm_command(
cache_dir.clone(),
format,
#[cfg(feature = "embeddings")]
all_embeddings,
#[cfg(feature = "embeddings")]
embedding_model,
#[cfg(feature = "layout-detection")]
all_table_models,
#[cfg(feature = "tree-sitter")]
all_grammars,
#[cfg(feature = "tree-sitter")]
grammar_groups,
#[cfg(feature = "tree-sitter")]
grammars,
#[cfg(feature = "ner-onnx")]
ner,
#[cfg(feature = "ner-onnx")]
ner_model,
#[cfg(feature = "ner-onnx")]
all_ner_models,
)?;
}
},
#[cfg(feature = "api")]
Commands::Api { command } => match command {
ApiCommands::Schema => {
println!("{}", xberg::api::openapi::openapi_json());
}
},
#[cfg(feature = "embeddings")]
Commands::Embed {
text,
preset,
provider,
model,
api_key,
plugin,
format,
} => {
let texts = if text.is_empty() {
vec![commands::read_stdin()?]
} else {
text
};
embed_command(texts, &preset, &provider, model, api_key, plugin, format)?;
}
#[cfg(feature = "core-cli")]
Commands::Chunk {
text,
config: config_path,
chunk_size,
chunk_overlap,
chunker_type,
chunking_tokenizer,
topic_threshold,
format,
} => {
let input = match text {
Some(t) => t,
None => commands::read_stdin().context("No --text provided and failed to read from stdin")?,
};
validate_chunk_params(chunk_size, chunk_overlap)?;
let base_config = load_config(config_path, true)?;
let mut chunking_config = base_config.chunking.unwrap_or_default();
if let Some(size) = chunk_size {
chunking_config.max_characters = size;
if chunk_overlap.is_none() && chunking_config.overlap >= size {
chunking_config.overlap = size / 4;
}
}
if let Some(overlap) = chunk_overlap {
chunking_config.overlap = overlap;
}
match chunker_type.as_str() {
"markdown" => chunking_config.chunker_type = xberg::ChunkerType::Markdown,
"yaml" => chunking_config.chunker_type = xberg::ChunkerType::Yaml,
"semantic" => chunking_config.chunker_type = xberg::ChunkerType::Semantic,
_ => chunking_config.chunker_type = xberg::ChunkerType::Text,
}
#[cfg(feature = "chunking-tokenizers")]
if let Some(ref tokenizer) = chunking_tokenizer {
chunking_config.sizing = xberg::ChunkSizing::Tokenizer {
model: tokenizer.clone(),
cache_dir: None,
};
}
#[cfg(not(feature = "chunking-tokenizers"))]
if chunking_tokenizer.is_some() {
anyhow::bail!("--chunking-tokenizer requires the chunking-tokenizers feature");
}
if topic_threshold.is_some() {
chunking_config.topic_threshold = topic_threshold;
}
chunk_command(input, chunking_config, format)?;
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "xberg", &mut std::io::stdout());
}
}
Ok(())
}
#[cfg(test)]
mod feature_profile_tests {
use super::*;
fn command_arg_ids(command: &str) -> Vec<String> {
Cli::command()
.find_subcommand(command)
.expect("command should exist")
.get_arguments()
.map(|arg| arg.get_id().as_str().to_owned())
.collect()
}
#[cfg(not(feature = "core-cli"))]
#[test]
fn lean_profile_omits_chunk_command() {
assert!(Cli::command().find_subcommand("chunk").is_none());
}
#[cfg(feature = "core-cli")]
#[test]
fn core_cli_exposes_chunk_command() {
assert!(Cli::command().find_subcommand("chunk").is_some());
}
#[cfg(not(feature = "url-surface"))]
#[test]
fn lean_profile_omits_url_flag() {
assert!(!command_arg_ids("extract").iter().any(|id| id == "url"));
}
#[cfg(feature = "url-surface")]
#[test]
fn url_ingestion_exposes_url_flag() {
assert!(command_arg_ids("extract").iter().any(|id| id == "url"));
}
#[cfg(not(feature = "ocr-surface"))]
#[test]
fn lean_profile_omits_ocr_overrides() {
let args = command_arg_ids("extract");
for unavailable in ["ocr", "ocr_backend", "force_ocr"] {
assert!(!args.iter().any(|id| id == unavailable));
}
}
#[cfg(feature = "ocr-surface")]
#[test]
fn ocr_capable_profile_exposes_ocr_overrides() {
let args = command_arg_ids("extract");
for required in ["ocr", "ocr_backend", "force_ocr"] {
assert!(args.iter().any(|id| id == required));
}
}
#[cfg(not(feature = "pdf-surface"))]
#[test]
fn non_pdf_profile_omits_pdf_overrides() {
let args = command_arg_ids("extract");
for unavailable in [
"pdf_password",
"pdf_extract_images",
"pdf_extract_tables",
"pdf_ocr_inline_images",
"pdf_extract_metadata",
"pdf_backend",
] {
assert!(!args.iter().any(|id| id == unavailable));
}
}
#[cfg(feature = "pdf-surface")]
#[test]
fn pdf_profile_exposes_pdf_overrides() {
let args = command_arg_ids("extract");
for required in [
"pdf_password",
"pdf_extract_images",
"pdf_extract_tables",
"pdf_extract_metadata",
"pdf_backend",
] {
assert!(args.iter().any(|id| id == required));
}
assert_eq!(
args.iter().any(|id| id == "pdf_ocr_inline_images"),
cfg!(feature = "ocr-surface")
);
}
#[cfg(not(feature = "analysis"))]
#[test]
fn lean_profile_omits_analysis_overrides() {
let args = command_arg_ids("extract");
for unavailable in ["quality", "detect_language", "token_reduction"] {
assert!(!args.iter().any(|id| id == unavailable));
}
}
#[cfg(not(any(feature = "core-cli", feature = "analysis")))]
#[test]
fn lean_profile_omits_chunking_overrides() {
let args = command_arg_ids("extract");
for unavailable in ["chunk", "chunk_size", "chunk_overlap", "chunking_tokenizer"] {
assert!(!args.iter().any(|id| id == unavailable));
}
}
#[cfg(not(any(
feature = "embeddings",
feature = "layout-detection",
feature = "paddle-ocr",
feature = "tree-sitter",
feature = "ner-onnx"
)))]
#[test]
fn lean_profile_omits_warm_command() {
let command = Cli::command();
let cache = command.find_subcommand("cache").expect("cache command should exist");
assert!(cache.find_subcommand("warm").is_none());
}
#[cfg(any(
feature = "embeddings",
feature = "layout-detection",
feature = "paddle-ocr",
feature = "tree-sitter",
feature = "ner-onnx"
))]
#[test]
fn warm_capable_profile_exposes_warm_command() {
let command = Cli::command();
let cache = command.find_subcommand("cache").expect("cache command should exist");
assert!(cache.find_subcommand("warm").is_some());
}
}