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 #[arg(long, global = true)]
70 json: bool,
71
72 #[command(subcommand)]
73 command: Command,
74}
75
76#[derive(Debug, Subcommand)]
77enum Command {
78 Doctor,
80 Extract { path: PathBuf },
82 Serve {
84 #[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}