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