{"repo": "byteowlz/ingestr", "files": {"ingestr-cli/src/main.rs": {"b580df1d8ac145689a4d7496bf05d90608329fb0": {"t": 1785745866.8722813, "n": 149561, "h": "2e1c1f19886fb603", "c": "use std::collections::HashMap;\nuse std::env;\nuse std::fmt;\nuse std::fs::{self, OpenOptions};\nuse std::io::{self, IsTerminal, Read, Write};\nuse std::path::{Path, PathBuf};\nuse std::process::{Command as ProcCommand, Stdio};\nuse std::sync::{\n Arc,\n atomic::{AtomicBool, AtomicUsize, Ordering},\n mpsc::{self, RecvTimeoutError},\n};\nuse std::time::{Duration, Instant, SystemTime};\n\nuse anyhow::{Context, Result, anyhow, bail};\nuse clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};\nuse clap_complete::Shell;\nuse config::{Config, Environment, File, FileFormat};\nuse env_logger::fmt::WriteStyle;\nuse ingestr_core::{IndexedDocument, SearchIndex};\nuse log::{LevelFilter, debug, error, info, warn};\nuse markitdown::{MarkItDown, model::ConversionOptions};\nuse notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};\nuse ocrs::{ImageSource, OcrEngine, OcrEngineParams};\nuse rayon::prelude::*;\nuse regex::Regex;\nuse serde::{Deserialize, Serialize};\nuse sha2::{Digest, Sha256};\nuse rten::Model as RtenModel;\nuse sysinfo::{Pid, Signal, System};\nuse time::OffsetDateTime;\nuse time::format_description::well_known::Rfc3339;\nuse walkdir::WalkDir;\n\nconst APP_NAME: &str = env!(\"CARGO_PKG_NAME\");\nconst CONFIG_DIR_NAME: &str = \"ingestr\";\n\nfn main() {\n if let Err(err) = try_main() {\n let _ = writeln!(io::stderr(), \"{err:?}\");\n std::process::exit(1);\n }\n}\n\n/// Known subcommand names (used for default-subcommand detection).\nconst KNOWN_SUBCOMMANDS: &[&str] = &[\n \"service\", \"search\", \"convert\", \"init\", \"config\", \"cache\", \"completions\", \"help\",\n];\n\nfn try_main() -> Result<()> {\n // Default subcommand: if the first positional arg is not a known subcommand,\n // treat it as `convert `. This lets users write `ingestr file.pdf`\n // instead of `ingestr convert file.pdf`.\n let args: Vec = env::args().collect();\n let cli = if args.len() > 1 {\n let first_arg = &args[1];\n // Skip if it's a flag (starts with -)\n let is_flag = first_arg.starts_with('-');\n let is_subcommand = KNOWN_SUBCOMMANDS.contains(&first_arg.as_str());\n if !is_flag && !is_subcommand {\n // Insert \"convert\" as the subcommand\n let mut new_args = vec![args[0].clone(), \"convert\".to_string()];\n new_args.extend_from_slice(&args[1..]);\n Cli::parse_from(new_args)\n } else {\n Cli::parse()\n }\n } else {\n Cli::parse()\n };\n\n let mut ctx = RuntimeContext::new(cli.common.clone())?;\n ctx.init_logging()?;\n debug!(\"resolved paths: {:#?}\", ctx.paths);\n\n match cli.command {\n Command::Service { command } => handle_service(&mut ctx, command),\n Command::Search(cmd) => handle_search(&ctx, cmd),\n Command::Convert(cmd) => handle_convert(&ctx, cmd),\n Command::Init(cmd) => handle_init(&ctx, cmd),\n Command::Config { command } => handle_config(&ctx, command),\n Command::Cache { command } => handle_cache(&ctx, command),\n Command::Completions { shell } => handle_completions(shell),\n }\n}\n\n#[derive(Debug, Parser)]\n#[command(\n author,\n version,\n about = \"Background service that converts documents to Markdown and indexes them for search.\",\n propagate_version = true\n)]\nstruct Cli {\n #[command(flatten)]\n common: CommonOpts,\n #[command(subcommand)]\n command: Command,\n}\n\n#[derive(Debug, Clone, Args)]\nstruct CommonOpts {\n /// Override the config file path\n #[arg(long, value_name = \"PATH\", global = true)]\n config: Option,\n /// Reduce output to only errors\n #[arg(short, long, action = clap::ArgAction::SetTrue, global = true)]\n quiet: bool,\n /// Increase logging verbosity (stackable)\n #[arg(short = 'v', long = \"verbose\", action = clap::ArgAction::Count, global = true)]\n verbose: u8,\n /// Enable debug logging (equivalent to -vv)\n #[arg(long, global = true)]\n debug: bool,\n /// Enable trace logging (overrides other levels)\n #[arg(long, global = true)]\n trace: bool,\n /// Output machine readable JSON\n #[arg(long, global = true, conflicts_with = \"yaml\")]\n json: bool,\n /// Output machine readable YAML\n #[arg(long, global = true)]\n yaml: bool,\n /// Disable ANSI colors in output\n #[arg(long = \"no-color\", global = true, conflicts_with = \"color\")]\n no_color: bool,\n /// Control color output (auto, always, never)\n #[arg(long, value_enum, default_value_t = ColorOption::Auto, global = true)]\n color: ColorOption,\n /// Do not change anything on disk\n #[arg(long = \"dry-run\", global = true)]\n dry_run: bool,\n /// Assume \"yes\" for interactive prompts\n #[arg(short = 'y', long = \"yes\", global = true)]\n assume_yes: bool,\n /// Maximum seconds to allow an operation to run\n #[arg(long = \"timeout\", value_name = \"SECONDS\", global = true)]\n timeout: Option,\n /// Override the degree of parallelism\n #[arg(long = \"parallel\", value_name = \"N\", global = true)]\n parallel: Option,\n /// Disable progress indicators\n #[arg(long = \"no-progress\", global = true)]\n no_progress: bool,\n /// Emit additional diagnostics for troubleshooting\n #[arg(long, global = true)]\n diagnostics: bool,\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum)]\nenum ColorOption {\n Auto,\n Always,\n Never,\n}\n\n#[derive(Debug, Subcommand)]\nenum Command {\n /// Manage the background conversion and indexing service\n Service {\n #[command(subcommand)]\n command: ServiceCommand,\n },\n /// Query the search index\n Search(SearchCommand),\n /// Convert a single file to Markdown\n Convert(ConvertCommand),\n /// Create config directories and default files\n Init(InitCommand),\n /// Inspect and manage configuration\n Config {\n #[command(subcommand)]\n command: ConfigCommand,\n },\n /// Manage the conversion cache\n Cache {\n #[command(subcommand)]\n command: CacheCommand,\n },\n /// Generate shell completions\n Completions {\n #[arg(value_enum)]\n shell: Shell,\n },\n}\n\n#[derive(Debug, Subcommand)]\nenum CacheCommand {\n /// Clear the conversion cache\n Clear,\n /// Show cache statistics\n Stats,\n}\n\n#[derive(Debug, Clone, Args)]\nstruct ServiceRunOpts {\n /// Directory to watch for new or updated documents\n #[arg(long, value_name = \"PATH\")]\n watch_dir: Option,\n /// Directory to write converted markdown files\n #[arg(long, value_name = \"PATH\")]\n output_dir: Option,\n /// Directory to store the search index\n #[arg(long, value_name = \"PATH\")]\n index_dir: Option,\n /// Disable indexing while still converting files\n #[arg(long)]\n disable_index: bool,\n /// Convert the current contents and exit instead of watching for changes\n #[arg(long)]\n once: bool,\n}\n\n#[derive(Debug, Subcommand)]\nenum ServiceCommand {\n /// Run the service in the foreground\n Run(ServiceRunOpts),\n /// Start the service in the background\n Start(ServiceRunOpts),\n /// Stop the background service\n Stop,\n /// Restart the background service\n Restart(ServiceRunOpts),\n /// Show service status\n Status,\n}\n\n#[derive(Debug, Clone, Args)]\nstruct SearchCommand {\n /// Search query\n query: String,\n /// Maximum results to return\n #[arg(long, default_value_t = 10)]\n limit: usize,\n /// Override the index directory\n #[arg(long, value_name = \"PATH\")]\n index_dir: Option,\n}\n\n#[derive(Debug, Clone, Args)]\nstruct ConvertCommand {\n /// File, directory, or URL to convert (use '-' or omit for stdin)\n #[arg(value_name = \"INPUT\")]\n input: Option,\n /// Batch convert all files with given extension in current directory (recursive)\n /// Example: --batch pptx converts all .pptx files to .md alongside sources\n #[arg(long, value_name = \"EXT\")]\n batch: Option,\n /// Write output to a file or directory instead of stdout. When converting\n /// a directory and no output is specified, writes .md files alongside sources.\n #[arg(short, long, value_name = \"PATH\")]\n output: Option,\n /// Input format hint when reading from stdin\n #[arg(long, value_name = \"FORMAT\", value_enum)]\n from: Option,\n /// Recursively process directories\n #[arg(short, long)]\n recursive: bool,\n /// File extensions to process (comma-separated, e.g., \"pdf,docx,html\")\n #[arg(long, value_name = \"EXTENSIONS\", value_delimiter = ',')]\n extensions: Option>,\n /// Write output files alongside source files (same directory) when processing\n /// directories. This is the default when no --output is specified.\n #[arg(long)]\n in_place: bool,\n /// Enable VLM processing for images\n #[arg(long)]\n vlm: bool,\n /// Vision model to use (e.g., \"glm-4.6v-flash\", \"qwen/qwen3-vl-8b\")\n #[arg(long, value_name = \"MODEL\")]\n vlm_model: Option,\n /// Custom VLM prompt for image description\n #[arg(long, value_name = \"PROMPT\")]\n vlm_prompt: Option,\n /// Number of parallel VLM requests (default: 1)\n #[arg(short = 'j', long = \"jobs\", value_name = \"N\", default_value = \"1\")]\n jobs: usize,\n /// Enable OCR processing for scanned documents\n #[arg(long)]\n ocr: bool,\n /// OCR backend to use\n #[arg(long, value_name = \"BACKEND\", value_enum, default_value = \"ocrs\")]\n ocr_backend: OcrBackend,\n /// OCR languages (comma-separated, e.g., \"eng,deu\")\n #[arg(long, value_name = \"LANGS\", value_delimiter = ',')]\n ocr_languages: Option>,\n /// Include YAML frontmatter with metadata in output\n #[arg(long)]\n meta: bool,\n /// Skip post-processing cleanup (output raw conversion result)\n #[arg(long)]\n raw: bool,\n /// Maximum tokens to output (approximate, truncates at section boundaries)\n #[arg(long, value_name = \"N\")]\n max_tokens: Option,\n /// Maximum characters to output (truncates at section boundaries)\n #[arg(long, value_name = \"N\")]\n max_chars: Option,\n /// Character offset to start from (for pagination with --max-tokens/--max-chars)\n #[arg(long, value_name = \"N\", default_value_t = 0)]\n offset: usize,\n /// Show document structure (table of contents with token estimates)\n #[arg(long)]\n toc: bool,\n /// Extract only a specific section by number or heading (e.g., \"2.1\" or \"Summary\")\n #[arg(short = 's', long, value_name = \"SECTION\")]\n section: Option,\n /// Convert only specific pages (PDF only, e.g., \"1-3,7\")\n #[arg(long, value_name = \"PAGES\")]\n pages: Option,\n /// Read input from the system clipboard\n #[arg(long)]\n clipboard: bool,\n /// Bypass the conversion cache\n #[arg(long)]\n no_cache: bool,\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum, Default)]\nenum InputFormat {\n #[default]\n Auto,\n Html,\n Text,\n Pdf,\n Docx,\n Xlsx,\n Pptx,\n Csv,\n Json,\n Xml,\n Markdown,\n}\n\nimpl InputFormat {\n fn extension(&self) -> Option<&'static str> {\n match self {\n InputFormat::Auto => None,\n InputFormat::Html => Some(\"html\"),\n InputFormat::Text => Some(\"txt\"),\n InputFormat::Pdf => Some(\"pdf\"),\n InputFormat::Docx => Some(\"docx\"),\n InputFormat::Xlsx => Some(\"xlsx\"),\n InputFormat::Pptx => Some(\"pptx\"),\n InputFormat::Csv => Some(\"csv\"),\n InputFormat::Json => Some(\"json\"),\n InputFormat::Xml => Some(\"xml\"),\n InputFormat::Markdown => Some(\"md\"),\n }\n }\n}\n\n#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Default, Serialize, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\nenum OcrBackend {\n Tesseract,\n #[default]\n Ocrs,\n Surya,\n Easyocr,\n}\n\nimpl std::fmt::Display for OcrBackend {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n OcrBackend::Tesseract => write!(f, \"tesseract\"),\n OcrBackend::Ocrs => write!(f, \"ocrs\"),\n OcrBackend::Surya => write!(f, \"surya\"),\n OcrBackend::Easyocr => write!(f, \"easyocr\"),\n }\n }\n}\n\n#[derive(Debug, Clone, Args)]\nstruct InitCommand {\n /// Recreate configuration even if it already exists\n #[arg(long = \"force\")]\n force: bool,\n}\n\n#[derive(Debug, Subcommand)]\nenum ConfigCommand {\n /// Output the effective configuration\n Show,\n /// Print the resolved config file path\n Path,\n /// Regenerate the default configuration file\n Reset,\n}\n\n#[derive(Debug, Clone)]\nstruct RuntimeContext {\n common: CommonOpts,\n paths: AppPaths,\n config: AppConfig,\n directories: ResolvedDirectories,\n}\n\n#[derive(Debug, Clone)]\nstruct ResolvedDirectories {\n watch_dir: PathBuf,\n output_dir: PathBuf,\n index_dir: PathBuf,\n}\n\nimpl RuntimeContext {\n fn new(common: CommonOpts) -> Result {\n let mut paths = AppPaths::discover(common.config.clone())?;\n let config = load_or_init_config(&mut paths, &common)?;\n let paths = paths.apply_overrides(&config)?;\n let directories = ResolvedDirectories::from_config(&config, &paths)?;\n let ctx = Self {\n common,\n paths,\n config,\n directories,\n };\n ctx.ensure_directories()?;\n Ok(ctx)\n }\n\n fn init_logging(&self) -> Result<()> {\n if self.common.quiet {\n log::set_max_level(LevelFilter::Off);\n return Ok(());\n }\n\n let mut builder =\n env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(\"info\"));\n\n builder.filter_level(self.effective_log_level());\n // lopdf can emit repetitive \"corrupt deflate stream\" warnings for encrypted/corrupt PDFs.\n // We handle these cases explicitly and return user-friendly errors, so hide this crate-level noise.\n builder.filter_module(\"lopdf\", LevelFilter::Error);\n\n let force_color = matches!(self.common.color, ColorOption::Always)\n || env::var_os(\"FORCE_COLOR\").is_some();\n let disable_color = self.common.no_color\n || matches!(self.common.color, ColorOption::Never)\n || env::var_os(\"NO_COLOR\").is_some()\n || (!force_color && !io::stderr().is_terminal());\n\n if disable_color {\n builder.write_style(WriteStyle::Never);\n } else if force_color {\n builder.write_style(WriteStyle::Always);\n } else {\n builder.write_style(WriteStyle::Auto);\n }\n\n if self.common.diagnostics {\n builder.format_timestamp_millis();\n builder.format_module_path(true);\n builder.format_target(true);\n }\n\n builder.try_init().or_else(|err| {\n if self.common.verbose > 0 {\n eprintln!(\"logger already initialized: {err}\");\n }\n Ok(())\n })\n }\n\n fn effective_log_level(&self) -> LevelFilter {\n if self.common.trace {\n LevelFilter::Trace\n } else if self.common.debug {\n LevelFilter::Debug\n } else {\n match self.common.verbose {\n 0 => LevelFilter::Info,\n 1 => LevelFilter::Debug,\n _ => LevelFilter::Trace,\n }\n }\n }\n\n fn ensure_directories(&self) -> Result<()> {\n if self.common.dry_run {\n info!(\n \"dry-run: would ensure data dir {}, state dir {}, output dir {}, and index dir {}\",\n self.paths.data_dir.display(),\n self.paths.state_dir.display(),\n self.directories.output_dir.display(),\n self.directories.index_dir.display()\n );\n return Ok(());\n }\n\n fs::create_dir_all(&self.paths.data_dir).with_context(|| {\n format!(\"creating data directory {}\", self.paths.data_dir.display())\n })?;\n fs::create_dir_all(&self.paths.state_dir).with_context(|| {\n format!(\n \"creating state directory {}\",\n self.paths.state_dir.display()\n )\n })?;\n fs::create_dir_all(&self.directories.output_dir).with_context(|| {\n format!(\n \"creating markdown output directory {}\",\n self.directories.output_dir.display()\n )\n })?;\n\n Ok(())\n }\n\n fn pid_path(&self) -> PathBuf {\n self.paths.state_dir.join(\"service.pid\")\n }\n\n fn service_settings(&self, serve: &ServiceRunOpts) -> Result {\n let watch_dir = if let Some(ref provided) = serve.watch_dir {\n expand_path(provided.clone())?\n } else {\n self.directories.watch_dir.clone()\n };\n\n let output_dir = if let Some(ref provided) = serve.output_dir {\n expand_path(provided.clone())?\n } else {\n self.directories.output_dir.clone()\n };\n\n let index_dir = if let Some(ref provided) = serve.index_dir {\n expand_path(provided.clone())?\n } else {\n self.directories.index_dir.clone()\n };\n\n let index_enabled = if serve.disable_index {\n false\n } else {\n self.config.index.enabled\n };\n\n Ok(ServiceSettings {\n watch_dir,\n output_dir,\n index_dir,\n index_enabled,\n debounce: Duration::from_millis(self.config.watcher.debounce_ms.max(50)),\n skip_hidden: self.config.watcher.skip_hidden,\n data_dir: self.paths.data_dir.clone(),\n state_dir: self.paths.state_dir.clone(),\n llm_enabled: self.config.llm.enabled,\n llm_client: self.config.llm.client.clone(),\n llm_model: self.config.llm.model.clone(),\n llm_base_url: self.config.llm.base_url.clone(),\n llm_api_key: self.config.llm.api_key.clone(),\n processors: self.config.processors.clone(),\n })\n }\n}\n\n#[derive(Debug, Clone)]\nstruct AppPaths {\n active_config: PathBuf,\n global_config: PathBuf,\n local_config: PathBuf,\n config_override: bool,\n data_dir: PathBuf,\n state_dir: PathBuf,\n}\n\nimpl AppPaths {\n fn discover(override_path: Option) -> Result {\n let global_config = default_config_dir()?.join(\"config.toml\");\n let local_config = env::current_dir()\n .unwrap_or_else(|_| PathBuf::from(\".\"))\n .join(\"config.toml\");\n\n let config_override = override_path.is_some();\n\n let active_config = match override_path {\n Some(ref path) => expand_path(path.clone())?,\n None => global_config.clone(),\n };\n\n if active_config.parent().is_none() {\n return Err(anyhow!(\"invalid config file path: {active_config:?}\"));\n }\n\n let data_dir = default_data_dir()?;\n let state_dir = default_state_dir()?;\n\n Ok(Self {\n active_config,\n global_config,\n local_config,\n config_override,\n data_dir,\n state_dir,\n })\n }\n\n fn apply_overrides(mut self, cfg: &AppConfig) -> Result {\n if let Some(ref data_override) = cfg.paths.data_dir {\n self.data_dir = expand_str_path(data_override)?;\n }\n if let Some(ref state_override) = cfg.paths.state_dir {\n self.state_dir = expand_str_path(state_override)?;\n }\n Ok(self)\n }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct AppConfig {\n profile: String,\n logging: LoggingConfig,\n runtime: RuntimeConfig,\n paths: PathsConfig,\n watcher: WatcherConfig,\n output: OutputConfig,\n index: IndexConfig,\n llm: LlmConfig,\n processors: ProcessorsConfig,\n}\n\nimpl AppConfig {\n fn with_profile_override(mut self, profile: Option) -> Self {\n if let Some(profile) = profile {\n self.profile = profile;\n }\n self\n }\n}\n\nimpl Default for AppConfig {\n fn default() -> Self {\n Self {\n profile: \"default\".to_string(),\n logging: LoggingConfig::default(),\n runtime: RuntimeConfig::default(),\n paths: PathsConfig::default(),\n watcher: WatcherConfig::default(),\n output: OutputConfig::default(),\n index: IndexConfig::default(),\n llm: LlmConfig::default(),\n processors: ProcessorsConfig::default(),\n }\n }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct LoggingConfig {\n level: String,\n file: Option,\n}\n\nimpl Default for LoggingConfig {\n fn default() -> Self {\n Self {\n level: \"info\".to_string(),\n file: None,\n }\n }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct RuntimeConfig {\n parallelism: Option,\n timeout: Option,\n fail_fast: bool,\n}\n\nimpl Default for RuntimeConfig {\n fn default() -> Self {\n Self {\n parallelism: None,\n timeout: Some(60),\n fail_fast: true,\n }\n }\n}\n\n#[derive(Debug, Default, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct PathsConfig {\n data_dir: Option,\n state_dir: Option,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct WatcherConfig {\n watch_dir: String,\n debounce_ms: u64,\n skip_hidden: bool,\n}\n\nimpl Default for WatcherConfig {\n fn default() -> Self {\n Self {\n watch_dir: default_watch_dir_string(),\n debounce_ms: 750,\n skip_hidden: true,\n }\n }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct OutputConfig {\n markdown_dir: String,\n}\n\nimpl Default for OutputConfig {\n fn default() -> Self {\n Self {\n markdown_dir: default_output_dir_string(),\n }\n }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct IndexConfig {\n enabled: bool,\n index_dir: Option,\n}\n\nimpl Default for IndexConfig {\n fn default() -> Self {\n Self {\n enabled: true,\n index_dir: None,\n }\n }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct LlmConfig {\n /// Enable LLM-based image description generation\n enabled: bool,\n /// LLM provider: \"openai\", \"gemini\", or \"deepseek\"\n client: String,\n /// Model name (e.g., \"gpt-4o\", \"gemini-1.5-flash\", \"deepseek-chat\")\n model: String,\n /// Custom API base URL (for OpenAI-compatible endpoints)\n base_url: Option,\n /// API key (optional, can also use environment variables)\n api_key: Option,\n}\n\nimpl Default for LlmConfig {\n fn default() -> Self {\n Self {\n enabled: false,\n client: \"openai\".to_string(),\n model: \"gpt-4o\".to_string(),\n base_url: None,\n api_key: None,\n }\n }\n}\n\n/// Processor pipeline configuration\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct ProcessorsConfig {\n /// Ordered list of processors to try\n pipeline: Vec,\n /// File type routing rules (extension -> processor list)\n routing: HashMap>,\n /// VLM processor configuration\n vlm: VlmConfig,\n /// OCR processor configuration\n ocr: OcrConfig,\n}\n\nimpl Default for ProcessorsConfig {\n fn default() -> Self {\n Self {\n pipeline: vec![\"markitdown\".to_string()],\n routing: HashMap::new(),\n vlm: VlmConfig::default(),\n ocr: OcrConfig::default(),\n }\n }\n}\n\n/// VLM (Vision Language Model) processor configuration.\n/// When `llm_url` or `model` are left empty/default, the main `[llm]` config\n/// is used as fallback so users only need to configure their LLM once.\n/// Works with any OpenAI-compatible vision API (Ollama, LM Studio, vLLM, etc.).\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct VlmConfig {\n /// Enable VLM processing\n enabled: bool,\n /// LLM server URL (falls back to [llm].base_url if empty)\n llm_url: Option,\n /// Model name (falls back to [llm].model if empty)\n model: Option,\n /// Custom prompts per file type\n prompts: HashMap,\n}\n\nimpl Default for VlmConfig {\n fn default() -> Self {\n let mut prompts = HashMap::new();\n prompts.insert(\n \"default\".to_string(),\n \"Describe this image in detail, including any text visible.\".to_string(),\n );\n prompts.insert(\n \"diagram\".to_string(),\n \"Describe this diagram, including its structure, labels, and relationships.\".to_string(),\n );\n prompts.insert(\n \"screenshot\".to_string(),\n \"Describe this screenshot, including the UI elements and any visible text.\".to_string(),\n );\n\n Self {\n enabled: false,\n llm_url: None,\n model: None,\n prompts,\n }\n }\n}\n\n/// OCR processor configuration\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(default)]\nstruct OcrConfig {\n /// Enable OCR processing\n enabled: bool,\n /// OCR backend (tesseract, ocrs, surya, easyocr)\n backend: OcrBackend,\n /// Languages for OCR\n languages: Vec,\n}\n\nimpl Default for OcrConfig {\n fn default() -> Self {\n Self {\n enabled: false,\n backend: OcrBackend::Ocrs,\n languages: vec![\"eng\".to_string()],\n }\n }\n}\n\nfn handle_service(ctx: &mut RuntimeContext, command: ServiceCommand) -> Result<()> {\n match command {\n ServiceCommand::Run(opts) => run_service_foreground(ctx, opts),\n ServiceCommand::Start(opts) => start_service_background(ctx, opts),\n ServiceCommand::Stop => stop_service(ctx),\n ServiceCommand::Restart(opts) => {\n stop_service(ctx).ok();\n start_service_background(ctx, opts)\n }\n ServiceCommand::Status => status_service(ctx),\n }\n}\n\nfn run_service_foreground(ctx: &mut RuntimeContext, cmd: ServiceRunOpts) -> Result<()> {\n let settings = ctx.service_settings(&cmd)?;\n let effective = ctx.config.clone().with_profile_override(None);\n\n info!(\n \"starting {} with profile '{}' watching {} -> {}\",\n APP_NAME,\n effective.profile,\n settings.watch_dir.display(),\n settings.output_dir.display()\n );\n\n if settings.index_enabled {\n info!(\"indexing enabled at {}\", settings.index_dir.display());\n } else {\n warn!(\"indexing disabled; searches will not include new documents\");\n }\n\n let mut service = ConversionService::new(settings)?;\n if cmd.once {\n service.process_existing()?;\n service.flush_index()?;\n return Ok(());\n }\n\n service.run()\n}\n\nfn start_service_background(ctx: &mut RuntimeContext, cmd: ServiceRunOpts) -> Result<()> {\n let pid_path = ctx.pid_path();\n if let Some(pid) = read_pid(&pid_path)? {\n if process_running(pid) {\n return Err(anyhow!(\"service already running with pid {}\", pid));\n }\n fs::remove_file(&pid_path).ok();\n }\n\n let settings = ctx.service_settings(&cmd)?;\n if ctx.common.dry_run {\n info!(\n \"dry-run: would start background service watching {} -> {}\",\n settings.watch_dir.display(),\n settings.output_dir.display()\n );\n return Ok(());\n }\n\n let mut command = ProcCommand::new(env::current_exe()?);\n command.arg(\"service\").arg(\"run\");\n append_service_args(&mut command, &cmd);\n if let Some(ref cfg) = ctx.common.config {\n command.arg(\"--config\").arg(cfg);\n }\n\n let log_path = ctx.paths.state_dir.join(\"service.log\");\n let log = OpenOptions::new()\n .create(true)\n .append(true)\n .open(&log_path)\n .with_context(|| format!(\"opening log file {}\", log_path.display()))?;\n let log_err = log.try_clone()?;\n command.stdout(Stdio::from(log));\n command.stderr(Stdio::from(log_err));\n command.stdin(Stdio::null());\n\n let child = command\n .spawn()\n .context(\"spawning background service process\")?;\n write_pid(&pid_path, child.id() as i32)?;\n info!(\n \"started background service pid {} (logs at {})\",\n child.id(),\n log_path.display()\n );\n Ok(())\n}\n\nfn stop_service(ctx: &RuntimeContext) -> Result<()> {\n let pid_path = ctx.pid_path();\n let Some(pid) = read_pid(&pid_path)? else {\n info!(\"no running service found (pid file missing)\");\n return Ok(());\n };\n\n if !process_running(pid) {\n info!(\"stale pid {}; removing pid file\", pid);\n fs::remove_file(&pid_path).ok();\n return Ok(());\n }\n\n let mut sys = System::new_all();\n sys.refresh_processes();\n let sys_pid = Pid::from_u32(pid as u32);\n if let Some(proc) = sys.process(sys_pid) {\n let killed = proc.kill_with(Signal::Term).unwrap_or(false) || proc.kill();\n if killed {\n info!(\"stopped service pid {}\", pid);\n fs::remove_file(&pid_path).ok();\n return Ok(());\n }\n }\n\n Err(anyhow!(\"failed to stop service pid {}\", pid))\n}\n\nfn status_service(ctx: &RuntimeContext) -> Result<()> {\n let pid_path = ctx.pid_path();\n let Some(pid) = read_pid(&pid_path)? else {\n println!(\"service status: stopped\");\n return Ok(());\n };\n\n if process_running(pid) {\n println!(\"service status: running (pid {})\", pid);\n } else {\n println!(\"service status: not running (stale pid {})\", pid);\n }\n\n Ok(())\n}\n\nfn append_service_args(cmd: &mut ProcCommand, opts: &ServiceRunOpts) {\n if let Some(ref dir) = opts.watch_dir {\n cmd.arg(\"--watch-dir\").arg(dir);\n }\n if let Some(ref dir) = opts.output_dir {\n cmd.arg(\"--output-dir\").arg(dir);\n }\n if let Some(ref dir) = opts.index_dir {\n cmd.arg(\"--index-dir\").arg(dir);\n }\n if opts.disable_index {\n cmd.arg(\"--disable-index\");\n }\n if opts.once {\n cmd.arg(\"--once\");\n }\n}\n\nfn read_pid(path: &Path) -> Result