Skip to main content

citum_server/
rpc.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! JSON-RPC request handling for the stdio transport.
7//!
8//! This module defines the request envelope shared by the stdio entrypoint
9//! and the HTTP handler, plus the dispatcher that maps method names to the
10//! renderer and validator operations.
11//!
12//! ## JSON-RPC envelope
13//!
14//! Requests use `{ "id", "method", "params" }`. Successful responses echo
15//! the request ID and include `result`; failures echo the request ID when
16//! available and include `error`.
17//!
18//! See the crate-level documentation for the shared method table exposed by
19//! both transports.
20//!
21//! ## `format_document` result
22//!
23//! `format_document` returns one document-level result object with three
24//! top-level fields:
25//!
26//! - `formatted_citations`: one rendered citation object per input citation.
27//! - `bibliography`: the rendered bibliography object, including `format`,
28//!   `content`, and `entries`.
29//! - `warnings`: non-fatal diagnostics produced while evaluating the style.
30//!
31//! Clients should read bibliography output from `result.bibliography`, not
32//! from `result.formatted_citations`.
33//!
34//! ## Stdio example
35//!
36//! From the Citum repository root:
37//!
38//! ```text
39//! printf '%s\n' '{"id":1,"method":"render_citation","params":{"style_path":"styles/embedded/apa-7th.yaml","refs":{"hawking1988":{"id":"hawking1988","class":"monograph","type":"book","title":"A Brief History of Time","author":[{"family":"Hawking","given":"Stephen"}],"issued":"1988"}},"citation":{"id":"cite-1","items":[{"id":"hawking1988"}]}}}' \
40//!   | cargo run -q -p citum-server
41//! ```
42
43use crate::error::ServerError;
44use citum_engine::{
45    Bibliography, Citation, DocumentOptions, Processor, StyleInput,
46    render::{djot::Djot, html::Html, latex::Latex, plain::PlainText, typst::Typst},
47};
48#[cfg(feature = "session")]
49use citum_engine::{
50    CitationInsertPosition, CitationOccurrence, CitationOccurrenceItem, DocumentSession,
51    OpenSessionResult, OutputFormatKind, RefsInput,
52};
53use citum_schema::Style;
54use serde::{Deserialize, Serialize};
55use serde_json::{Value, json};
56#[cfg(all(feature = "session", feature = "http"))]
57use std::collections::HashMap;
58use std::io::{self, BufRead, Write};
59#[cfg(all(feature = "session", feature = "http"))]
60use std::sync::atomic::{AtomicU64, Ordering};
61#[cfg(all(feature = "session", feature = "http"))]
62use std::time::{Duration, SystemTime, UNIX_EPOCH};
63
64/// JSON-RPC request envelope.
65#[derive(Debug, Deserialize)]
66#[cfg_attr(
67    any(feature = "schema", feature = "schema-types"),
68    derive(schemars::JsonSchema)
69)]
70pub struct RpcRequest {
71    /// The request identifier echoed back in success and error responses.
72    pub id: Value,
73    /// The JSON-RPC method name to dispatch.
74    pub method: String,
75    /// The method-specific parameter object.
76    pub params: Value,
77}
78
79/// Output format for rendered citations and bibliographies.
80#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
81#[serde(rename_all = "lowercase")]
82#[cfg_attr(
83    any(feature = "schema", feature = "schema-types"),
84    derive(schemars::JsonSchema)
85)]
86pub enum OutputFormat {
87    /// Plain text output.
88    #[default]
89    Plain,
90    /// HTML output.
91    Html,
92    /// Djot markup output.
93    Djot,
94    /// LaTeX output.
95    Latex,
96    /// Typst output.
97    Typst,
98}
99
100/// Parameters for the `render_citation` method.
101#[derive(Debug, Deserialize)]
102#[cfg_attr(
103    any(feature = "schema", feature = "schema-types"),
104    derive(schemars::JsonSchema)
105)]
106pub struct RenderCitationParams {
107    /// Path to the Citum YAML style file.
108    pub style_path: String,
109    /// Bibliography (references) as a map of reference objects.
110    pub refs: serde_json::Value,
111    /// Citation object specifying which references to cite.
112    pub citation: serde_json::Value,
113    /// Output format for the rendered citation.
114    pub output_format: Option<OutputFormat>,
115    /// Debug: embed AST node indices in output.
116    pub inject_ast_indices: Option<bool>,
117}
118
119/// Parameters for the `render_bibliography` method.
120#[derive(Debug, Deserialize)]
121#[cfg_attr(
122    any(feature = "schema", feature = "schema-types"),
123    derive(schemars::JsonSchema)
124)]
125pub struct RenderBibliographyParams {
126    /// Path to the Citum YAML style file.
127    pub style_path: String,
128    /// Bibliography (references) as a map of reference objects.
129    pub refs: serde_json::Value,
130    /// Output format for the rendered bibliography.
131    pub output_format: Option<OutputFormat>,
132    /// Debug: embed AST node indices in output.
133    pub inject_ast_indices: Option<bool>,
134}
135
136/// Parameters for the `validate_style` method.
137#[derive(Debug, Deserialize)]
138#[cfg_attr(
139    any(feature = "schema", feature = "schema-types"),
140    derive(schemars::JsonSchema)
141)]
142pub struct ValidateStyleParams {
143    /// Path to the Citum YAML style file to validate.
144    pub style_path: String,
145}
146
147/// Parameters for the `format_document` method (schema mirror of `FormatDocumentRequest`).
148#[derive(Debug, Deserialize)]
149#[cfg_attr(
150    any(feature = "schema", feature = "schema-types"),
151    derive(schemars::JsonSchema)
152)]
153pub struct FormatDocumentParams {
154    /// Style identifier, path, URI, or inline YAML.
155    pub style: StyleInput,
156    /// Optional BCP 47 locale override.
157    pub locale: Option<String>,
158    /// Output format (plain, html, djot, latex, typst). Defaults to plain.
159    pub output_format: Option<OutputFormat>,
160    /// Bibliography input as `RefsInput`: path (YAML/JSON/CBOR or `.bib`), inline YAML,
161    /// inline JSON, inline BibLaTeX (`{"kind":"biblatex","value":"@book{…}"}`) or legacy bare map.
162    pub refs: serde_json::Value,
163    /// Ordered citations as they appear in the document.
164    pub citations: serde_json::Value,
165    /// Optional document-level configuration.
166    pub document_options: Option<DocumentOptions>,
167}
168
169/// Parameters for the `open_session` method.
170#[cfg(feature = "session")]
171#[derive(Debug, Deserialize)]
172#[cfg_attr(
173    any(feature = "schema", feature = "schema-types"),
174    derive(schemars::JsonSchema)
175)]
176pub struct OpenSessionParams {
177    /// Style identifier, path, URI, or inline YAML.
178    pub style: StyleInput,
179    /// Optional BCP 47 locale override.
180    pub locale: Option<String>,
181    /// Output format (plain, html, djot, latex, typst). Defaults to plain.
182    pub output_format: Option<OutputFormatKind>,
183    /// Optional document-level configuration.
184    pub document_options: Option<DocumentOptions>,
185}
186
187/// Parameters for the `put_references` method.
188#[cfg(feature = "session")]
189#[derive(Debug, Deserialize)]
190#[cfg_attr(
191    any(feature = "schema", feature = "schema-types"),
192    derive(schemars::JsonSchema)
193)]
194pub struct PutReferencesParams {
195    /// Session identifier returned by `open_session`.
196    pub session_id: Option<String>,
197    /// Full reference input for the session.
198    pub refs: RefsInput,
199}
200
201/// Parameters for the `insert_citations_batch` method.
202#[cfg(feature = "session")]
203#[derive(Debug, Deserialize)]
204#[cfg_attr(
205    any(feature = "schema", feature = "schema-types"),
206    derive(schemars::JsonSchema)
207)]
208pub struct InsertCitationsBatchParams {
209    /// Session identifier returned by `open_session`.
210    pub session_id: Option<String>,
211    /// Complete ordered citation list.
212    pub citations: Vec<CitationOccurrence>,
213}
214
215/// Parameters for the `insert_citation` method.
216#[cfg(feature = "session")]
217#[derive(Debug, Deserialize)]
218#[cfg_attr(
219    any(feature = "schema", feature = "schema-types"),
220    derive(schemars::JsonSchema)
221)]
222pub struct InsertCitationParams {
223    /// Session identifier returned by `open_session`.
224    pub session_id: Option<String>,
225    /// Citation to insert.
226    pub citation: CitationOccurrence,
227    /// Optional neighbour-ID position context.
228    pub position: Option<CitationInsertPosition>,
229}
230
231/// Parameters for the `update_citation` method.
232#[cfg(feature = "session")]
233#[derive(Debug, Deserialize)]
234#[cfg_attr(
235    any(feature = "schema", feature = "schema-types"),
236    derive(schemars::JsonSchema)
237)]
238pub struct UpdateCitationParams {
239    /// Session identifier returned by `open_session`.
240    pub session_id: Option<String>,
241    /// Existing citation ID to update.
242    pub citation_id: String,
243    /// Replacement citation data.
244    pub citation: CitationOccurrence,
245    /// Optional neighbour-ID position context.
246    pub position: Option<CitationInsertPosition>,
247}
248
249/// Parameters for the `delete_citation` method.
250#[cfg(feature = "session")]
251#[derive(Debug, Deserialize)]
252#[cfg_attr(
253    any(feature = "schema", feature = "schema-types"),
254    derive(schemars::JsonSchema)
255)]
256pub struct DeleteCitationParams {
257    /// Session identifier returned by `open_session`.
258    pub session_id: Option<String>,
259    /// Existing citation ID to delete.
260    pub citation_id: String,
261}
262
263/// Parameters for the `preview_citation` method.
264#[cfg(feature = "session")]
265#[derive(Debug, Deserialize)]
266#[cfg_attr(
267    any(feature = "schema", feature = "schema-types"),
268    derive(schemars::JsonSchema)
269)]
270pub struct PreviewCitationParams {
271    /// Session identifier returned by `open_session`.
272    pub session_id: Option<String>,
273    /// Citation items to preview.
274    pub items: Vec<CitationOccurrenceItem>,
275    /// Optional neighbour-ID position context.
276    pub position: Option<CitationInsertPosition>,
277}
278
279/// Parameters for methods that only need a session ID.
280#[cfg(feature = "session")]
281#[derive(Debug, Deserialize)]
282#[cfg_attr(
283    any(feature = "schema", feature = "schema-types"),
284    derive(schemars::JsonSchema)
285)]
286pub struct SessionIdParams {
287    /// Session identifier returned by `open_session`.
288    pub session_id: Option<String>,
289}
290
291#[derive(Debug, Serialize)]
292struct BibliographyResult {
293    format: OutputFormat,
294    content: String,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    entries: Option<Vec<String>>,
297}
298
299#[derive(Debug)]
300#[cfg(all(feature = "session", feature = "http"))]
301struct StoredSession {
302    session: DocumentSession,
303    last_access: SystemTime,
304}
305
306#[cfg(all(feature = "session", feature = "http"))]
307const HTTP_SESSION_TTL_SECS: u64 = 30 * 60;
308
309#[cfg(feature = "session")]
310#[derive(Debug)]
311enum SessionMode {
312    Stdio {
313        session: Box<Option<DocumentSession>>,
314    },
315    #[cfg(all(feature = "session", feature = "http"))]
316    Http {
317        sessions: HashMap<String, StoredSession>,
318        next_session_id: AtomicU64,
319        ttl: Duration,
320    },
321}
322
323/// Stateful RPC dispatcher used by stdio and HTTP transports.
324#[derive(Debug)]
325pub struct RpcDispatcher {
326    #[cfg(feature = "session")]
327    session_mode: SessionMode,
328}
329
330/// Error returned by the stateful RPC dispatcher.
331#[derive(Debug)]
332pub enum RpcDispatchError {
333    /// Legacy string-valued error response.
334    Message(String),
335    /// Complete JSON response for methods with structured top-level errors.
336    Response(Box<Value>),
337}
338
339impl RpcDispatcher {
340    /// Create a dispatcher with one implicit stdio session slot.
341    pub fn new_stdio() -> Self {
342        Self {
343            #[cfg(feature = "session")]
344            session_mode: SessionMode::Stdio {
345                session: Box::new(None),
346            },
347        }
348    }
349
350    /// Create a dispatcher with an HTTP multi-session store.
351    #[cfg(feature = "http")]
352    pub fn new_http() -> Self {
353        Self {
354            #[cfg(feature = "session")]
355            session_mode: SessionMode::Http {
356                sessions: HashMap::new(),
357                next_session_id: AtomicU64::new(1),
358                ttl: Duration::from_secs(HTTP_SESSION_TTL_SECS),
359            },
360        }
361    }
362
363    /// Process one RPC request against this dispatcher's session state.
364    ///
365    /// # Errors
366    ///
367    /// Returns a dispatch error when the method is unknown or a method-specific
368    /// validation, rendering, or session lookup fails.
369    pub fn dispatch(
370        &mut self,
371        req: RpcRequest,
372    ) -> Result<Value, (Option<Value>, RpcDispatchError)> {
373        let id = req.id.clone();
374
375        match req.method.as_str() {
376            "render_citation" => render_citation(&req.params, id)
377                .map_err(|e| (Some(req.id), RpcDispatchError::Message(e.to_string()))),
378            "render_bibliography" => render_bibliography(&req.params, id)
379                .map_err(|e| (Some(req.id), RpcDispatchError::Message(e.to_string()))),
380            "validate_style" => validate_style(&req.params, id)
381                .map_err(|e| (Some(req.id), RpcDispatchError::Message(e.to_string()))),
382            "format_document" => format_document(&req.params, id)
383                .map_err(|e| (Some(req.id), RpcDispatchError::Message(e.to_string()))),
384            #[cfg(feature = "session")]
385            "open_session" => self
386                .open_session(&req.params, id)
387                .map_err(|e| (Some(req.id), RpcDispatchError::Message(e.to_string()))),
388            #[cfg(feature = "session")]
389            "put_references" => self.put_references(&req.params, id, req.id.clone()),
390            #[cfg(feature = "session")]
391            "insert_citations_batch" => {
392                self.insert_citations_batch(&req.params, id, req.id.clone())
393            }
394            #[cfg(feature = "session")]
395            "insert_citation" => self.insert_citation(&req.params, id, req.id.clone()),
396            #[cfg(feature = "session")]
397            "update_citation" => self.update_citation(&req.params, id, req.id.clone()),
398            #[cfg(feature = "session")]
399            "delete_citation" => self.delete_citation(&req.params, id, req.id.clone()),
400            #[cfg(feature = "session")]
401            "preview_citation" => self.preview_citation(&req.params, id, req.id.clone()),
402            #[cfg(feature = "session")]
403            "get_citations" => self.get_citations(&req.params, id, req.id.clone()),
404            #[cfg(feature = "session")]
405            "get_bibliography" => self.get_bibliography(&req.params, id, req.id.clone()),
406            #[cfg(feature = "session")]
407            "close_session" => self.close_session(&req.params, id, req.id.clone()),
408            _ => Err((
409                Some(req.id),
410                RpcDispatchError::Message(format!("unknown method: {}", req.method)),
411            )),
412        }
413    }
414
415    #[cfg(feature = "session")]
416    fn open_session(&mut self, params: &Value, id: Value) -> Result<Value, ServerError> {
417        let params: OpenSessionParams = serde_json::from_value(params.clone())
418            .map_err(|e| ServerError::CitationError(format!("Invalid request JSON: {e}")))?;
419        let style = resolve_style_input(&params.style)?;
420        let session = DocumentSession::new(
421            style,
422            params.style,
423            params.locale,
424            params.output_format.unwrap_or_default(),
425            params.document_options,
426        );
427        let session_id = match &mut self.session_mode {
428            SessionMode::Stdio { session: slot } => {
429                **slot = Some(session);
430                "default".to_string()
431            }
432            #[cfg(feature = "http")]
433            SessionMode::Http {
434                sessions,
435                next_session_id,
436                ..
437            } => {
438                let next = next_session_id.fetch_add(1, Ordering::Relaxed);
439                let session_id = format!("s-{next:016x}");
440                sessions.insert(
441                    session_id.clone(),
442                    StoredSession {
443                        session,
444                        last_access: SystemTime::now(),
445                    },
446                );
447                session_id
448            }
449        };
450        let result = OpenSessionResult { session_id };
451        Ok(json!({ "id": id, "result": result }))
452    }
453
454    #[cfg(feature = "session")]
455    fn put_references(
456        &mut self,
457        params: &Value,
458        id: Value,
459        request_id: Value,
460    ) -> Result<Value, (Option<Value>, RpcDispatchError)> {
461        let params: PutReferencesParams = parse_session_params(params, &request_id)?;
462        let session = self.session_mut(params.session_id.as_deref(), &request_id)?;
463        session.put_references(params.refs);
464        Ok(json!({ "id": id, "result": {} }))
465    }
466
467    #[cfg(feature = "session")]
468    fn insert_citations_batch(
469        &mut self,
470        params: &Value,
471        id: Value,
472        request_id: Value,
473    ) -> Result<Value, (Option<Value>, RpcDispatchError)> {
474        let params: InsertCitationsBatchParams = parse_session_params(params, &request_id)?;
475        let session = self.session_mut(params.session_id.as_deref(), &request_id)?;
476        let result = session
477            .insert_citations_batch(params.citations)
478            .map_err(session_method_error(&request_id))?;
479        Ok(json!({ "id": id, "result": result }))
480    }
481
482    #[cfg(feature = "session")]
483    fn insert_citation(
484        &mut self,
485        params: &Value,
486        id: Value,
487        request_id: Value,
488    ) -> Result<Value, (Option<Value>, RpcDispatchError)> {
489        let params: InsertCitationParams = parse_session_params(params, &request_id)?;
490        let session = self.session_mut(params.session_id.as_deref(), &request_id)?;
491        let result = session
492            .insert_citation(params.citation, params.position)
493            .map_err(session_method_error(&request_id))?;
494        Ok(json!({ "id": id, "result": result }))
495    }
496
497    #[cfg(feature = "session")]
498    fn update_citation(
499        &mut self,
500        params: &Value,
501        id: Value,
502        request_id: Value,
503    ) -> Result<Value, (Option<Value>, RpcDispatchError)> {
504        let params: UpdateCitationParams = parse_session_params(params, &request_id)?;
505        let session = self.session_mut(params.session_id.as_deref(), &request_id)?;
506        let result = session
507            .update_citation(&params.citation_id, params.citation, params.position)
508            .map_err(session_method_error(&request_id))?;
509        Ok(json!({ "id": id, "result": result }))
510    }
511
512    #[cfg(feature = "session")]
513    fn delete_citation(
514        &mut self,
515        params: &Value,
516        id: Value,
517        request_id: Value,
518    ) -> Result<Value, (Option<Value>, RpcDispatchError)> {
519        let params: DeleteCitationParams = parse_session_params(params, &request_id)?;
520        let session = self.session_mut(params.session_id.as_deref(), &request_id)?;
521        let result = session
522            .delete_citation(&params.citation_id)
523            .map_err(session_method_error(&request_id))?;
524        Ok(json!({ "id": id, "result": result }))
525    }
526
527    #[cfg(feature = "session")]
528    fn preview_citation(
529        &mut self,
530        params: &Value,
531        id: Value,
532        request_id: Value,
533    ) -> Result<Value, (Option<Value>, RpcDispatchError)> {
534        let params: PreviewCitationParams = parse_session_params(params, &request_id)?;
535        let session = self.session_mut(params.session_id.as_deref(), &request_id)?;
536        let result = session
537            .preview_citation(params.items, params.position)
538            .map_err(session_method_error(&request_id))?;
539        Ok(json!({ "id": id, "result": result }))
540    }
541
542    #[cfg(feature = "session")]
543    fn get_citations(
544        &mut self,
545        params: &Value,
546        id: Value,
547        request_id: Value,
548    ) -> Result<Value, (Option<Value>, RpcDispatchError)> {
549        let params: SessionIdParams = parse_session_params(params, &request_id)?;
550        let session = self.session_mut(params.session_id.as_deref(), &request_id)?;
551        Ok(json!({
552            "id": id,
553            "result": { "formatted_citations": session.get_citations() }
554        }))
555    }
556
557    #[cfg(feature = "session")]
558    fn get_bibliography(
559        &mut self,
560        params: &Value,
561        id: Value,
562        request_id: Value,
563    ) -> Result<Value, (Option<Value>, RpcDispatchError)> {
564        let params: SessionIdParams = parse_session_params(params, &request_id)?;
565        let session = self.session_mut(params.session_id.as_deref(), &request_id)?;
566        Ok(json!({
567            "id": id,
568            "result": { "bibliography": session.get_bibliography() }
569        }))
570    }
571
572    #[cfg(feature = "session")]
573    fn close_session(
574        &mut self,
575        params: &Value,
576        id: Value,
577        request_id: Value,
578    ) -> Result<Value, (Option<Value>, RpcDispatchError)> {
579        let params: SessionIdParams = parse_session_params(params, &request_id)?;
580        #[cfg(not(feature = "http"))]
581        let _ = &params;
582        match &mut self.session_mode {
583            SessionMode::Stdio { session } => {
584                **session = None;
585            }
586            #[cfg(feature = "http")]
587            SessionMode::Http { sessions, .. } => {
588                let session_id = params.session_id.as_deref().ok_or_else(|| {
589                    (
590                        Some(request_id.clone()),
591                        RpcDispatchError::Message("missing required field: session_id".to_string()),
592                    )
593                })?;
594                sessions.remove(session_id);
595            }
596        }
597        Ok(json!({ "id": id, "result": {} }))
598    }
599
600    #[cfg(feature = "session")]
601    fn session_mut(
602        &mut self,
603        session_id: Option<&str>,
604        request_id: &Value,
605    ) -> Result<&mut DocumentSession, (Option<Value>, RpcDispatchError)> {
606        #[cfg(not(feature = "http"))]
607        let _ = session_id;
608        match &mut self.session_mode {
609            SessionMode::Stdio { session } => session.as_mut().as_mut().ok_or_else(|| {
610                (
611                    Some(request_id.clone()),
612                    RpcDispatchError::Message("session not open".to_string()),
613                )
614            }),
615            #[cfg(feature = "http")]
616            SessionMode::Http { sessions, ttl, .. } => {
617                let session_id = session_id.ok_or_else(|| {
618                    (
619                        Some(request_id.clone()),
620                        RpcDispatchError::Message("missing required field: session_id".to_string()),
621                    )
622                })?;
623                if let Some(stored) = sessions.get(session_id)
624                    && stored.last_access.elapsed().unwrap_or_default() > *ttl
625                {
626                    let expired_at = format_system_time(stored.last_access + *ttl);
627                    sessions.remove(session_id);
628                    return Err((
629                        Some(request_id.clone()),
630                        RpcDispatchError::Response(Box::new(json!({
631                            "id": request_id,
632                            "error": "session_expired",
633                            "session_id": session_id,
634                            "expired_at": expired_at
635                        }))),
636                    ));
637                }
638                let stored = sessions.get_mut(session_id).ok_or_else(|| {
639                    (
640                        Some(request_id.clone()),
641                        RpcDispatchError::Message(format!("session not found: {session_id}")),
642                    )
643                })?;
644                stored.last_access = SystemTime::now();
645                Ok(&mut stored.session)
646            }
647        }
648    }
649}
650
651impl Default for RpcDispatcher {
652    fn default() -> Self {
653        Self::new_stdio()
654    }
655}
656
657/// Return `MissingField` if `field` is absent from `params`.
658fn require_field(params: &Value, field: &'static str) -> Result<(), ServerError> {
659    if params.get(field).is_none() {
660        return Err(ServerError::MissingField(field.into()));
661    }
662    Ok(())
663}
664
665/// Validate the optional `output_format` field before full deserialization.
666fn validate_output_format(params: &Value) -> Result<(), ServerError> {
667    if let Some(v) = params.get("output_format") {
668        serde_json::from_value::<OutputFormat>(v.clone()).map_err(|_| {
669            let raw = v.as_str().unwrap_or("unknown").to_string();
670            ServerError::UnsupportedOutputFormat(raw.into())
671        })?;
672    }
673    Ok(())
674}
675
676/// Main RPC dispatcher that processes a single request.
677///
678/// On success, this returns a JSON object containing the original request ID
679/// and a method-specific `result` payload. On failure, it returns the request
680/// ID when available plus a human-readable error string.
681///
682/// # Errors
683///
684/// Returns an error for unknown methods or when request-specific rendering or
685/// validation steps fail.
686pub fn dispatch(req: RpcRequest) -> Result<Value, (Option<Value>, String)> {
687    let mut dispatcher = RpcDispatcher::new_stdio();
688    dispatcher.dispatch(req).map_err(|(id, error)| match error {
689        RpcDispatchError::Message(message) => (id, message),
690        RpcDispatchError::Response(value) => (id, value.to_string()),
691    })
692}
693
694/// Render a single citation.
695fn render_citation(params: &Value, id: Value) -> Result<Value, ServerError> {
696    require_field(params, "style_path")?;
697    require_field(params, "refs")?;
698    require_field(params, "citation")?;
699    validate_output_format(params)?;
700    let params: RenderCitationParams = serde_json::from_value(params.clone())
701        .map_err(|e| ServerError::CitationError(e.to_string()))?;
702
703    // Load the style.
704    let style = load_style(&params.style_path)?;
705
706    // Deserialize references and citation from JSON.
707    let bibliography: Bibliography = serde_json::from_value(params.refs.clone())
708        .map_err(|e| ServerError::BibliographyError(e.to_string()))?;
709
710    let citation: Citation = serde_json::from_value(params.citation.clone())
711        .map_err(|e| ServerError::CitationError(e.to_string()))?;
712
713    // Create processor and render.
714    let mut processor = Processor::new(style, bibliography);
715    let inject_ast_indices = params.inject_ast_indices.unwrap_or(false);
716    processor.set_inject_ast_indices(inject_ast_indices);
717
718    let output_format = params.output_format.unwrap_or_default();
719    let result = render_citation_with_format(&processor, &citation, output_format)
720        .map_err(|e| ServerError::CitationError(e.to_string()))?;
721
722    Ok(json!({
723        "id": id,
724        "result": result
725    }))
726}
727
728/// Render a bibliography.
729fn render_bibliography(params: &Value, id: Value) -> Result<Value, ServerError> {
730    require_field(params, "style_path")?;
731    require_field(params, "refs")?;
732    validate_output_format(params)?;
733    let params: RenderBibliographyParams = serde_json::from_value(params.clone())
734        .map_err(|e| ServerError::CitationError(e.to_string()))?;
735
736    // Load the style.
737    let style = load_style(&params.style_path)?;
738
739    // Deserialize bibliography from JSON.
740    let bibliography: Bibliography = serde_json::from_value(params.refs.clone())
741        .map_err(|e| ServerError::BibliographyError(e.to_string()))?;
742
743    // Create processor and render bibliography.
744    let mut processor = Processor::new(style, bibliography);
745    let inject_ast_indices = params.inject_ast_indices.unwrap_or(false);
746    processor.set_inject_ast_indices(inject_ast_indices);
747
748    let output_format = params.output_format.unwrap_or_default();
749    let content = render_bibliography_with_format(&processor, output_format)?;
750    let entries = matches!(output_format, OutputFormat::Plain).then(|| {
751        content
752            .lines()
753            .filter(|line| !line.is_empty())
754            .map(std::string::ToString::to_string)
755            .collect()
756    });
757    let result = BibliographyResult {
758        format: output_format,
759        content,
760        entries,
761    };
762
763    Ok(json!({
764        "id": id,
765        "result": result
766    }))
767}
768
769fn render_citation_with_format(
770    processor: &Processor,
771    citation: &Citation,
772    format: OutputFormat,
773) -> Result<String, ServerError> {
774    match format {
775        OutputFormat::Plain => Ok(processor.process_citation_with_format::<PlainText>(citation)?),
776        OutputFormat::Html => Ok(processor.process_citation_with_format::<Html>(citation)?),
777        OutputFormat::Djot => Ok(processor.process_citation_with_format::<Djot>(citation)?),
778        OutputFormat::Latex => Ok(processor.process_citation_with_format::<Latex>(citation)?),
779        OutputFormat::Typst => Ok(processor.process_citation_with_format::<Typst>(citation)?),
780    }
781}
782
783fn render_bibliography_with_format(
784    processor: &Processor,
785    format: OutputFormat,
786) -> Result<String, ServerError> {
787    match format {
788        OutputFormat::Plain => Ok(processor.render_bibliography_with_format::<PlainText>()),
789        OutputFormat::Html => Ok(processor.render_bibliography_with_format::<Html>()),
790        OutputFormat::Djot => Ok(processor.render_bibliography_with_format::<Djot>()),
791        OutputFormat::Latex => Ok(processor.render_bibliography_with_format::<Latex>()),
792        OutputFormat::Typst => Ok(processor.render_bibliography_with_format::<Typst>()),
793    }
794}
795
796/// Validate a style YAML file.
797fn validate_style(params: &Value, id: Value) -> Result<Value, ServerError> {
798    require_field(params, "style_path")?;
799    let params: ValidateStyleParams = serde_json::from_value(params.clone())
800        .map_err(|e| ServerError::CitationError(e.to_string()))?;
801
802    match load_style(&params.style_path) {
803        Ok(_) => Ok(json!({
804            "id": id,
805            "result": {
806                "valid": true,
807                "warnings": []
808            }
809        })),
810        Err(e) => Ok(json!({
811            "id": id,
812            "result": {
813                "valid": false,
814                "warnings": [e.to_string()]
815            }
816        })),
817    }
818}
819
820/// Format a complete document's citations and bibliography.
821fn format_document(params: &Value, id: Value) -> Result<Value, ServerError> {
822    let request: citum_engine::FormatDocumentRequest = serde_json::from_value(params.clone())
823        .map_err(|e| ServerError::CitationError(format!("Invalid request JSON: {}", e)))?;
824
825    let result = match &request.style {
826        citum_engine::StyleInput::Yaml(_) => citum_engine::format_document(request)
827            .map_err(|e| ServerError::CitationError(e.to_string()))?,
828        citum_engine::StyleInput::Id(s)
829        | citum_engine::StyleInput::Uri(s)
830        | citum_engine::StyleInput::Path(s) => {
831            let style = load_style(s)?;
832            citum_engine::format_document_with_style(style, request)
833                .map_err(|e| ServerError::CitationError(e.to_string()))?
834        }
835    };
836
837    let result_json =
838        serde_json::to_value(&result).map_err(|e| ServerError::CitationError(e.to_string()))?;
839
840    Ok(json!({
841        "id": id,
842        "result": result_json
843    }))
844}
845
846#[cfg(feature = "session")]
847fn resolve_style_input(style_input: &StyleInput) -> Result<Style, ServerError> {
848    match style_input {
849        StyleInput::Yaml(_) => style_input
850            .resolve_local()
851            .map_err(|e| ServerError::CitationError(e.to_string())),
852        StyleInput::Id(s) | StyleInput::Uri(s) | StyleInput::Path(s) => load_style(s),
853    }
854}
855
856/// Load a style through the standard resolver chain.
857///
858/// The chain includes file, store, HTTP, git, and registry resolvers.
859/// This server is intended for local use only; do not expose it to untrusted
860/// clients, as `style_input` can trigger outbound network requests (SSRF risk).
861fn load_style(style_input: &str) -> Result<Style, ServerError> {
862    use citum_store::resolver::{ResolverError, StyleResolver};
863
864    let chain = citum_store::build_standard_chain()
865        .map_err(|e| ServerError::ResolverError(e.to_string()))?;
866
867    match chain.resolve_style(style_input) {
868        Ok(style) => {
869            let mut resolved = style
870                .try_into_resolved_with(Some(&chain))
871                .map_err(|e| ServerError::StyleResolution(e.to_string()))?;
872            resolved.extends = None;
873            Ok(resolved)
874        }
875        Err(ResolverError::StyleNotFound(_)) => {
876            Err(ServerError::StyleNotFound(style_input.to_string()))
877        }
878        Err(e) => Err(ServerError::ResolverError(e.to_string())),
879    }
880}
881
882#[cfg(feature = "session")]
883fn parse_session_params<T>(
884    params: &Value,
885    request_id: &Value,
886) -> Result<T, (Option<Value>, RpcDispatchError)>
887where
888    T: for<'de> Deserialize<'de>,
889{
890    serde_json::from_value(params.clone()).map_err(|e| {
891        (
892            Some(request_id.clone()),
893            RpcDispatchError::Message(format!("Invalid request JSON: {e}")),
894        )
895    })
896}
897
898#[cfg(feature = "session")]
899fn session_method_error(
900    request_id: &Value,
901) -> impl FnOnce(citum_engine::DocumentSessionError) -> (Option<Value>, RpcDispatchError) + '_ {
902    |err| {
903        (
904            Some(request_id.clone()),
905            RpcDispatchError::Message(err.to_string()),
906        )
907    }
908}
909
910#[cfg(all(feature = "session", feature = "http"))]
911fn format_system_time(time: SystemTime) -> String {
912    let seconds = time
913        .duration_since(UNIX_EPOCH)
914        .unwrap_or_default()
915        .as_secs();
916    if let Ok(seconds) = i64::try_from(seconds)
917        && let Ok(datetime) = time::OffsetDateTime::from_unix_timestamp(seconds)
918        && let Ok(formatted) = datetime.format(&time::format_description::well_known::Rfc3339)
919    {
920        return formatted;
921    }
922    format!("unix:{seconds}")
923}
924
925/// Build a JSON-RPC error response from a dispatch error.
926pub fn error_response(id: Option<Value>, error: RpcDispatchError) -> Value {
927    match error {
928        RpcDispatchError::Message(error) => json!({
929            "id": id,
930            "error": error
931        }),
932        RpcDispatchError::Response(response) => *response,
933    }
934}
935
936/// Run the JSON-RPC server on stdin/stdout.
937/// Reads newline-delimited JSON requests and writes newline-delimited JSON responses.
938///
939/// # Errors
940///
941/// Returns an error when reading from stdin, writing to stdout, or flushing the
942/// output stream fails.
943pub fn run_stdio() -> io::Result<()> {
944    let stdin = io::stdin();
945    let mut stdout = io::stdout();
946    let mut dispatcher = RpcDispatcher::new_stdio();
947
948    let reader = stdin.lock();
949    for line in reader.lines() {
950        let line = line?;
951
952        // Skip empty lines.
953        if line.is_empty() {
954            continue;
955        }
956
957        // Try to parse the request.
958        let response = match serde_json::from_str::<RpcRequest>(&line) {
959            Ok(req) => match dispatcher.dispatch(req.clone()) {
960                Ok(result) => result,
961                Err((id, error)) => error_response(id, error),
962            },
963            Err(e) => {
964                // Invalid JSON: send error without ID.
965                json!({
966                    "id": Value::Null,
967                    "error": format!("invalid JSON: {}", e)
968                })
969            }
970        };
971
972        // Write response as newline-delimited JSON.
973        writeln!(stdout, "{response}")?;
974        stdout.flush()?;
975    }
976
977    Ok(())
978}
979
980// Helper to make RpcRequest cloneable for error reporting.
981impl Clone for RpcRequest {
982    fn clone(&self) -> Self {
983        RpcRequest {
984            id: self.id.clone(),
985            method: self.method.clone(),
986            params: self.params.clone(),
987        }
988    }
989}