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