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