Skip to main content

vissue_control/
rpc.rs

1//! JSON-RPC 2.0 types. Handshake is camelCase; issue payloads are snake_case.
2
3use serde::{Deserialize, Serialize, de::DeserializeOwned};
4use serde_json::{Value, json};
5
6use crate::frame::FrameError;
7use vissue_core::error::Error as CoreError;
8use vissue_core::views::{
9    AgendaRow, ClaimRow, Excerpt, IssueDetail, IssueRow, RelatedHit, SearchHit, TreeNode, WalkHit,
10};
11
12/// One entry in the on-disk change log.
13pub use vissue_core::events::Event;
14
15/// Protocol version accepted by `initialize`.
16pub const PROTOCOL_VERSION: u32 = 1;
17
18/// JSON-RPC parse error (`-32700`).
19pub const PARSE_ERROR: i32 = -32700;
20/// JSON-RPC invalid request (`-32600`).
21pub const INVALID_REQUEST: i32 = -32600;
22/// JSON-RPC method not found (`-32601`).
23pub const METHOD_NOT_FOUND: i32 = -32601;
24/// JSON-RPC invalid params (`-32602`).
25pub const INVALID_PARAMS: i32 = -32602;
26/// JSON-RPC internal error (`-32603`).
27pub const INTERNAL_ERROR: i32 = -32603;
28/// Issue not found (`-32004`).
29pub const NOT_FOUND: i32 = -32004;
30/// Claim conflict (`-32009`).
31pub const CONFLICT: i32 = -32009;
32/// Closed issue or invalid state (`-32010`).
33pub const INVALID_STATE: i32 = -32010;
34/// Blocker cycle (`-32022`).
35pub const CYCLE: i32 = -32022;
36
37/// Catalog rebuilt. Params: [`VaultChanged`].
38pub const NOTIFY_VAULT_CHANGED: &str = "vault/changed";
39/// Shared selection. Params: [`IssueSelected`].
40pub const NOTIFY_ISSUE_SELECTED: &str = "issue/selected";
41/// Owner is exiting. Params: `{}`.
42pub const NOTIFY_SHUTTING_DOWN: &str = "serve/shutting_down";
43
44/// Wire-level failure for a control client or dispatcher.
45#[derive(Debug)]
46pub enum Error {
47    /// Socket or file I/O.
48    Io(std::io::Error),
49    /// JSON encode or decode.
50    Json(serde_json::Error),
51    /// Frame read or write.
52    Frame(FrameError),
53    /// Server JSON-RPC error object.
54    Rpc(JsonRpcError),
55    /// Method or platform the client cannot handle.
56    Unsupported(&'static str),
57}
58
59impl std::fmt::Display for Error {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Error::Io(err) => write!(f, "{err}"),
63            Error::Json(err) => write!(f, "{err}"),
64            Error::Frame(err) => write!(f, "{err}"),
65            Error::Rpc(err) => write!(f, "{}", err.message),
66            Error::Unsupported(msg) => write!(f, "{msg}"),
67        }
68    }
69}
70
71impl std::error::Error for Error {
72    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
73        match self {
74            Error::Io(err) => Some(err),
75            Error::Json(err) => Some(err),
76            Error::Frame(err) => Some(err),
77            _ => None,
78        }
79    }
80}
81
82impl From<std::io::Error> for Error {
83    fn from(err: std::io::Error) -> Self {
84        Error::Io(err)
85    }
86}
87
88impl From<serde_json::Error> for Error {
89    fn from(err: serde_json::Error) -> Self {
90        Error::Json(err)
91    }
92}
93
94impl From<FrameError> for Error {
95    fn from(err: FrameError) -> Self {
96        Error::Frame(err)
97    }
98}
99
100impl From<JsonRpcError> for Error {
101    fn from(err: JsonRpcError) -> Self {
102        Error::Rpc(err)
103    }
104}
105
106/// JSON-RPC request or notification id.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(untagged)]
109pub enum JsonRpcId {
110    /// Numeric id.
111    Number(i64),
112    /// String id.
113    String(String),
114    /// JSON `null`. A response, never a notification.
115    Null,
116}
117
118/// JSON-RPC 2.0 request or notification envelope.
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct JsonRpcRequest {
121    /// Always `"2.0"`.
122    pub jsonrpc: String,
123    /// Present on a call; absent on a notification.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub id: Option<JsonRpcId>,
126    /// Method name.
127    pub method: String,
128    /// Params object, or omitted.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub params: Option<Value>,
131}
132
133impl JsonRpcRequest {
134    /// Request with `id`.
135    pub fn call(id: JsonRpcId, method: impl Into<String>, params: Value) -> Self {
136        Self {
137            jsonrpc: "2.0".into(),
138            id: Some(id),
139            method: method.into(),
140            params: Some(params),
141        }
142    }
143
144    /// Notification (no `id`).
145    pub fn notification(method: impl Into<String>, params: Value) -> Self {
146        Self {
147            jsonrpc: "2.0".into(),
148            id: None,
149            method: method.into(),
150            params: Some(params),
151        }
152    }
153
154    /// True when `id` is absent.
155    pub fn is_notification(&self) -> bool {
156        self.id.is_none()
157    }
158}
159
160/// JSON-RPC 2.0 response envelope.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct JsonRpcResponse {
163    /// Always `"2.0"`.
164    pub jsonrpc: String,
165    /// Request id echoed back. `None` or [`JsonRpcId::Null`] on some errors.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub id: Option<JsonRpcId>,
168    /// Success body.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub result: Option<Value>,
171    /// Failure body.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub error: Option<JsonRpcError>,
174}
175
176impl JsonRpcResponse {
177    /// Success response.
178    pub fn ok(id: Option<JsonRpcId>, result: Value) -> Self {
179        Self {
180            jsonrpc: "2.0".into(),
181            id,
182            result: Some(result),
183            error: None,
184        }
185    }
186
187    /// Error response.
188    pub fn err(id: Option<JsonRpcId>, error: JsonRpcError) -> Self {
189        Self {
190            jsonrpc: "2.0".into(),
191            id,
192            result: None,
193            error: Some(error),
194        }
195    }
196}
197
198/// JSON-RPC error object. Application codes carry `data.code`.
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub struct JsonRpcError {
201    /// JSON-RPC or application numeric code.
202    pub code: i32,
203    /// Human-readable message.
204    pub message: String,
205    /// Optional payload. Application codes put `code` here as a string.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub data: Option<Value>,
208}
209
210/// `-32700` parse error.
211pub fn parse_error() -> JsonRpcError {
212    JsonRpcError {
213        code: PARSE_ERROR,
214        message: "parse error".into(),
215        data: None,
216    }
217}
218
219/// `-32600` invalid request.
220pub fn invalid_request() -> JsonRpcError {
221    JsonRpcError {
222        code: INVALID_REQUEST,
223        message: "invalid request".into(),
224        data: None,
225    }
226}
227
228/// `-32601` method not found. `data.method` is `method`.
229pub fn method_not_found(method: &str) -> JsonRpcError {
230    JsonRpcError {
231        code: METHOD_NOT_FOUND,
232        message: "method not found".into(),
233        data: Some(json!({ "method": method })),
234    }
235}
236
237/// `-32602` invalid params with `message`.
238pub fn invalid_params(message: impl Into<String>) -> JsonRpcError {
239    JsonRpcError {
240        code: INVALID_PARAMS,
241        message: message.into(),
242        data: None,
243    }
244}
245
246/// `-32603` internal error with `message`.
247pub fn internal_error(message: impl Into<String>) -> JsonRpcError {
248    JsonRpcError {
249        code: INTERNAL_ERROR,
250        message: message.into(),
251        data: None,
252    }
253}
254
255/// Map a typed core error onto the control-plane codes.
256pub fn error_from_core(err: &CoreError) -> JsonRpcError {
257    match err {
258        CoreError::IssueNotFound { id } => JsonRpcError {
259            code: NOT_FOUND,
260            message: err.to_string(),
261            data: Some(json!({ "code": "not_found", "id": id })),
262        },
263        CoreError::DuplicateId { id, paths } => JsonRpcError {
264            code: CONFLICT,
265            message: err.to_string(),
266            data: Some(json!({
267                "code": "duplicate_id",
268                "id": id,
269                "paths": paths,
270            })),
271        },
272        CoreError::ClaimConflict { id, holder, .. } => JsonRpcError {
273            code: CONFLICT,
274            message: err.to_string(),
275            data: Some(json!({ "code": "conflict", "id": id, "holder": holder })),
276        },
277        CoreError::BlockerCycle { blocker, issue } => JsonRpcError {
278            code: CYCLE,
279            message: err.to_string(),
280            data: Some(json!({ "code": "cycle", "id": issue, "block": blocker })),
281        },
282        CoreError::InvalidState { id, state } => JsonRpcError {
283            code: INVALID_STATE,
284            message: err.to_string(),
285            data: Some(json!({ "code": "invalid_state", "id": id, "state": state })),
286        },
287        CoreError::StaleWrite {
288            id,
289            expected_state,
290            actual_state,
291            expected_gen,
292            actual_gen,
293        } => JsonRpcError {
294            code: INVALID_STATE,
295            message: err.to_string(),
296            data: Some(json!({
297                "code": "stale",
298                "id": id,
299                "expected_state": expected_state,
300                "actual_state": actual_state,
301                "expected_gen": expected_gen,
302                "actual_gen": actual_gen,
303            })),
304        },
305        CoreError::TerminalConflict {
306            id,
307            held,
308            attempted,
309        } => JsonRpcError {
310            code: CONFLICT,
311            message: err.to_string(),
312            data: Some(json!({
313                "code": "terminal_conflict",
314                "id": id,
315                "held": held,
316                "attempted": attempted,
317            })),
318        },
319        CoreError::Other(_) => internal_error(err.to_string()),
320    }
321}
322
323/// v1 methods the owner advertises on `initialize`.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
325pub enum Method {
326    /// Handshake. Not listed in [`V1_CAPABILITIES`].
327    Initialize,
328    /// Process identity, root, prefix, and crate version.
329    IdentityGet,
330    /// Filtered issue rows.
331    IssueList,
332    /// One issue plus revision.
333    IssueGet,
334    /// Frontier: `issue/list` with `ready: true`.
335    IssueReady,
336    /// Substring search over id, title, properties, tags, and body.
337    IssueSearch,
338    /// Live claims.
339    IssueClaims,
340    /// Deadlines and scheduled starts.
341    IssueAgenda,
342    /// Alias of [`Self::IssueGet`].
343    IssueShow,
344    /// Secret-screened body range.
345    IssueExcerpt,
346    /// Children and blockers.
347    IssueTree,
348    /// Bounded neighborhood with evidence.
349    IssueRelated,
350    /// Direct children.
351    IssueChildren,
352    /// Walk up the blocker graph.
353    IssueAncestors,
354    /// Walk down the blocker graph.
355    IssueImpact,
356    /// Everything pointing at the id.
357    IssueBacklinks,
358    /// Shared selection; notifies `issue/selected`.
359    IssueOpen,
360    /// Create an issue.
361    IssueCreate,
362    /// State, priority, block, unblock.
363    IssueUpdate,
364    /// Take the issue.
365    IssueClaim,
366    /// Dated logbook entry.
367    IssueNote,
368    /// Move to another project.
369    IssueRefile,
370    /// Project names plus revision.
371    ProjectList,
372    /// Pull of the on-disk event log.
373    EventsSince,
374    /// Current generation and revision.
375    EventsGen,
376}
377
378impl Method {
379    /// Wire method name.
380    pub fn as_str(self) -> &'static str {
381        match self {
382            Self::Initialize => "initialize",
383            Self::IdentityGet => "identity/get",
384            Self::IssueList => "issue/list",
385            Self::IssueGet => "issue/get",
386            Self::IssueReady => "issue/ready",
387            Self::IssueSearch => "issue/search",
388            Self::IssueClaims => "issue/claims",
389            Self::IssueAgenda => "issue/agenda",
390            Self::IssueShow => "issue/show",
391            Self::IssueExcerpt => "issue/excerpt",
392            Self::IssueTree => "issue/tree",
393            Self::IssueRelated => "issue/related",
394            Self::IssueChildren => "issue/children",
395            Self::IssueAncestors => "issue/ancestors",
396            Self::IssueImpact => "issue/impact",
397            Self::IssueBacklinks => "issue/backlinks",
398            Self::IssueOpen => "issue/open",
399            Self::IssueCreate => "issue/create",
400            Self::IssueUpdate => "issue/update",
401            Self::IssueClaim => "issue/claim",
402            Self::IssueNote => "issue/note",
403            Self::IssueRefile => "issue/refile",
404            Self::ProjectList => "project/list",
405            Self::EventsSince => "events/since",
406            Self::EventsGen => "events/gen",
407        }
408    }
409
410    /// Parse a v1 wire name.
411    ///
412    /// # Errors
413    ///
414    /// Returns an error when `name` is not a v1 method.
415    pub fn parse(name: &str) -> Result<Self, JsonRpcError> {
416        match name {
417            "initialize" => Ok(Self::Initialize),
418            "identity/get" => Ok(Self::IdentityGet),
419            "issue/list" => Ok(Self::IssueList),
420            "issue/get" => Ok(Self::IssueGet),
421            "issue/ready" => Ok(Self::IssueReady),
422            "issue/search" => Ok(Self::IssueSearch),
423            "issue/claims" => Ok(Self::IssueClaims),
424            "issue/agenda" => Ok(Self::IssueAgenda),
425            "issue/show" => Ok(Self::IssueShow),
426            "issue/excerpt" => Ok(Self::IssueExcerpt),
427            "issue/tree" => Ok(Self::IssueTree),
428            "issue/related" => Ok(Self::IssueRelated),
429            "issue/children" => Ok(Self::IssueChildren),
430            "issue/ancestors" => Ok(Self::IssueAncestors),
431            "issue/impact" => Ok(Self::IssueImpact),
432            "issue/backlinks" => Ok(Self::IssueBacklinks),
433            "issue/open" => Ok(Self::IssueOpen),
434            "issue/create" => Ok(Self::IssueCreate),
435            "issue/update" => Ok(Self::IssueUpdate),
436            "issue/claim" => Ok(Self::IssueClaim),
437            "issue/note" => Ok(Self::IssueNote),
438            "issue/refile" => Ok(Self::IssueRefile),
439            "project/list" => Ok(Self::ProjectList),
440            "events/since" => Ok(Self::EventsSince),
441            "events/gen" => Ok(Self::EventsGen),
442            other => Err(method_not_found(other)),
443        }
444    }
445}
446
447/// Capability strings returned by `initialize` (v1). `initialize` itself is omitted.
448pub const V1_CAPABILITIES: &[&str] = &[
449    "issue/list",
450    "issue/get",
451    "issue/ready",
452    "issue/search",
453    "issue/claims",
454    "issue/agenda",
455    "issue/show",
456    "issue/excerpt",
457    "issue/tree",
458    "issue/related",
459    "issue/children",
460    "issue/ancestors",
461    "issue/impact",
462    "issue/backlinks",
463    "issue/open",
464    "issue/create",
465    "issue/update",
466    "issue/claim",
467    "issue/note",
468    "issue/refile",
469    "project/list",
470    "events/since",
471    "events/gen",
472    "identity/get",
473];
474
475/// `initialize` params. camelCase on the wire.
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477#[serde(rename_all = "camelCase")]
478pub struct InitializeParams {
479    /// Must be [`PROTOCOL_VERSION`].
480    pub protocol_version: u32,
481    /// Client name, e.g. `vissue-tui`. Empty when omitted.
482    #[serde(default)]
483    pub client: String,
484    /// Connection identity. Required and non-empty.
485    pub agent: String,
486}
487
488/// `initialize` result. camelCase on the wire.
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
490#[serde(rename_all = "camelCase")]
491pub struct InitializeResult {
492    /// Echo of [`PROTOCOL_VERSION`].
493    pub protocol_version: u32,
494    /// Advertised methods. See [`V1_CAPABILITIES`].
495    pub capabilities: Vec<String>,
496    /// Tracker root the owner bound.
497    pub root: String,
498    /// Layout prefix the owner bound.
499    pub prefix: String,
500    /// On-disk generation counter.
501    pub generation: u64,
502    /// Serve-local catalog revision. Starts at 1.
503    pub revision: u64,
504    /// Owner identity.
505    pub identity: String,
506}
507
508/// Parse `initialize` params. Missing/empty `agent` and version != 1 are -32602.
509///
510/// # Errors
511///
512/// Returns an error when `value` is not an object, `protocolVersion` is
513/// missing, not a number, or not 1, or `agent` is missing or empty.
514pub fn parse_initialize_params(value: &Value) -> Result<InitializeParams, JsonRpcError> {
515    let obj = value
516        .as_object()
517        .ok_or_else(|| invalid_params("params must be an object"))?;
518    let version = match obj.get("protocolVersion") {
519        Some(Value::Number(n)) => n
520            .as_u64()
521            .ok_or_else(|| invalid_params("protocolVersion must be a number"))?,
522        Some(_) => return Err(invalid_params("protocolVersion must be a number")),
523        None => return Err(invalid_params("protocolVersion is required")),
524    };
525    if version != u64::from(PROTOCOL_VERSION) {
526        return Err(JsonRpcError {
527            code: INVALID_PARAMS,
528            message: "unsupported protocol version".into(),
529            data: Some(json!({ "supported": PROTOCOL_VERSION })),
530        });
531    }
532    let agent = match obj.get("agent") {
533        Some(Value::String(s)) if !s.trim().is_empty() => s.clone(),
534        _ => return Err(invalid_params("agent is required")),
535    };
536    let client = obj
537        .get("client")
538        .and_then(Value::as_str)
539        .unwrap_or("")
540        .to_string();
541    Ok(InitializeParams {
542        protocol_version: PROTOCOL_VERSION,
543        client,
544        agent,
545    })
546}
547
548/// Filters for `issue/list` and `issue/ready`. snake_case on the wire.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
550pub struct IssueListParams {
551    /// Restrict to this project.
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    pub project: Option<String>,
554    /// Restrict to this TODO keyword.
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub state: Option<String>,
557    /// When `true`, only the frontier (no open blockers).
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub ready: Option<bool>,
560    /// Case-insensitive substring over id, title, tags, and properties.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub query: Option<String>,
563    /// Max rows after offset.
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub limit: Option<usize>,
566    /// Skip this many matching rows.
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub offset: Option<usize>,
569    /// When this equals the current revision, the result is unchanged.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub since_revision: Option<u64>,
572}
573
574/// Page of issue rows, or an unchanged marker.
575#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
576pub struct IssueListResult {
577    /// Matching rows. Empty when [`Self::unchanged`].
578    #[serde(default)]
579    pub issues: Vec<IssueRow>,
580    /// Issues in the selected project (or whole vault) before other filters.
581    #[serde(default)]
582    pub total: u64,
583    /// Rows matching state, ready, and query, before limit and offset.
584    #[serde(default)]
585    pub matched: u64,
586    /// Current serve revision.
587    pub revision: u64,
588    /// Current on-disk generation.
589    #[serde(default)]
590    pub generation: u64,
591    /// `since_revision` matched; `issues` is empty.
592    #[serde(default)]
593    pub unchanged: bool,
594}
595
596/// Single issue id.
597#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
598pub struct IdParams {
599    /// Issue id.
600    pub id: String,
601}
602
603/// One issue plus the serve revision.
604#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
605pub struct IssueGetResult {
606    /// Flattened detail fields on the wire.
607    #[serde(flatten)]
608    pub issue: IssueDetail,
609    /// Current serve revision.
610    pub revision: u64,
611}
612
613/// `issue/search` params. Default `limit` is 20.
614#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
615pub struct SearchParams {
616    /// Substring over id, title, properties, tags, and body.
617    pub query: String,
618    /// Max hits.
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub limit: Option<usize>,
621}
622
623/// `issue/claims` params.
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
625pub struct ClaimsParams {
626    /// Restrict to this holder.
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub holder: Option<String>,
629    /// Restrict to this project.
630    #[serde(default, skip_serializing_if = "Option::is_none")]
631    pub project: Option<String>,
632}
633
634/// `issue/agenda` params. Default `days` is 14.
635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
636pub struct AgendaParams {
637    /// Horizon in days.
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub days: Option<i64>,
640    /// Restrict to this project.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub project: Option<String>,
643}
644
645/// `issue/tree` params. `format` is `nodes`, `ascii`, or `dot`.
646#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
647pub struct TreeParams {
648    /// Root issue id.
649    pub id: String,
650    /// `nodes` (default), `ascii`, or `dot`.
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub format: Option<String>,
653}
654
655/// `issue/tree` result: a node graph or rendered text.
656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
657#[serde(untagged)]
658pub enum TreeResult {
659    /// Structured tree (`format` omitted or `nodes`).
660    Nodes(TreeNode),
661    /// Rendered `ascii` or `dot`.
662    Text {
663        /// Graph text.
664        text: String,
665    },
666}
667
668/// `issue/related` params. Default `depth` is 2 and `limit` is 20.
669#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
670pub struct RelatedParams {
671    /// Center issue id.
672    pub id: String,
673    /// Graph walk depth.
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub depth: Option<usize>,
676    /// Max hits.
677    #[serde(default, skip_serializing_if = "Option::is_none")]
678    pub limit: Option<usize>,
679}
680
681/// Params for children, ancestors, impact, and backlinks.
682#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
683pub struct WalkParams {
684    /// Start issue id.
685    pub id: String,
686    /// Walk depth. Omitted means the method default.
687    #[serde(default, skip_serializing_if = "Option::is_none")]
688    pub depth: Option<usize>,
689}
690
691/// `project/list` result.
692#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
693pub struct ProjectListResult {
694    /// Project names under the prefix.
695    pub projects: Vec<String>,
696    /// Current serve revision.
697    pub revision: u64,
698}
699
700/// `events/since` params.
701#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
702pub struct EventsSinceParams {
703    /// Return events with sequence greater than this.
704    pub since: u64,
705    /// Max events.
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub limit: Option<usize>,
708}
709
710/// Pull of the on-disk event log.
711#[derive(Debug, Clone, Serialize, Deserialize)]
712pub struct EventsSinceResult {
713    /// Events after `since`.
714    pub events: Vec<Event>,
715    /// Current generation after the pull.
716    pub generation: u64,
717}
718
719/// `events/gen` result.
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
721pub struct EventsGenResult {
722    /// On-disk generation counter.
723    pub generation: u64,
724    /// Serve-local catalog revision.
725    pub revision: u64,
726}
727
728/// `identity/get` result.
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
730pub struct IdentityResult {
731    /// Connection or process identity.
732    pub identity: String,
733    /// Tracker root.
734    pub root: String,
735    /// Layout prefix.
736    pub prefix: String,
737    /// Crate version string.
738    pub version: String,
739}
740
741/// `issue/create` params. Fields match the CLI create verb.
742#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
743pub struct CreateParams {
744    /// Target project.
745    pub project: String,
746    /// Heading title.
747    pub title: String,
748    /// Override the connection agent.
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub agent: Option<String>,
751    /// Priority letter.
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub priority: Option<char>,
754    /// `:TYPE:` property.
755    #[serde(default, skip_serializing_if = "Option::is_none")]
756    pub issue_type: Option<String>,
757    /// Org deadline stamp.
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub deadline: Option<String>,
760    /// Org scheduled stamp.
761    #[serde(default, skip_serializing_if = "Option::is_none")]
762    pub scheduled: Option<String>,
763    /// Space-separated tags.
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub tags: Option<String>,
766    /// Parent issue id.
767    #[serde(default, skip_serializing_if = "Option::is_none")]
768    pub parent: Option<String>,
769    /// Body prose written under the properties drawer.
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub body: Option<String>,
772}
773
774/// `issue/update` params.
775#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
776pub struct UpdateParams {
777    /// Issue id.
778    pub id: String,
779    /// New TODO keyword.
780    #[serde(default, skip_serializing_if = "Option::is_none")]
781    pub state: Option<String>,
782    /// New priority letter.
783    #[serde(default, skip_serializing_if = "Option::is_none")]
784    pub priority: Option<String>,
785    /// Add this blocker.
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub block: Option<String>,
788    /// Remove this blocker.
789    #[serde(default, skip_serializing_if = "Option::is_none")]
790    pub unblock: Option<String>,
791    /// Refuse unless the heading is still this state.
792    #[serde(default, skip_serializing_if = "Option::is_none")]
793    pub if_state: Option<String>,
794    /// Refuse unless the corpus generation is still this value.
795    #[serde(default, skip_serializing_if = "Option::is_none")]
796    pub if_gen: Option<u64>,
797    /// Override the connection agent.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub agent: Option<String>,
800}
801
802/// `issue/claim` params. `force` defaults to false.
803#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
804pub struct ClaimParams {
805    /// Issue id.
806    pub id: String,
807    /// Take over an existing claim.
808    #[serde(default)]
809    pub force: bool,
810    /// Override the connection agent.
811    #[serde(default, skip_serializing_if = "Option::is_none")]
812    pub agent: Option<String>,
813}
814
815/// `issue/note` params.
816#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
817pub struct NoteParams {
818    /// Issue id.
819    pub id: String,
820    /// Logbook text.
821    pub text: String,
822}
823
824/// `issue/refile` params.
825#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
826pub struct RefileParams {
827    /// Issue id.
828    pub id: String,
829    /// Destination project.
830    pub to: String,
831}
832
833/// Mutation result shared by create, update, claim, note, and refile.
834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
835pub struct MutResult {
836    /// True when the write succeeded.
837    pub ok: bool,
838    /// Same text the CLI would print.
839    pub report: String,
840    /// Post-write detail. Null on refile of a vanished source.
841    #[serde(default)]
842    pub issue: Option<IssueDetail>,
843    /// Serve revision after the write.
844    pub revision: u64,
845    /// On-disk generation after the write.
846    pub generation: u64,
847}
848
849/// `vault/changed` params. Broadcast after a catalog rebuild.
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
851pub struct VaultChanged {
852    /// On-disk generation.
853    pub generation: u64,
854    /// Serve-local revision.
855    pub revision: u64,
856    /// Dirty project names.
857    #[serde(default)]
858    pub projects: Vec<String>,
859    /// Touched issue ids, when known.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub ids: Option<Vec<String>>,
862}
863
864/// `issue/selected` params. Broadcast after `issue/open`.
865#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
866pub struct IssueSelected {
867    /// Selected issue id.
868    pub id: String,
869    /// Project of that issue.
870    pub project: String,
871}
872
873/// Push notifications. No `id` on the wire.
874#[derive(Debug, Clone, PartialEq)]
875pub enum Notification {
876    /// [`NOTIFY_VAULT_CHANGED`].
877    VaultChanged(VaultChanged),
878    /// [`NOTIFY_ISSUE_SELECTED`].
879    IssueSelected(IssueSelected),
880    /// [`NOTIFY_SHUTTING_DOWN`].
881    ServeShuttingDown,
882    /// Method the client does not know, or params that failed to decode.
883    Unknown {
884        /// Wire method name.
885        method: String,
886        /// Raw params.
887        params: Value,
888    },
889}
890
891impl Notification {
892    /// Wire method name.
893    pub fn method(&self) -> &str {
894        match self {
895            Self::VaultChanged(_) => NOTIFY_VAULT_CHANGED,
896            Self::IssueSelected(_) => NOTIFY_ISSUE_SELECTED,
897            Self::ServeShuttingDown => NOTIFY_SHUTTING_DOWN,
898            Self::Unknown { method, .. } => method,
899        }
900    }
901
902    /// Parse a method/params pair. Unknown names stay [`Self::Unknown`].
903    pub fn parse(method: &str, params: Value) -> Self {
904        match method {
905            NOTIFY_VAULT_CHANGED => match serde_json::from_value(params.clone()) {
906                Ok(body) => Self::VaultChanged(body),
907                Err(_) => Self::Unknown {
908                    method: method.into(),
909                    params,
910                },
911            },
912            NOTIFY_ISSUE_SELECTED => match serde_json::from_value(params.clone()) {
913                Ok(body) => Self::IssueSelected(body),
914                Err(_) => Self::Unknown {
915                    method: method.into(),
916                    params,
917                },
918            },
919            NOTIFY_SHUTTING_DOWN => Self::ServeShuttingDown,
920            other => Self::Unknown {
921                method: other.into(),
922                params,
923            },
924        }
925    }
926
927    /// Params object for the wire.
928    pub fn to_params(&self) -> Value {
929        match self {
930            Self::VaultChanged(body) => serde_json::to_value(body).unwrap_or(Value::Null),
931            Self::IssueSelected(body) => serde_json::to_value(body).unwrap_or(Value::Null),
932            Self::ServeShuttingDown => json!({}),
933            Self::Unknown { params, .. } => params.clone(),
934        }
935    }
936}
937
938/// Typed v1 request.
939#[derive(Debug, Clone, PartialEq)]
940pub enum Request {
941    /// Handshake.
942    Initialize(InitializeParams),
943    /// Process identity, root, prefix, and crate version.
944    IdentityGet,
945    /// Filtered issue rows.
946    IssueList(IssueListParams),
947    /// One issue plus revision.
948    IssueGet(IdParams),
949    /// Frontier: `issue/list` with `ready: true`.
950    IssueReady(IssueListParams),
951    /// Substring search over id, title, properties, tags, and body.
952    IssueSearch(SearchParams),
953    /// Live claims.
954    IssueClaims(ClaimsParams),
955    /// Deadlines and scheduled starts.
956    IssueAgenda(AgendaParams),
957    /// Alias of [`Self::IssueGet`].
958    IssueShow(IdParams),
959    /// Secret-screened body range.
960    IssueExcerpt(IdParams),
961    /// Children and blockers.
962    IssueTree(TreeParams),
963    /// Bounded neighborhood with evidence.
964    IssueRelated(RelatedParams),
965    /// Direct children.
966    IssueChildren(WalkParams),
967    /// Walk up the blocker graph.
968    IssueAncestors(WalkParams),
969    /// Walk down the blocker graph.
970    IssueImpact(WalkParams),
971    /// Everything pointing at the id.
972    IssueBacklinks(WalkParams),
973    /// Shared selection; notifies `issue/selected`.
974    IssueOpen(IdParams),
975    /// Create an issue.
976    IssueCreate(CreateParams),
977    /// State, priority, block, unblock.
978    IssueUpdate(UpdateParams),
979    /// Take the issue.
980    IssueClaim(ClaimParams),
981    /// Dated logbook entry.
982    IssueNote(NoteParams),
983    /// Move to another project.
984    IssueRefile(RefileParams),
985    /// Project names plus revision.
986    ProjectList,
987    /// Pull of the on-disk event log.
988    EventsSince(EventsSinceParams),
989    /// Current generation and revision.
990    EventsGen,
991}
992
993impl Request {
994    /// Wire [`Method`] for this request.
995    pub fn method(&self) -> Method {
996        match self {
997            Self::Initialize(_) => Method::Initialize,
998            Self::IdentityGet => Method::IdentityGet,
999            Self::IssueList(_) => Method::IssueList,
1000            Self::IssueGet(_) => Method::IssueGet,
1001            Self::IssueReady(_) => Method::IssueReady,
1002            Self::IssueSearch(_) => Method::IssueSearch,
1003            Self::IssueClaims(_) => Method::IssueClaims,
1004            Self::IssueAgenda(_) => Method::IssueAgenda,
1005            Self::IssueShow(_) => Method::IssueShow,
1006            Self::IssueExcerpt(_) => Method::IssueExcerpt,
1007            Self::IssueTree(_) => Method::IssueTree,
1008            Self::IssueRelated(_) => Method::IssueRelated,
1009            Self::IssueChildren(_) => Method::IssueChildren,
1010            Self::IssueAncestors(_) => Method::IssueAncestors,
1011            Self::IssueImpact(_) => Method::IssueImpact,
1012            Self::IssueBacklinks(_) => Method::IssueBacklinks,
1013            Self::IssueOpen(_) => Method::IssueOpen,
1014            Self::IssueCreate(_) => Method::IssueCreate,
1015            Self::IssueUpdate(_) => Method::IssueUpdate,
1016            Self::IssueClaim(_) => Method::IssueClaim,
1017            Self::IssueNote(_) => Method::IssueNote,
1018            Self::IssueRefile(_) => Method::IssueRefile,
1019            Self::ProjectList => Method::ProjectList,
1020            Self::EventsSince(_) => Method::EventsSince,
1021            Self::EventsGen => Method::EventsGen,
1022        }
1023    }
1024
1025    /// Parse a method/params pair.
1026    ///
1027    /// # Errors
1028    ///
1029    /// Returns an error when `method` is unknown or `params` fail to decode.
1030    pub fn parse(method: &str, params: Option<Value>) -> Result<Self, JsonRpcError> {
1031        let method = Method::parse(method)?;
1032        let params = match params {
1033            None | Some(Value::Null) => Value::Object(Default::default()),
1034            Some(v) => v,
1035        };
1036        match method {
1037            Method::Initialize => Ok(Self::Initialize(parse_initialize_params(&params)?)),
1038            Method::IdentityGet => Ok(Self::IdentityGet),
1039            Method::IssueList => Ok(Self::IssueList(decode_params(params)?)),
1040            Method::IssueGet => Ok(Self::IssueGet(decode_params(params)?)),
1041            Method::IssueReady => Ok(Self::IssueReady(decode_params(params)?)),
1042            Method::IssueSearch => Ok(Self::IssueSearch(decode_params(params)?)),
1043            Method::IssueClaims => Ok(Self::IssueClaims(decode_params(params)?)),
1044            Method::IssueAgenda => Ok(Self::IssueAgenda(decode_params(params)?)),
1045            Method::IssueShow => Ok(Self::IssueShow(decode_params(params)?)),
1046            Method::IssueExcerpt => Ok(Self::IssueExcerpt(decode_params(params)?)),
1047            Method::IssueTree => Ok(Self::IssueTree(decode_params(params)?)),
1048            Method::IssueRelated => Ok(Self::IssueRelated(decode_params(params)?)),
1049            Method::IssueChildren => Ok(Self::IssueChildren(decode_params(params)?)),
1050            Method::IssueAncestors => Ok(Self::IssueAncestors(decode_params(params)?)),
1051            Method::IssueImpact => Ok(Self::IssueImpact(decode_params(params)?)),
1052            Method::IssueBacklinks => Ok(Self::IssueBacklinks(decode_params(params)?)),
1053            Method::IssueOpen => Ok(Self::IssueOpen(decode_params(params)?)),
1054            Method::IssueCreate => Ok(Self::IssueCreate(decode_params(params)?)),
1055            Method::IssueUpdate => Ok(Self::IssueUpdate(decode_params(params)?)),
1056            Method::IssueClaim => Ok(Self::IssueClaim(decode_params(params)?)),
1057            Method::IssueNote => Ok(Self::IssueNote(decode_params(params)?)),
1058            Method::IssueRefile => Ok(Self::IssueRefile(decode_params(params)?)),
1059            Method::ProjectList => Ok(Self::ProjectList),
1060            Method::EventsSince => Ok(Self::EventsSince(decode_params(params)?)),
1061            Method::EventsGen => Ok(Self::EventsGen),
1062        }
1063    }
1064
1065    /// Params object for the wire.
1066    pub fn to_params(&self) -> Value {
1067        match self {
1068            Self::Initialize(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1069            Self::IdentityGet | Self::ProjectList | Self::EventsGen => json!({}),
1070            Self::IssueList(p) | Self::IssueReady(p) => {
1071                serde_json::to_value(p).unwrap_or(Value::Null)
1072            }
1073            Self::IssueGet(p) | Self::IssueShow(p) | Self::IssueExcerpt(p) | Self::IssueOpen(p) => {
1074                serde_json::to_value(p).unwrap_or(Value::Null)
1075            }
1076            Self::IssueSearch(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1077            Self::IssueClaims(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1078            Self::IssueAgenda(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1079            Self::IssueTree(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1080            Self::IssueRelated(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1081            Self::IssueChildren(p)
1082            | Self::IssueAncestors(p)
1083            | Self::IssueImpact(p)
1084            | Self::IssueBacklinks(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1085            Self::IssueCreate(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1086            Self::IssueUpdate(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1087            Self::IssueClaim(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1088            Self::IssueNote(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1089            Self::IssueRefile(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1090            Self::EventsSince(p) => serde_json::to_value(p).unwrap_or(Value::Null),
1091        }
1092    }
1093}
1094
1095/// Typed v1 result body.
1096#[derive(Debug, Clone)]
1097pub enum Response {
1098    /// Handshake.
1099    Initialize(InitializeResult),
1100    /// Process identity, root, prefix, and crate version.
1101    IdentityGet(IdentityResult),
1102    /// Filtered issue rows.
1103    IssueList(IssueListResult),
1104    /// One issue plus revision.
1105    IssueGet(IssueGetResult),
1106    /// Frontier page.
1107    IssueReady(IssueListResult),
1108    /// Search hits.
1109    IssueSearch(Vec<SearchHit>),
1110    /// Live claims.
1111    IssueClaims(Vec<ClaimRow>),
1112    /// Deadlines and scheduled starts.
1113    IssueAgenda(Vec<AgendaRow>),
1114    /// Alias of [`Self::IssueGet`].
1115    IssueShow(IssueGetResult),
1116    /// Secret-screened body range.
1117    IssueExcerpt(Excerpt),
1118    /// Children and blockers.
1119    IssueTree(TreeResult),
1120    /// Bounded neighborhood with evidence.
1121    IssueRelated(Vec<RelatedHit>),
1122    /// Direct children.
1123    IssueChildren(Vec<WalkHit>),
1124    /// Walk up the blocker graph.
1125    IssueAncestors(Vec<WalkHit>),
1126    /// Walk down the blocker graph.
1127    IssueImpact(Vec<WalkHit>),
1128    /// Everything pointing at the id.
1129    IssueBacklinks(Vec<WalkHit>),
1130    /// Shared selection result.
1131    IssueOpen(IssueGetResult),
1132    /// Create result.
1133    IssueCreate(MutResult),
1134    /// Update result.
1135    IssueUpdate(MutResult),
1136    /// Claim result.
1137    IssueClaim(MutResult),
1138    /// Note result.
1139    IssueNote(MutResult),
1140    /// Refile result.
1141    IssueRefile(MutResult),
1142    /// Project names plus revision.
1143    ProjectList(ProjectListResult),
1144    /// Pull of the on-disk event log.
1145    EventsSince(EventsSinceResult),
1146    /// Current generation and revision.
1147    EventsGen(EventsGenResult),
1148}
1149
1150impl Response {
1151    /// Serialize the result body (not the envelope).
1152    ///
1153    /// # Errors
1154    ///
1155    /// Returns an error when the body cannot be encoded as JSON.
1156    pub fn to_value(&self) -> Result<Value, serde_json::Error> {
1157        match self {
1158            Self::Initialize(v) => serde_json::to_value(v),
1159            Self::IdentityGet(v) => serde_json::to_value(v),
1160            Self::IssueList(v) | Self::IssueReady(v) => serde_json::to_value(v),
1161            Self::IssueGet(v) | Self::IssueShow(v) | Self::IssueOpen(v) => serde_json::to_value(v),
1162            Self::IssueSearch(v) => serde_json::to_value(v),
1163            Self::IssueClaims(v) => serde_json::to_value(v),
1164            Self::IssueAgenda(v) => serde_json::to_value(v),
1165            Self::IssueExcerpt(v) => serde_json::to_value(v),
1166            Self::IssueTree(v) => serde_json::to_value(v),
1167            Self::IssueRelated(v) => serde_json::to_value(v),
1168            Self::IssueChildren(v)
1169            | Self::IssueAncestors(v)
1170            | Self::IssueImpact(v)
1171            | Self::IssueBacklinks(v) => serde_json::to_value(v),
1172            Self::IssueCreate(v)
1173            | Self::IssueUpdate(v)
1174            | Self::IssueClaim(v)
1175            | Self::IssueNote(v)
1176            | Self::IssueRefile(v) => serde_json::to_value(v),
1177            Self::ProjectList(v) => serde_json::to_value(v),
1178            Self::EventsSince(v) => serde_json::to_value(v),
1179            Self::EventsGen(v) => serde_json::to_value(v),
1180        }
1181    }
1182}
1183
1184fn decode_params<T: DeserializeOwned>(value: Value) -> Result<T, JsonRpcError> {
1185    serde_json::from_value(value).map_err(|e| invalid_params(e.to_string()))
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191    use std::collections::BTreeMap;
1192
1193    #[test]
1194    fn initialize_missing_agent_is_invalid_params() {
1195        let err = parse_initialize_params(&json!({
1196            "protocolVersion": 1,
1197            "client": "vissue-tui"
1198        }))
1199        .unwrap_err();
1200        assert_eq!(err.code, INVALID_PARAMS);
1201        assert_eq!(err.message, "agent is required");
1202
1203        let err = parse_initialize_params(&json!({
1204            "protocolVersion": 1,
1205            "agent": ""
1206        }))
1207        .unwrap_err();
1208        assert_eq!(err.code, INVALID_PARAMS);
1209        assert_eq!(err.message, "agent is required");
1210
1211        let err = Request::parse(
1212            "initialize",
1213            Some(json!({"protocolVersion": 1, "agent": "   "})),
1214        )
1215        .unwrap_err();
1216        assert_eq!(err.code, INVALID_PARAMS);
1217    }
1218
1219    #[test]
1220    fn protocol_version_2_is_rejected() {
1221        let err = parse_initialize_params(&json!({
1222            "protocolVersion": 2,
1223            "agent": "rg@host"
1224        }))
1225        .unwrap_err();
1226        assert_eq!(err.code, INVALID_PARAMS);
1227        assert_eq!(err.message, "unsupported protocol version");
1228        assert_eq!(err.data, Some(json!({"supported": 1})));
1229    }
1230
1231    #[test]
1232    fn initialize_version_1_is_accepted() {
1233        let params = parse_initialize_params(&json!({
1234            "protocolVersion": 1,
1235            "client": "vissue-tui",
1236            "agent": "rg@host"
1237        }))
1238        .unwrap();
1239        assert_eq!(params.protocol_version, 1);
1240        assert_eq!(params.agent, "rg@host");
1241        assert_eq!(params.client, "vissue-tui");
1242    }
1243
1244    #[test]
1245    fn handshake_fields_are_camel_case() {
1246        let params = InitializeParams {
1247            protocol_version: 1,
1248            client: "vissue-tui".into(),
1249            agent: "rg@host".into(),
1250        };
1251        let value = serde_json::to_value(&params).unwrap();
1252        assert_eq!(value["protocolVersion"], 1);
1253        assert!(value.get("protocol_version").is_none());
1254
1255        let result = InitializeResult {
1256            protocol_version: 1,
1257            capabilities: vec!["issue/list".into()],
1258            root: "/tmp/tracker".into(),
1259            prefix: "Software".into(),
1260            generation: 3,
1261            revision: 1,
1262            identity: "rg@host".into(),
1263        };
1264        let value = serde_json::to_value(&result).unwrap();
1265        assert_eq!(value["protocolVersion"], 1);
1266        assert_eq!(value["generation"], 3);
1267    }
1268
1269    #[test]
1270    fn issue_payloads_are_snake_case() {
1271        let params = IssueListParams {
1272            since_revision: Some(41),
1273            ..IssueListParams::default()
1274        };
1275        let value = serde_json::to_value(&params).unwrap();
1276        assert_eq!(value["since_revision"], 41);
1277        assert!(value.get("sinceRevision").is_none());
1278    }
1279
1280    #[test]
1281    fn unknown_method_is_not_found() {
1282        let err = Method::parse("issue/fold").unwrap_err();
1283        assert_eq!(err.code, METHOD_NOT_FOUND);
1284        assert_eq!(err.data, Some(json!({"method": "issue/fold"})));
1285    }
1286
1287    #[test]
1288    fn every_v1_capability_parses() {
1289        for name in V1_CAPABILITIES {
1290            assert!(Method::parse(name).is_ok(), "{name}");
1291        }
1292        assert_eq!(Method::Initialize.as_str(), "initialize");
1293    }
1294
1295    #[test]
1296    fn request_parse_roundtrips_issue_get() {
1297        let req = Request::parse("issue/get", Some(json!({"id": "atlas-1a2b"}))).unwrap();
1298        assert_eq!(req.method(), Method::IssueGet);
1299        assert_eq!(req.to_params()["id"], "atlas-1a2b");
1300    }
1301
1302    #[test]
1303    fn missing_id_on_issue_get_is_invalid_params() {
1304        let err = Request::parse("issue/get", Some(json!({}))).unwrap_err();
1305        assert_eq!(err.code, INVALID_PARAMS);
1306    }
1307
1308    #[test]
1309    fn core_errors_carry_data_code() {
1310        let err = error_from_core(&CoreError::IssueNotFound {
1311            id: "atlas-1a2b".into(),
1312        });
1313        assert_eq!(err.code, NOT_FOUND);
1314        assert_eq!(err.data.unwrap()["code"], "not_found");
1315
1316        let err = error_from_core(&CoreError::ClaimConflict {
1317            id: "atlas-1a2b".into(),
1318            holder: "other".into(),
1319            claimed_at: None,
1320        });
1321        assert_eq!(err.code, CONFLICT);
1322        let data = err.data.unwrap();
1323        assert_eq!(data["code"], "conflict");
1324        assert_eq!(data["holder"], "other");
1325
1326        let err = error_from_core(&CoreError::BlockerCycle {
1327            blocker: "a".into(),
1328            issue: "b".into(),
1329        });
1330        assert_eq!(err.code, CYCLE);
1331        let data = err.data.unwrap();
1332        assert_eq!(data["code"], "cycle");
1333        assert_eq!(data["id"], "b");
1334        assert_eq!(data["block"], "a");
1335
1336        let err = error_from_core(&CoreError::InvalidState {
1337            id: "atlas-4g5h".into(),
1338            state: "DONE".into(),
1339        });
1340        assert_eq!(err.code, INVALID_STATE);
1341        assert_eq!(err.data.unwrap()["code"], "invalid_state");
1342
1343        let err = error_from_core(&CoreError::DuplicateId {
1344            id: "atlas-1a2b".into(),
1345            paths: vec![
1346                std::path::PathBuf::from("/a/issues.org"),
1347                std::path::PathBuf::from("/b/issues.org"),
1348            ],
1349        });
1350        assert_eq!(err.code, CONFLICT);
1351        assert_eq!(err.data.unwrap()["code"], "duplicate_id");
1352    }
1353
1354    #[test]
1355    fn notification_parse_known_methods() {
1356        let n = Notification::parse(
1357            NOTIFY_VAULT_CHANGED,
1358            json!({"generation": 1, "revision": 2, "projects": ["atlas"]}),
1359        );
1360        assert!(matches!(n, Notification::VaultChanged(_)));
1361        assert_eq!(n.method(), NOTIFY_VAULT_CHANGED);
1362
1363        let n = Notification::parse(
1364            NOTIFY_ISSUE_SELECTED,
1365            json!({"id": "atlas-1a2b", "project": "atlas"}),
1366        );
1367        assert!(matches!(n, Notification::IssueSelected(_)));
1368
1369        let n = Notification::parse(NOTIFY_SHUTTING_DOWN, json!({}));
1370        assert!(matches!(n, Notification::ServeShuttingDown));
1371        assert_eq!(n.to_params(), json!({}));
1372    }
1373
1374    #[test]
1375    fn list_unchanged_deserializes_without_rows() {
1376        let page: IssueListResult =
1377            serde_json::from_value(json!({"unchanged": true, "revision": 41})).unwrap();
1378        assert!(page.unchanged);
1379        assert!(page.issues.is_empty());
1380        assert_eq!(page.revision, 41);
1381    }
1382
1383    #[test]
1384    fn response_to_value_serializes_initialize() {
1385        let resp = Response::Initialize(InitializeResult {
1386            protocol_version: 1,
1387            capabilities: V1_CAPABILITIES.iter().map(|s| (*s).to_string()).collect(),
1388            root: "/tmp".into(),
1389            prefix: "Software".into(),
1390            generation: 1,
1391            revision: 1,
1392            identity: "agent".into(),
1393        });
1394        let value = resp.to_value().unwrap();
1395        assert_eq!(value["protocolVersion"], 1);
1396        assert!(
1397            value["capabilities"]
1398                .as_array()
1399                .unwrap()
1400                .contains(&json!("issue/list"))
1401        );
1402    }
1403
1404    #[test]
1405    fn envelope_helpers_roundtrip() {
1406        let req = JsonRpcRequest::call(JsonRpcId::Number(1), "identity/get", json!({}));
1407        let bytes = serde_json::to_vec(&req).unwrap();
1408        let back: JsonRpcRequest = serde_json::from_slice(&bytes).unwrap();
1409        assert_eq!(back.method, "identity/get");
1410        assert!(!back.is_notification());
1411
1412        let note = JsonRpcRequest::notification(NOTIFY_SHUTTING_DOWN, json!({}));
1413        assert!(note.is_notification());
1414
1415        let ok = JsonRpcResponse::ok(Some(JsonRpcId::Number(1)), json!({"ok": true}));
1416        assert_eq!(ok.result.unwrap()["ok"], true);
1417        let err = JsonRpcResponse::err(Some(JsonRpcId::Null), parse_error());
1418        assert_eq!(err.error.unwrap().code, PARSE_ERROR);
1419    }
1420
1421    #[test]
1422    fn mut_and_walk_params_decode() {
1423        let claim = Request::parse(
1424            "issue/claim",
1425            Some(json!({"id": "atlas-1a2b", "force": true})),
1426        )
1427        .unwrap();
1428        match claim {
1429            Request::IssueClaim(p) => {
1430                assert!(p.force);
1431                assert_eq!(p.id, "atlas-1a2b");
1432            }
1433            other => panic!("{other:?}"),
1434        }
1435        let create = Request::parse(
1436            "issue/create",
1437            Some(json!({"project": "atlas", "title": "x"})),
1438        )
1439        .unwrap();
1440        assert_eq!(create.method(), Method::IssueCreate);
1441        assert!(Request::parse("issue/create", Some(json!({"title": "x"}))).is_err());
1442        assert_eq!(
1443            Request::parse("events/gen", None).unwrap().method(),
1444            Method::EventsGen
1445        );
1446        let _ = Request::IssueNote(NoteParams {
1447            id: "a".into(),
1448            text: "n".into(),
1449        })
1450        .to_params();
1451        let _ = Request::IssueRefile(RefileParams {
1452            id: "a".into(),
1453            to: "b".into(),
1454        })
1455        .to_params();
1456        let _ = Request::IssueUpdate(UpdateParams {
1457            id: "a".into(),
1458            state: Some("STARTED".into()),
1459            priority: None,
1460            block: None,
1461            unblock: None,
1462            if_state: None,
1463            if_gen: None,
1464            agent: None,
1465        })
1466        .to_params();
1467        let _ = Request::EventsSince(EventsSinceParams {
1468            since: 0,
1469            limit: Some(10),
1470        })
1471        .to_params();
1472        let _ = Request::IssueTree(TreeParams {
1473            id: "a".into(),
1474            format: Some("ascii".into()),
1475        })
1476        .to_params();
1477        let _ = Request::IssueRelated(RelatedParams {
1478            id: "a".into(),
1479            depth: Some(2),
1480            limit: Some(20),
1481        })
1482        .to_params();
1483        let _ = Request::IssueChildren(WalkParams {
1484            id: "a".into(),
1485            depth: None,
1486        })
1487        .to_params();
1488        let _ = Request::IssueSearch(SearchParams {
1489            query: "q".into(),
1490            limit: None,
1491        })
1492        .to_params();
1493        let _ = Request::IssueClaims(ClaimsParams::default()).to_params();
1494        let _ = Request::IssueAgenda(AgendaParams::default()).to_params();
1495        let _ = Request::IdentityGet.to_params();
1496    }
1497
1498    #[test]
1499    fn response_variants_serialize() {
1500        let detail = IssueDetail {
1501            id: "atlas-1a2b".into(),
1502            project: "atlas".into(),
1503            title: "t".into(),
1504            state: "TODO".into(),
1505            priority: "B".into(),
1506            properties: BTreeMap::new(),
1507            org_tags: vec![],
1508            tags: vec![],
1509            blocked_by: vec![],
1510            parent: None,
1511            claimed_by: None,
1512            claimed_at: None,
1513            file: "issues.org:1-2".into(),
1514            line_start: 1,
1515            line_end: 2,
1516            body: "what the issue asks for".into(),
1517            logbook: vec![],
1518        };
1519        let get = IssueGetResult {
1520            issue: detail.clone(),
1521            revision: 1,
1522        };
1523        assert!(Response::IssueGet(get.clone()).to_value().unwrap()["id"] == "atlas-1a2b");
1524        assert!(Response::IssueShow(get.clone()).to_value().is_ok());
1525        assert!(Response::IssueOpen(get).to_value().is_ok());
1526        assert!(
1527            Response::IssueExcerpt(Excerpt {
1528                id: "atlas-1a2b".into(),
1529                file: "issues.org".into(),
1530                line_start: 1,
1531                line_end: 2,
1532                text: "body".into(),
1533                suppressed: false,
1534            })
1535            .to_value()
1536            .is_ok()
1537        );
1538        assert!(Response::IssueSearch(vec![]).to_value().unwrap().is_array());
1539        assert!(Response::IssueClaims(vec![]).to_value().unwrap().is_array());
1540        assert!(Response::IssueAgenda(vec![]).to_value().unwrap().is_array());
1541        assert!(
1542            Response::IssueRelated(vec![])
1543                .to_value()
1544                .unwrap()
1545                .is_array()
1546        );
1547        assert!(
1548            Response::IssueChildren(vec![])
1549                .to_value()
1550                .unwrap()
1551                .is_array()
1552        );
1553        assert!(
1554            Response::IssueAncestors(vec![])
1555                .to_value()
1556                .unwrap()
1557                .is_array()
1558        );
1559        assert!(Response::IssueImpact(vec![]).to_value().unwrap().is_array());
1560        assert!(
1561            Response::IssueBacklinks(vec![])
1562                .to_value()
1563                .unwrap()
1564                .is_array()
1565        );
1566        assert!(
1567            Response::ProjectList(ProjectListResult {
1568                projects: vec!["atlas".into()],
1569                revision: 1,
1570            })
1571            .to_value()
1572            .is_ok()
1573        );
1574        assert!(
1575            Response::EventsGen(EventsGenResult {
1576                generation: 1,
1577                revision: 1,
1578            })
1579            .to_value()
1580            .is_ok()
1581        );
1582        assert!(
1583            Response::EventsSince(EventsSinceResult {
1584                events: vec![],
1585                generation: 1,
1586            })
1587            .to_value()
1588            .is_ok()
1589        );
1590        assert!(
1591            Response::IdentityGet(IdentityResult {
1592                identity: "a".into(),
1593                root: "/".into(),
1594                prefix: "Software".into(),
1595                version: "0.2.0".into(),
1596            })
1597            .to_value()
1598            .is_ok()
1599        );
1600        let mut_ok = MutResult {
1601            ok: true,
1602            report: "ok".into(),
1603            issue: Some(detail),
1604            revision: 2,
1605            generation: 3,
1606        };
1607        assert!(Response::IssueClaim(mut_ok.clone()).to_value().is_ok());
1608        assert!(Response::IssueCreate(mut_ok.clone()).to_value().is_ok());
1609        assert!(Response::IssueUpdate(mut_ok.clone()).to_value().is_ok());
1610        assert!(Response::IssueNote(mut_ok.clone()).to_value().is_ok());
1611        assert!(Response::IssueRefile(mut_ok).to_value().is_ok());
1612        assert!(
1613            Response::IssueTree(TreeResult::Text { text: "* a".into() })
1614                .to_value()
1615                .is_ok()
1616        );
1617        assert!(
1618            Response::IssueList(IssueListResult {
1619                revision: 1,
1620                ..IssueListResult::default()
1621            })
1622            .to_value()
1623            .is_ok()
1624        );
1625        assert!(
1626            Response::IssueReady(IssueListResult {
1627                revision: 1,
1628                ..IssueListResult::default()
1629            })
1630            .to_value()
1631            .is_ok()
1632        );
1633    }
1634
1635    #[test]
1636    fn parse_every_method_with_minimal_params() {
1637        let id = json!({"id": "atlas-1a2b"});
1638        for (method, params) in [
1639            ("identity/get", json!({})),
1640            ("issue/list", json!({})),
1641            ("issue/get", id.clone()),
1642            ("issue/ready", json!({})),
1643            ("issue/search", json!({"query": "q"})),
1644            ("issue/claims", json!({})),
1645            ("issue/agenda", json!({})),
1646            ("issue/show", id.clone()),
1647            ("issue/excerpt", id.clone()),
1648            ("issue/tree", id.clone()),
1649            ("issue/related", id.clone()),
1650            ("issue/children", id.clone()),
1651            ("issue/ancestors", id.clone()),
1652            ("issue/impact", id.clone()),
1653            ("issue/backlinks", id.clone()),
1654            ("issue/open", id.clone()),
1655            ("issue/create", json!({"project": "atlas", "title": "t"})),
1656            ("issue/update", id.clone()),
1657            ("issue/claim", id.clone()),
1658            ("issue/note", json!({"id": "atlas-1a2b", "text": "n"})),
1659            ("issue/refile", json!({"id": "atlas-1a2b", "to": "beacon"})),
1660            ("project/list", json!({})),
1661            ("events/since", json!({"since": 0})),
1662            ("events/gen", json!({})),
1663        ] {
1664            let req = Request::parse(method, Some(params)).expect(method);
1665            assert_eq!(req.method().as_str(), method);
1666            let _ = req.to_params();
1667        }
1668    }
1669
1670    #[test]
1671    fn helper_errors_have_stable_codes() {
1672        assert_eq!(invalid_request().code, INVALID_REQUEST);
1673        assert_eq!(internal_error("x").code, INTERNAL_ERROR);
1674        assert_eq!(parse_error().code, PARSE_ERROR);
1675        let err = Error::Rpc(invalid_params("agent is required"));
1676        assert_eq!(err.to_string(), "agent is required");
1677        let _ = Error::Unsupported("unix only");
1678        let _ = Notification::parse("vault/changed", json!(null));
1679        let _ = Notification::parse("issue/selected", json!(null));
1680        let _ = Notification::parse("other/x", json!({"a": 1}));
1681        let n = Notification::Unknown {
1682            method: "x".into(),
1683            params: json!({"a": 1}),
1684        };
1685        assert_eq!(n.to_params()["a"], 1);
1686        assert_eq!(n.method(), "x");
1687    }
1688}