#![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 peak_memory;
mod style;
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
#[cfg(any(
feature = "embeddings",
feature = "layout-detection",
feature = "paddle-ocr",
feature = "tree-sitter",
feature = "ner-onnx"
))]
use commands::cache::WarmOptions;
#[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, compiled_in_formats, doctor_command, extract_command, load_config,
manifest_command, stats_command, validate_file_exists, validate_output_dir,
};
#[cfg(feature = "tree-sitter")]
use commands::{cache_dir_command, clean_command, download_command, list_command};
#[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 tracing_subscriber::util::SubscriberInitExt as _;
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 = "tree-sitter")]
TreeSitter {
#[command(subcommand)]
command: TreeSitterCommands,
},
Doctor {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long, conflicts_with = "config")]
no_config_discovery: bool,
#[arg(short, long, default_value = "text")]
format: WireFormat,
#[arg(long)]
clean: bool,
},
#[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,
},
}
#[cfg(feature = "tree-sitter")]
#[derive(Subcommand)]
enum TreeSitterCommands {
Download {
languages: Vec<String>,
#[arg(long)]
all: bool,
#[arg(long, value_name = "GROUPS", value_delimiter = ',')]
groups: Option<Vec<String>>,
#[arg(long)]
cache_dir: Option<PathBuf>,
#[arg(long)]
from_config: bool,
#[arg(short, long, default_value = "text")]
format: WireFormat,
},
List {
#[arg(long)]
downloaded: bool,
#[arg(long)]
filter: Option<String>,
#[arg(short, long, default_value = "text")]
format: WireFormat,
},
CacheDir {
#[arg(short, long, default_value = "text")]
format: WireFormat,
},
Clean {
#[arg(short, long, default_value = "text")]
format: WireFormat,
},
}
#[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,
DocTags,
}
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,
ContentOutputFormatArg::DocTags => ContentOutputFormat::DocTags,
}
}
}
#[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 subscriber = tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(std::io::stderr)
.finish();
#[cfg(feature = "pdf-surface")]
let subscriber = {
use tracing_subscriber::layer::SubscriberExt as _;
subscriber.with(xberg::pdf::render::glyph_drop_capture_layer())
};
let _ = subscriber.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 = compiled_in_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()?;
let http_options = commands::server::McpTransportOptions {
host,
port,
allowed_hosts,
};
mcp_command(config, transport, http_options)?;
}
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,
} => {
let options = WarmOptions {
#[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, options)?;
}
},
#[cfg(feature = "tree-sitter")]
Commands::TreeSitter { command } => match command {
TreeSitterCommands::Download {
languages,
all,
groups,
cache_dir,
from_config,
format,
} => {
download_command(languages, all, groups, cache_dir, from_config, format)?;
}
TreeSitterCommands::List {
downloaded,
filter,
format,
} => {
list_command(downloaded, filter, format)?;
}
TreeSitterCommands::CacheDir { format } => {
cache_dir_command(format)?;
}
TreeSitterCommands::Clean { format } => {
clean_command(format)?;
}
},
Commands::Doctor {
config,
no_config_discovery,
format,
clean,
} => {
doctor_command(config, no_config_discovery, format, clean)?;
}
#[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
};
let options = commands::embed::EmbedProviderOptions {
preset,
provider,
llm_model: model,
llm_api_key: api_key,
plugin_name: plugin,
};
embed_command(texts, options, 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"));
}
#[test]
fn doctor_command_is_always_exposed() {
let args = command_arg_ids("doctor");
for required in ["config", "no_config_discovery", "format", "clean"] {
assert!(args.iter().any(|id| id == required), "missing doctor arg {required}");
}
}
#[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(feature = "ocr-surface")]
#[test]
fn should_hard_disable_ocr_when_extract_parses_ocr_false() {
let cli = Cli::try_parse_from([
"xberg",
"extract",
"scan.pdf",
"--ocr",
"false",
"--no-config-discovery",
])
.expect("clap should parse --ocr false");
let Commands::Extract {
config: config_path,
no_config_discovery,
overrides,
..
} = cli.command
else {
panic!("expected Commands::Extract");
};
let mut config = load_config(config_path, !no_config_discovery)
.expect("built-in extraction config should load without discovery");
overrides.apply(&mut config);
assert!(
config.disable_ocr,
"--ocr false should hard-disable implicit OCR fallback"
);
assert!(config.ocr.is_none(), "--ocr false should clear OCR backend settings");
}
#[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());
}
}
#[cfg(all(test, feature = "tree-sitter"))]
mod tree_sitter_cli_tests {
use super::*;
#[test]
fn cli_command_graph_is_structurally_valid() {
Cli::command().debug_assert();
}
#[test]
fn tree_sitter_subcommand_and_its_children_are_registered() {
let command = Cli::command();
let tree_sitter = command
.find_subcommand("tree-sitter")
.expect("tree-sitter subcommand should be registered");
for child in ["download", "list", "cache-dir", "clean"] {
assert!(
tree_sitter.find_subcommand(child).is_some(),
"tree-sitter subcommand missing child: {child}"
);
}
}
#[test]
fn should_parse_download_from_config_flag_with_explicit_cache_dir_override() {
let cli = Cli::try_parse_from([
"xberg",
"tree-sitter",
"download",
"--from-config",
"--cache-dir",
"/tmp/xberg-grammars",
])
.expect("clap should parse tree-sitter download --from-config --cache-dir");
let Commands::TreeSitter { command } = cli.command else {
panic!("expected Commands::TreeSitter");
};
let TreeSitterCommands::Download {
languages,
all,
groups,
cache_dir,
from_config,
format,
} = command
else {
panic!("expected TreeSitterCommands::Download");
};
assert!(from_config);
assert_eq!(cache_dir, Some(PathBuf::from("/tmp/xberg-grammars")));
assert!(languages.is_empty());
assert!(!all);
assert_eq!(groups, None);
assert_eq!(format, WireFormat::Text);
}
#[test]
fn should_parse_download_languages_and_groups_without_from_config() {
let cli = Cli::try_parse_from([
"xberg",
"tree-sitter",
"download",
"go",
"zig",
"--groups",
"web,systems",
])
.expect("clap should parse tree-sitter download with languages and --groups");
let Commands::TreeSitter { command } = cli.command else {
panic!("expected Commands::TreeSitter");
};
let TreeSitterCommands::Download {
languages,
groups,
from_config,
..
} = command
else {
panic!("expected TreeSitterCommands::Download");
};
assert_eq!(languages, vec!["go".to_string(), "zig".to_string()]);
assert_eq!(groups, Some(vec!["web".to_string(), "systems".to_string()]));
assert!(!from_config);
}
#[test]
fn should_parse_list_subcommand_with_downloaded_and_filter() {
let cli = Cli::try_parse_from([
"xberg",
"tree-sitter",
"list",
"--downloaded",
"--filter",
"py",
"--format",
"json",
])
.expect("clap should parse tree-sitter list --downloaded --filter py --format json");
let Commands::TreeSitter { command } = cli.command else {
panic!("expected Commands::TreeSitter");
};
let TreeSitterCommands::List {
downloaded,
filter,
format,
} = command
else {
panic!("expected TreeSitterCommands::List");
};
assert!(downloaded);
assert_eq!(filter, Some("py".to_string()));
assert_eq!(format, WireFormat::Json);
}
#[test]
fn should_parse_cache_dir_subcommand() {
let cli = Cli::try_parse_from(["xberg", "tree-sitter", "cache-dir"])
.expect("clap should parse tree-sitter cache-dir");
let Commands::TreeSitter { command } = cli.command else {
panic!("expected Commands::TreeSitter");
};
assert!(matches!(
command,
TreeSitterCommands::CacheDir {
format: WireFormat::Text
}
));
}
#[test]
fn should_parse_clean_subcommand_with_format_override() {
let cli = Cli::try_parse_from(["xberg", "tree-sitter", "clean", "--format", "toon"])
.expect("clap should parse tree-sitter clean --format toon");
let Commands::TreeSitter { command } = cli.command else {
panic!("expected Commands::TreeSitter");
};
assert!(matches!(
command,
TreeSitterCommands::Clean {
format: WireFormat::Toon
}
));
}
}