Skip to main content

citum_server/
rpc.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus
4*/
5
6use crate::error::ServerError;
7use citum_engine::{
8    Bibliography, Citation, DocumentOptions, Processor, StyleInput,
9    render::{djot::Djot, html::Html, latex::Latex, plain::PlainText, typst::Typst},
10};
11use citum_schema::Style;
12use serde::{Deserialize, Serialize};
13use serde_json::{Value, json};
14use std::io::{self, BufRead, Write};
15
16/// JSON-RPC request envelope.
17#[derive(Debug, Deserialize)]
18#[cfg_attr(
19    any(feature = "schema", feature = "schema-types"),
20    derive(schemars::JsonSchema)
21)]
22pub struct RpcRequest {
23    /// The request identifier echoed back in success and error responses.
24    pub id: Value,
25    /// The JSON-RPC method name to dispatch.
26    pub method: String,
27    /// The method-specific parameter object.
28    pub params: Value,
29}
30
31/// Output format for rendered citations and bibliographies.
32#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
33#[serde(rename_all = "lowercase")]
34#[cfg_attr(
35    any(feature = "schema", feature = "schema-types"),
36    derive(schemars::JsonSchema)
37)]
38pub enum OutputFormat {
39    /// Plain text output.
40    #[default]
41    Plain,
42    /// HTML output.
43    Html,
44    /// Djot markup output.
45    Djot,
46    /// LaTeX output.
47    Latex,
48    /// Typst output.
49    Typst,
50}
51
52/// Parameters for the `render_citation` method.
53#[derive(Debug, Deserialize)]
54#[cfg_attr(
55    any(feature = "schema", feature = "schema-types"),
56    derive(schemars::JsonSchema)
57)]
58pub struct RenderCitationParams {
59    /// Path to the Citum YAML style file.
60    pub style_path: String,
61    /// Bibliography (references) as a map of reference objects.
62    pub refs: serde_json::Value,
63    /// Citation object specifying which references to cite.
64    pub citation: serde_json::Value,
65    /// Output format for the rendered citation.
66    pub output_format: Option<OutputFormat>,
67    /// Debug: embed AST node indices in output.
68    pub inject_ast_indices: Option<bool>,
69}
70
71/// Parameters for the `render_bibliography` method.
72#[derive(Debug, Deserialize)]
73#[cfg_attr(
74    any(feature = "schema", feature = "schema-types"),
75    derive(schemars::JsonSchema)
76)]
77pub struct RenderBibliographyParams {
78    /// Path to the Citum YAML style file.
79    pub style_path: String,
80    /// Bibliography (references) as a map of reference objects.
81    pub refs: serde_json::Value,
82    /// Output format for the rendered bibliography.
83    pub output_format: Option<OutputFormat>,
84    /// Debug: embed AST node indices in output.
85    pub inject_ast_indices: Option<bool>,
86}
87
88/// Parameters for the `validate_style` method.
89#[derive(Debug, Deserialize)]
90#[cfg_attr(
91    any(feature = "schema", feature = "schema-types"),
92    derive(schemars::JsonSchema)
93)]
94pub struct ValidateStyleParams {
95    /// Path to the Citum YAML style file to validate.
96    pub style_path: String,
97}
98
99/// Parameters for the `format_document` method (schema mirror of `FormatDocumentRequest`).
100#[derive(Debug, Deserialize)]
101#[cfg_attr(
102    any(feature = "schema", feature = "schema-types"),
103    derive(schemars::JsonSchema)
104)]
105pub struct FormatDocumentParams {
106    /// Style identifier, path, URI, or inline YAML.
107    pub style: StyleInput,
108    /// Optional BCP 47 locale override.
109    pub locale: Option<String>,
110    /// Output format (plain, html, djot, latex, typst). Defaults to plain.
111    pub output_format: Option<OutputFormat>,
112    /// Bibliography (references) as a map of reference objects.
113    pub refs: serde_json::Value,
114    /// Ordered citations as they appear in the document.
115    pub citations: serde_json::Value,
116    /// Optional document-level configuration.
117    pub document_options: Option<DocumentOptions>,
118}
119
120#[derive(Debug, Serialize)]
121struct BibliographyResult {
122    format: OutputFormat,
123    content: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    entries: Option<Vec<String>>,
126}
127
128/// Return `MissingField` if `field` is absent from `params`.
129fn require_field(params: &Value, field: &'static str) -> Result<(), ServerError> {
130    if params.get(field).is_none() {
131        return Err(ServerError::MissingField(field.into()));
132    }
133    Ok(())
134}
135
136/// Validate the optional `output_format` field before full deserialization.
137fn validate_output_format(params: &Value) -> Result<(), ServerError> {
138    if let Some(v) = params.get("output_format") {
139        serde_json::from_value::<OutputFormat>(v.clone()).map_err(|_| {
140            let raw = v.as_str().unwrap_or("unknown").to_string();
141            ServerError::UnsupportedOutputFormat(raw.into())
142        })?;
143    }
144    Ok(())
145}
146
147/// Main RPC dispatcher that processes a single request.
148///
149/// On success, this returns a JSON object containing the original request ID
150/// and a method-specific `result` payload. On failure, it returns the request
151/// ID when available plus a human-readable error string.
152///
153/// # Errors
154///
155/// Returns an error for unknown methods or when request-specific rendering or
156/// validation steps fail.
157pub fn dispatch(req: RpcRequest) -> Result<Value, (Option<Value>, String)> {
158    let id = req.id.clone();
159
160    match req.method.as_str() {
161        "render_citation" => {
162            render_citation(&req.params, id).map_err(|e| (Some(req.id), e.to_string()))
163        }
164        "render_bibliography" => {
165            render_bibliography(&req.params, id).map_err(|e| (Some(req.id), e.to_string()))
166        }
167        "validate_style" => {
168            validate_style(&req.params, id).map_err(|e| (Some(req.id), e.to_string()))
169        }
170        "format_document" => {
171            format_document(&req.params, id).map_err(|e| (Some(req.id), e.to_string()))
172        }
173        _ => Err((Some(req.id), format!("unknown method: {}", req.method))),
174    }
175}
176
177/// Render a single citation.
178fn render_citation(params: &Value, id: Value) -> Result<Value, ServerError> {
179    require_field(params, "style_path")?;
180    require_field(params, "refs")?;
181    require_field(params, "citation")?;
182    validate_output_format(params)?;
183    let params: RenderCitationParams = serde_json::from_value(params.clone())
184        .map_err(|e| ServerError::CitationError(e.to_string()))?;
185
186    // Load the style.
187    let style = load_style(&params.style_path)?;
188
189    // Deserialize references and citation from JSON.
190    let bibliography: Bibliography = serde_json::from_value(params.refs.clone())
191        .map_err(|e| ServerError::BibliographyError(e.to_string()))?;
192
193    let citation: Citation = serde_json::from_value(params.citation.clone())
194        .map_err(|e| ServerError::CitationError(e.to_string()))?;
195
196    // Create processor and render.
197    let mut processor = Processor::new(style, bibliography);
198    let inject_ast_indices = params.inject_ast_indices.unwrap_or(false);
199    processor.set_inject_ast_indices(inject_ast_indices);
200
201    let output_format = params.output_format.unwrap_or_default();
202    let result = render_citation_with_format(&processor, &citation, output_format)
203        .map_err(|e| ServerError::CitationError(e.to_string()))?;
204
205    Ok(json!({
206        "id": id,
207        "result": result
208    }))
209}
210
211/// Render a bibliography.
212fn render_bibliography(params: &Value, id: Value) -> Result<Value, ServerError> {
213    require_field(params, "style_path")?;
214    require_field(params, "refs")?;
215    validate_output_format(params)?;
216    let params: RenderBibliographyParams = serde_json::from_value(params.clone())
217        .map_err(|e| ServerError::CitationError(e.to_string()))?;
218
219    // Load the style.
220    let style = load_style(&params.style_path)?;
221
222    // Deserialize bibliography from JSON.
223    let bibliography: Bibliography = serde_json::from_value(params.refs.clone())
224        .map_err(|e| ServerError::BibliographyError(e.to_string()))?;
225
226    // Create processor and render bibliography.
227    let mut processor = Processor::new(style, bibliography);
228    let inject_ast_indices = params.inject_ast_indices.unwrap_or(false);
229    processor.set_inject_ast_indices(inject_ast_indices);
230
231    let output_format = params.output_format.unwrap_or_default();
232    let content = render_bibliography_with_format(&processor, output_format)?;
233    let entries = matches!(output_format, OutputFormat::Plain).then(|| {
234        content
235            .lines()
236            .filter(|line| !line.is_empty())
237            .map(std::string::ToString::to_string)
238            .collect()
239    });
240    let result = BibliographyResult {
241        format: output_format,
242        content,
243        entries,
244    };
245
246    Ok(json!({
247        "id": id,
248        "result": result
249    }))
250}
251
252fn render_citation_with_format(
253    processor: &Processor,
254    citation: &Citation,
255    format: OutputFormat,
256) -> Result<String, ServerError> {
257    match format {
258        OutputFormat::Plain => Ok(processor.process_citation_with_format::<PlainText>(citation)?),
259        OutputFormat::Html => Ok(processor.process_citation_with_format::<Html>(citation)?),
260        OutputFormat::Djot => Ok(processor.process_citation_with_format::<Djot>(citation)?),
261        OutputFormat::Latex => Ok(processor.process_citation_with_format::<Latex>(citation)?),
262        OutputFormat::Typst => Ok(processor.process_citation_with_format::<Typst>(citation)?),
263    }
264}
265
266fn render_bibliography_with_format(
267    processor: &Processor,
268    format: OutputFormat,
269) -> Result<String, ServerError> {
270    match format {
271        OutputFormat::Plain => Ok(processor.render_bibliography_with_format::<PlainText>()),
272        OutputFormat::Html => Ok(processor.render_bibliography_with_format::<Html>()),
273        OutputFormat::Djot => Ok(processor.render_bibliography_with_format::<Djot>()),
274        OutputFormat::Latex => Ok(processor.render_bibliography_with_format::<Latex>()),
275        OutputFormat::Typst => Ok(processor.render_bibliography_with_format::<Typst>()),
276    }
277}
278
279/// Validate a style YAML file.
280fn validate_style(params: &Value, id: Value) -> Result<Value, ServerError> {
281    require_field(params, "style_path")?;
282    let params: ValidateStyleParams = serde_json::from_value(params.clone())
283        .map_err(|e| ServerError::CitationError(e.to_string()))?;
284
285    match load_style(&params.style_path) {
286        Ok(_) => Ok(json!({
287            "id": id,
288            "result": {
289                "valid": true,
290                "warnings": []
291            }
292        })),
293        Err(e) => Ok(json!({
294            "id": id,
295            "result": {
296                "valid": false,
297                "warnings": [e.to_string()]
298            }
299        })),
300    }
301}
302
303/// Format a complete document's citations and bibliography.
304fn format_document(params: &Value, id: Value) -> Result<Value, ServerError> {
305    let request: citum_engine::FormatDocumentRequest = serde_json::from_value(params.clone())
306        .map_err(|e| ServerError::CitationError(format!("Invalid request JSON: {}", e)))?;
307
308    let result = match &request.style {
309        citum_engine::StyleInput::Yaml(_) => citum_engine::format_document(request)
310            .map_err(|e| ServerError::CitationError(e.to_string()))?,
311        citum_engine::StyleInput::Id(s)
312        | citum_engine::StyleInput::Uri(s)
313        | citum_engine::StyleInput::Path(s) => {
314            let style = load_style(s)?;
315            citum_engine::format_document_with_style(style, request)
316                .map_err(|e| ServerError::CitationError(e.to_string()))?
317        }
318    };
319
320    let result_json =
321        serde_json::to_value(&result).map_err(|e| ServerError::CitationError(e.to_string()))?;
322
323    Ok(json!({
324        "id": id,
325        "result": result_json
326    }))
327}
328
329/// Load a style through the standard resolver chain.
330///
331/// The chain includes file, store, HTTP, git, and registry resolvers.
332/// This server is intended for local use only; do not expose it to untrusted
333/// clients, as `style_input` can trigger outbound network requests (SSRF risk).
334fn load_style(style_input: &str) -> Result<Style, ServerError> {
335    use citum_store::resolver::{ResolverError, StyleResolver};
336
337    let chain = citum_store::build_standard_chain()
338        .map_err(|e| ServerError::ResolverError(e.to_string()))?;
339
340    match chain.resolve_style(style_input) {
341        Ok(style) => {
342            let mut resolved = style
343                .try_into_resolved_with(Some(&chain))
344                .map_err(|e| ServerError::StyleResolution(e.to_string()))?;
345            resolved.extends = None;
346            Ok(resolved)
347        }
348        Err(ResolverError::StyleNotFound(_)) => {
349            Err(ServerError::StyleNotFound(style_input.to_string()))
350        }
351        Err(e) => Err(ServerError::ResolverError(e.to_string())),
352    }
353}
354
355/// Run the JSON-RPC server on stdin/stdout.
356/// Reads newline-delimited JSON requests and writes newline-delimited JSON responses.
357///
358/// # Errors
359///
360/// Returns an error when reading from stdin, writing to stdout, or flushing the
361/// output stream fails.
362pub fn run_stdio() -> io::Result<()> {
363    let stdin = io::stdin();
364    let mut stdout = io::stdout();
365
366    let reader = stdin.lock();
367    for line in reader.lines() {
368        let line = line?;
369
370        // Skip empty lines.
371        if line.is_empty() {
372            continue;
373        }
374
375        // Try to parse the request.
376        let response = match serde_json::from_str::<RpcRequest>(&line) {
377            Ok(req) => match dispatch(req.clone()) {
378                Ok(result) => result,
379                Err((id, error)) => json!({
380                    "id": id,
381                    "error": error
382                }),
383            },
384            Err(e) => {
385                // Invalid JSON: send error without ID.
386                json!({
387                    "id": Value::Null,
388                    "error": format!("invalid JSON: {}", e)
389                })
390            }
391        };
392
393        // Write response as newline-delimited JSON.
394        writeln!(stdout, "{response}")?;
395        stdout.flush()?;
396    }
397
398    Ok(())
399}
400
401// Helper to make RpcRequest cloneable for error reporting.
402impl Clone for RpcRequest {
403    fn clone(&self) -> Self {
404        RpcRequest {
405            id: self.id.clone(),
406            method: self.method.clone(),
407            params: self.params.clone(),
408        }
409    }
410}