Skip to main content

a3s_use_ocr/
cli.rs

1use std::path::PathBuf;
2
3use a3s_use_core::{UseError, UseResult};
4use clap::error::ErrorKind;
5use clap::{Parser, Subcommand};
6use serde::Serialize;
7
8use crate::{OcrClient, OcrMcpServer, OcrRequest};
9
10#[derive(Debug)]
11pub struct CommandOutput {
12    pub human: String,
13    pub json: serde_json::Value,
14    pub exit_code: u8,
15    pub should_print: bool,
16}
17
18impl CommandOutput {
19    fn data<T>(value: T) -> UseResult<Self>
20    where
21        T: Serialize,
22    {
23        let data = serde_json::to_value(value).map_err(output_error)?;
24        let human = serde_json::to_string_pretty(&data).map_err(output_error)?;
25        Ok(Self {
26            human,
27            json: serde_json::json!({
28                "schemaVersion": 1,
29                "ok": true,
30                "data": data,
31            }),
32            exit_code: 0,
33            should_print: true,
34        })
35    }
36
37    fn text(value: String) -> Self {
38        Self {
39            human: value.clone(),
40            json: serde_json::json!({
41                "schemaVersion": 1,
42                "ok": true,
43                "data": { "text": value },
44            }),
45            exit_code: 0,
46            should_print: true,
47        }
48    }
49
50    fn silent() -> Self {
51        Self {
52            human: String::new(),
53            json: serde_json::Value::Null,
54            exit_code: 0,
55            should_print: false,
56        }
57    }
58}
59
60#[derive(Debug, Parser)]
61#[command(
62    name = "a3s-use-ocr",
63    version,
64    about = "Provider-oriented OCR for A3S (PP-OCRv6 is the default provider)",
65    arg_required_else_help = true
66)]
67struct Cli {
68    /// Emit one versioned JSON document.
69    #[arg(long, global = true)]
70    json: bool,
71
72    #[command(subcommand)]
73    command: Command,
74}
75
76#[derive(Debug, Subcommand)]
77enum Command {
78    /// Inspect the configured OCR provider without reading an image.
79    Doctor,
80    /// Extract text and layout evidence from one local image.
81    Extract { path: PathBuf },
82    /// Run an extension protocol surface.
83    Serve {
84        /// Serve standard MCP over stdin/stdout.
85        #[arg(long)]
86        mcp: bool,
87    },
88}
89
90pub async fn run(args: Vec<String>) -> UseResult<CommandOutput> {
91    let mut argv = vec!["a3s-use-ocr".to_string()];
92    argv.extend(args);
93    let cli = match Cli::try_parse_from(argv) {
94        Ok(cli) => cli,
95        Err(error)
96            if matches!(
97                error.kind(),
98                ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
99            ) =>
100        {
101            return Ok(CommandOutput::text(error.to_string()));
102        }
103        Err(error) => return Err(usage_error(error.to_string())),
104    };
105
106    if let Command::Serve { mcp } = &cli.command {
107        if !mcp {
108            return Err(usage_error("serve requires --mcp"));
109        }
110        if cli.json {
111            return Err(usage_error("--json cannot be combined with serve --mcp"));
112        }
113        OcrMcpServer::from_env()?.serve_stdio().await?;
114        return Ok(CommandOutput::silent());
115    }
116
117    let client = OcrClient::from_env()?;
118    match cli.command {
119        Command::Doctor => CommandOutput::data(client.diagnostic()),
120        Command::Extract { path } => {
121            CommandOutput::data(client.extract(OcrRequest { path }).await?)
122        }
123        Command::Serve { .. } => Err(UseError::new(
124            "use.ocr.command_invalid",
125            "OCR MCP command dispatch reached an invalid state.",
126        )),
127    }
128}
129
130fn output_error(error: serde_json::Error) -> UseError {
131    UseError::new(
132        "use.ocr.output_invalid",
133        format!("Failed to encode OCR command output: {error}"),
134    )
135}
136
137fn usage_error(message: impl Into<String>) -> UseError {
138    UseError::new("use.ocr.usage_invalid", message).with_suggestion("Run 'a3s use ocr --help'.")
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[tokio::test]
146    async fn doctor_is_versioned_even_when_no_provider_is_ready() {
147        let output = run(vec!["doctor".to_string(), "--json".to_string()])
148            .await
149            .unwrap();
150        assert_eq!(output.json["schemaVersion"], 1);
151        assert_eq!(output.json["ok"], true);
152        assert!(output.json["data"]["readiness"].is_string());
153    }
154
155    #[tokio::test]
156    async fn serve_requires_an_explicit_protocol() {
157        let error = run(vec!["serve".to_string()]).await.unwrap_err();
158        assert_eq!(error.code, "use.ocr.usage_invalid");
159    }
160}