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