1use std::collections::BTreeMap;
2use std::fmt::{self, Write};
3use std::str::FromStr;
4
5use serde::{Deserialize, Serialize};
6
7mod discovery;
8pub use discovery::*;
9
10#[cfg(feature = "rpc")]
11pub mod rpc {
12 use jsonrpsee::core::SubscriptionResult;
13 use jsonrpsee::proc_macros::rpc;
14 use jsonrpsee::types::ErrorObjectOwned;
15
16 use crate::{
17 CommandRequest, CommandResponse, HandshakeResponse, QueryRequest, QueryResponse,
18 WorkspaceEventDto,
19 };
20
21 pub const RPC_NAMESPACE: &str = "moniker";
22
23 #[rpc(server, client, namespace = "moniker")]
24 pub trait DaemonRpc {
25 #[method(name = "handshake")]
26 async fn handshake(&self, client: String) -> Result<HandshakeResponse, ErrorObjectOwned>;
27
28 #[method(name = "query")]
29 async fn query(&self, request: QueryRequest) -> Result<QueryResponse, ErrorObjectOwned>;
30
31 #[method(name = "command")]
32 async fn command(
33 &self,
34 request: CommandRequest,
35 ) -> Result<CommandResponse, ErrorObjectOwned>;
36
37 #[method(name = "shutdown")]
38 async fn shutdown(&self) -> Result<(), ErrorObjectOwned>;
39
40 #[subscription(name = "subscribeEvents" => "events", unsubscribe = "unsubscribeEvents", item = WorkspaceEventDto)]
41 async fn subscribe_events(&self) -> SubscriptionResult;
42 }
43}
44
45#[cfg(feature = "rpc")]
46pub use rpc::*;
47
48pub const PROTOCOL_VERSION: u32 = 1;
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
51#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
52#[serde(tag = "type", rename_all = "snake_case")]
53pub enum ProtocolRequest {
54 Query(Box<QueryRequest>),
55 Command(CommandRequest),
56}
57
58#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[serde(tag = "type", rename_all = "snake_case")]
61pub enum ProtocolResponse {
62 Query(Box<QueryResponse>),
63 Command(CommandResponse),
64 Error(QueryError),
65}
66
67#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69pub struct HandshakeResponse {
70 pub protocol_version: u32,
71 pub daemon_version: String,
72 pub workspace_root: String,
73 pub workspace_roots: Vec<String>,
74 pub capabilities: CapabilitySet,
75}
76
77#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
78#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
79pub struct DaemonWorkspaceConfig {
80 pub roots: Vec<String>,
81 pub project: Option<String>,
82 pub cache_dir: Option<String>,
83 pub live_refresh: Option<String>,
84}
85
86#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88pub struct CapabilitySet {
89 pub queries: Vec<String>,
90 pub commands: Vec<String>,
91 pub events: Vec<String>,
92}
93
94impl Default for CapabilitySet {
95 fn default() -> Self {
96 Self {
97 queries: vec![
98 "workspace.status".to_string(),
99 "tree.children".to_string(),
100 "symbol.search".to_string(),
101 "symbol.insights".to_string(),
102 "symbol.detail".to_string(),
103 "symbol.usages".to_string(),
104 "view.read".to_string(),
105 "rules.list".to_string(),
106 "rules.check".to_string(),
107 "change.review".to_string(),
108 "symbol.graph".to_string(),
109 "identity.children".to_string(),
110 "identity.graph".to_string(),
111 "resolution.audit".to_string(),
112 "notes".to_string(),
113 ],
114 commands: vec!["workspace.refresh".to_string()],
115 events: Vec::new(),
116 }
117 }
118}
119
120#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122pub struct QueryRequest {
123 pub query: Query,
124 pub consistency: Consistency,
125 pub page: Page,
126}
127
128impl QueryRequest {
129 pub fn new(query: Query) -> Self {
130 Self {
131 query,
132 consistency: Consistency::Current,
133 page: Page::default(),
134 }
135 }
136}
137
138#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
140#[serde(tag = "op", rename_all = "snake_case")]
141pub enum Query {
142 WorkspaceStatus,
143 TreeChildren(TreeChildrenQuery),
144 SymbolSearch(SymbolSearchQuery),
145 SymbolInsights(SymbolSearchQuery),
146 SymbolDetail(SymbolDetailQuery),
147 SymbolUsages(SymbolUsagesQuery),
148 ViewRead(ViewReadQuery),
149 RulesList(RulesListQuery),
150 RulesCheck(RulesCheckQuery),
151 ChangeReview(ChangeReviewQuery),
152 SymbolGraph(SymbolGraphQuery),
153 IdentityChildren(IdentityChildrenQuery),
154 IdentityGraph(IdentityChildrenQuery),
155 ResolutionAudit(ResolutionAuditQuery),
156 Notes(NotesQuery),
157}
158
159impl Query {
160 pub fn capability(&self) -> &'static str {
161 match self {
162 Self::WorkspaceStatus => "workspace.status",
163 Self::TreeChildren(_) => "tree.children",
164 Self::SymbolSearch(_) => "symbol.search",
165 Self::SymbolInsights(_) => "symbol.insights",
166 Self::SymbolDetail(_) => "symbol.detail",
167 Self::SymbolUsages(_) => "symbol.usages",
168 Self::ViewRead(_) => "view.read",
169 Self::RulesList(_) => "rules.list",
170 Self::RulesCheck(_) => "rules.check",
171 Self::ChangeReview(_) => "change.review",
172 Self::SymbolGraph(_) => "symbol.graph",
173 Self::IdentityChildren(_) => "identity.children",
174 Self::IdentityGraph(_) => "identity.graph",
175 Self::ResolutionAudit(_) => "resolution.audit",
176 Self::Notes(_) => "notes",
177 }
178 }
179}
180
181#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
183pub struct TreeChildrenQuery {
184 pub workspace: Option<String>,
185 pub path: Vec<String>,
186 pub depth: usize,
187 pub lang: Vec<String>,
188 pub projection: Vec<String>,
189}
190
191#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
193pub struct SymbolSearchQuery {
194 pub workspace: Option<String>,
195 pub text: Option<String>,
196 pub path: Vec<String>,
197 pub lang: Vec<String>,
198 pub kind: Vec<String>,
199 pub shape: Vec<String>,
200 pub name: Option<String>,
201 pub include_non_navigable: bool,
202 pub include_code: bool,
203 pub context_lines: usize,
204 pub projection: Vec<String>,
205}
206
207#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
208#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
209pub struct SymbolDetailQuery {
210 pub workspace: Option<String>,
211 pub uri: String,
212 pub context_lines: usize,
213}
214
215#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
216#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
217pub struct SymbolUsagesQuery {
218 pub workspace: Option<String>,
219 pub uri: String,
220 pub direction: UsageDirection,
221 pub path: Vec<String>,
222 pub lang: Vec<String>,
223 pub projection: Vec<String>,
224}
225
226#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
227#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
228pub struct ViewReadQuery {
229 pub uri: String,
230 pub scheme: Option<String>,
231 pub context_lines: usize,
232 pub include_code: bool,
233}
234
235#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
236#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
237pub struct ResolutionAuditQuery {
238 pub workspace: Option<String>,
239 pub prefix: String,
240 pub limit: usize,
241}
242
243#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
244#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
245#[serde(rename_all = "snake_case")]
246pub enum UsageDirection {
247 #[default]
248 Incoming,
249 Outgoing,
250 Both,
251}
252
253impl UsageDirection {
254 pub fn as_str(self) -> &'static str {
255 match self {
256 Self::Incoming => "incoming",
257 Self::Outgoing => "outgoing",
258 Self::Both => "both",
259 }
260 }
261}
262
263impl FromStr for UsageDirection {
264 type Err = QueryParseError;
265
266 fn from_str(value: &str) -> Result<Self, Self::Err> {
267 match value {
268 "incoming" => Ok(Self::Incoming),
269 "outgoing" => Ok(Self::Outgoing),
270 "both" => Ok(Self::Both),
271 _ => Err(QueryParseError::InvalidValue {
272 key: "direction".to_string(),
273 value: value.to_string(),
274 }),
275 }
276 }
277}
278
279#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
280#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
281pub struct RulesListQuery {
282 pub workspace: Option<String>,
283 pub profile: Option<String>,
284 pub rules: Option<String>,
285 pub lang: Vec<String>,
286 pub severity: Vec<String>,
287}
288
289#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
290#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
291pub struct RulesCheckQuery {
292 pub workspace: Option<String>,
293 pub profile: Option<String>,
294 pub rules: Option<String>,
295 pub file: Vec<String>,
296 pub report: bool,
297}
298
299#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
300#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
301pub struct ChangeReviewQuery {
302 pub workspace: Option<String>,
303}
304
305#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
306#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
307pub struct SymbolGraphQuery {
308 pub workspace: Option<String>,
309 pub focus: String,
310}
311
312#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
316#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
317pub struct IdentityChildrenQuery {
318 pub workspace: Option<String>,
319 pub prefix: String,
320}
321
322#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
323#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
324pub struct NotesQuery {
325 pub action: NotesAction,
326 pub id: Option<String>,
327 pub moniker: Option<String>,
328 pub kind: Option<String>,
329 pub status: Option<String>,
330 pub title: Option<String>,
331 pub body: Option<String>,
332 pub created_by: Option<String>,
333 pub orphan: Option<bool>,
334 pub include_done: bool,
335}
336
337#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
338#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
339#[serde(rename_all = "snake_case")]
340pub enum NotesAction {
341 #[default]
342 List,
343 Get,
344 Create,
345 Update,
346 Transition,
347 Delete,
348}
349
350#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
351#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
352pub struct CommandRequest {
353 pub command: Command,
354}
355
356#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
357#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
358#[serde(tag = "op", rename_all = "snake_case")]
359pub enum Command {
360 WorkspaceRefresh,
361}
362
363#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
364#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
365#[serde(rename_all = "snake_case")]
366pub enum Consistency {
367 #[default]
368 Current,
369 RefreshIfStale,
370 StaleOk,
371}
372
373impl FromStr for Consistency {
374 type Err = QueryParseError;
375
376 fn from_str(value: &str) -> Result<Self, Self::Err> {
377 match value {
378 "current" => Ok(Self::Current),
379 "refresh-if-stale" => Ok(Self::RefreshIfStale),
380 "stale-ok" => Ok(Self::StaleOk),
381 _ => Err(QueryParseError::InvalidValue {
382 key: "consistency".to_string(),
383 value: value.to_string(),
384 }),
385 }
386 }
387}
388
389#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
390#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
391pub struct Page {
392 pub cursor: Option<QueryCursor>,
393 pub limit: usize,
394}
395
396impl Default for Page {
397 fn default() -> Self {
398 Self {
399 cursor: None,
400 limit: 80,
401 }
402 }
403}
404
405#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
406#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
407pub struct QueryCursor {
408 pub offset: usize,
409 pub generation: Option<WorkspaceGeneration>,
410}
411
412impl QueryCursor {
413 pub fn new(offset: usize, generation: Option<WorkspaceGeneration>) -> Self {
414 Self { offset, generation }
415 }
416}
417
418#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
419#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
420pub struct WorkspaceGeneration(pub u64);
421
422#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
424#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
425pub struct WorkspaceEventDto {
426 pub kind: WorkspaceEventKind,
427 pub generation: Option<WorkspaceGeneration>,
428 pub stale_summary: Option<String>,
429}
430
431#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
432#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
433#[serde(rename_all = "snake_case")]
434pub enum WorkspaceEventKind {
435 Stale,
436 Refreshed,
437 Notes,
438 GitBase,
439}
440
441#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
442#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
443pub struct QueryResponse {
444 pub generation: Option<WorkspaceGeneration>,
445 pub result: QueryResult,
446 pub next_cursor: Option<QueryCursor>,
447}
448
449#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
450#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
451#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
452pub enum QueryResult {
453 WorkspaceStatus(WorkspaceStatus),
454 TreeChildren(TreeChildrenResult),
455 SymbolList(SymbolListResult),
456 SymbolInsights(SymbolInsightsResult),
457 SymbolDetail(SymbolDetailResult),
458 SymbolUsages(Box<SymbolUsagesResult>),
459 ViewRead(ViewReadResult),
460 RulesList(RulesListResult),
461 RulesCheck(RulesCheckResult),
462 ChangeReview(Box<ChangeReviewResult>),
463 SymbolGraph(Box<SymbolGraphResult>),
464 IdentityChildren(IdentityChildrenResult),
465 IdentityGraph(Box<IdentityGraphResult>),
466 ResolutionAudit(Box<ResolutionAuditResult>),
467 Notes(NotesResult),
468}
469
470#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
475#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
476pub struct UnlinkedRefsDto {
477 pub external: usize,
478 pub manifest_blocked: usize,
479 pub unresolved: usize,
480 pub unresolved_reasons: BTreeMap<String, usize>,
481}
482
483#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
484#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
485pub struct SymbolGraphResult {
486 pub focus: SymbolGraphFocus,
487 pub members: Vec<SymbolDto>,
488 pub internal_edges: Vec<SymbolGraphEdge>,
489 pub callers: Vec<SymbolGraphNeighbor>,
490 pub callees: Vec<SymbolGraphNeighbor>,
491 pub unlinked: UnlinkedRefsDto,
492}
493
494#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
495#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
496#[serde(tag = "kind", rename_all = "snake_case")]
497pub enum SymbolGraphFocus {
498 Symbol { symbol: Box<SymbolDto> },
499 File { path: String },
500}
501
502#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
503#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
504pub struct SymbolGraphNeighbor {
505 pub symbol: SymbolDto,
506 pub kinds: Vec<String>,
507 pub count: usize,
508}
509
510#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
511#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
512pub struct SymbolGraphEdge {
513 pub source: String,
514 pub target: String,
515 pub kinds: Vec<String>,
516 pub count: usize,
517}
518
519#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
520#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
521pub struct IdentityChildrenResult {
522 pub prefix: String,
523 pub children: Vec<IdentitySegmentDto>,
524}
525
526#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
530#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
531pub struct IdentitySegmentDto {
532 pub segment: String,
533 pub kind: String,
534 pub name: String,
535 pub identity: String,
536 pub defs: usize,
537 pub has_children: bool,
538 pub symbol: Option<Box<SymbolDto>>,
539}
540
541#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
546#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
547pub struct ResolutionAuditResult {
548 pub prefix: String,
549 pub totals: AuditTotalsDto,
550 pub clusters: Vec<AuditClusterDto>,
551 pub zones: Vec<AuditZoneDto>,
552}
553
554#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
555#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
556pub struct AuditTotalsDto {
557 pub references: usize,
558 pub resolved: usize,
559 pub external: usize,
560 pub blocked: usize,
561 pub unresolved: usize,
562 pub name_match_resolved: usize,
563}
564
565#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
566#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
567pub struct AuditClusterDto {
568 pub pattern: String,
569 pub count: usize,
570 pub samples: Vec<AuditSampleDto>,
571}
572
573#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
574#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
575pub struct AuditSampleDto {
576 pub source: String,
577 pub call_name: String,
578 pub receiver: String,
579 pub target: String,
580}
581
582#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
583#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
584pub struct AuditZoneDto {
585 pub zone: String,
586 pub unresolved: usize,
587 pub dominant_pattern: String,
588}
589
590#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
595#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
596pub struct IdentityGraphResult {
597 pub prefix: String,
598 pub nodes: Vec<IdentitySegmentDto>,
599 pub edges: Vec<IdentityGraphEdge>,
600 pub ports_in: Vec<IdentityGraphPort>,
601 pub ports_out: Vec<IdentityGraphPort>,
602 pub unlinked: UnlinkedRefsDto,
603}
604
605#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
607#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
608pub struct IdentityGraphEdge {
609 pub source: String,
610 pub target: String,
611 pub kinds: Vec<String>,
612 pub count: usize,
613}
614
615#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
618#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
619pub struct IdentityGraphPort {
620 pub identity: String,
621 pub kinds: Vec<String>,
622 pub count: usize,
623}
624
625#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
626#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
627pub struct ChangeReviewResult {
628 pub scope: String,
629 pub summary: ChangeReviewSummary,
630 pub files: Vec<ChangeReviewFile>,
631 pub symbol_changes: Vec<ChangeReviewSymbol>,
632 pub ref_changes: Vec<ChangeReviewRef>,
633 pub diagnostics: Vec<String>,
634}
635
636#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
637#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
638pub struct ChangeReviewSummary {
639 pub files: usize,
640 pub analyzable_files: usize,
641 pub symbol_changes: usize,
642 pub ref_changes: usize,
643 pub retargeted_refs: usize,
644 pub residual_files: usize,
645}
646
647#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
648#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
649pub struct ChangeReviewFile {
650 pub old_path: Option<String>,
651 pub new_path: Option<String>,
652 pub disposition: String,
653 pub analyzable: bool,
654 pub symbol_changes: usize,
655 pub moved_symbols: usize,
656 pub coverage_explained: bool,
657 pub old_residual: Vec<(u32, u32)>,
658 pub new_residual: Vec<(u32, u32)>,
659}
660
661#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
662#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
663pub struct ChangeReviewSymbol {
664 pub kind: String,
665 pub confidence: String,
666 pub body_changed: bool,
667 pub signature_changed: bool,
668 pub visibility_changed: bool,
669 pub header_changed: bool,
670 pub file_moved: bool,
671 pub old: Option<ChangeReviewSide>,
672 pub new: Option<ChangeReviewSide>,
673}
674
675#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
677pub struct ChangeReviewSide {
678 pub identity: String,
679 pub file: String,
680 pub kind: String,
681 pub name: String,
682 pub visibility: String,
683 pub lines: Option<(u32, u32)>,
684}
685
686#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
687#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
688pub struct ChangeReviewRef {
689 pub kind: String,
690 pub file: String,
691 pub ref_kind: String,
692 pub old_target: Option<String>,
693 pub new_target: Option<String>,
694 pub old_lines: Option<(u32, u32)>,
695 pub new_lines: Option<(u32, u32)>,
696}
697
698#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
699#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
700pub struct CommandResponse {
701 pub generation: Option<WorkspaceGeneration>,
702 pub message: String,
703 pub status: Option<WorkspaceStatus>,
704}
705
706#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
707#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
708#[serde(tag = "kind", rename_all = "snake_case")]
709pub enum ViewReadResult {
710 List(ViewListResult),
711 Detail(Box<ViewDetailResult>),
712}
713
714#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
716pub struct ViewListResult {
717 pub views: Vec<ViewSummaryDto>,
718}
719
720#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
721#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
722pub struct ViewSummaryDto {
723 pub id: String,
724 pub title: Option<String>,
725 pub fragment: String,
726 pub anchor: String,
727 pub scope: String,
728}
729
730#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
731#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
732pub struct ViewDetailResult {
733 pub id: String,
734 pub title: Option<String>,
735 pub fragment: String,
736 pub anchor: String,
737 pub scope: String,
738 pub intent: Option<String>,
739 pub summary: Option<String>,
740 pub rules: Vec<ViewRuleDto>,
741 pub boundaries: Vec<ViewBoundaryDto>,
742 pub gotchas: Vec<ViewGotchaDto>,
743}
744
745#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
746#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
747pub struct ViewRuleDto {
748 pub id: String,
749 pub severity: String,
750 pub domain: String,
751 pub rationale: Option<String>,
752}
753
754#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
755#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
756pub struct ViewRuleRefDto {
757 pub id: String,
758 pub present: bool,
759}
760
761#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
762#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
763pub struct ViewBoundaryDto {
764 pub id: String,
765 pub owns: Vec<String>,
766 pub forbids: Vec<String>,
767 pub forbid_rules: Vec<String>,
768 pub rationale: Option<String>,
769 pub rule_refs: Vec<ViewRuleRefDto>,
770 pub evidence: Vec<ViewEvidenceDto>,
771 pub missing: Vec<String>,
772}
773
774#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
775#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
776pub struct ViewGotchaDto {
777 pub id: String,
778 pub rationale: String,
779 pub check: Option<String>,
780 pub rule_refs: Vec<ViewRuleRefDto>,
781 pub evidence: Vec<ViewEvidenceDto>,
782 pub missing: Vec<String>,
783}
784
785#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
786#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
787pub struct ViewEvidenceDto {
788 pub selector: String,
789 pub label: String,
790 pub moniker: String,
791 pub file: String,
792 pub slice: Option<(u32, u32)>,
793 pub active_slice: Option<(u32, u32)>,
794 pub code: Vec<SourceLine>,
795}
796
797#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
798#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
799pub struct WorkspaceStatus {
800 pub root: String,
801 pub phase: String,
802 pub roots: Vec<WorkspaceRootStatus>,
803 pub generation: Option<WorkspaceGeneration>,
804 pub files: usize,
805 pub symbols: usize,
806 pub references: usize,
807 pub stale: bool,
808 pub stale_summary: String,
809}
810
811#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
812#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
813pub struct WorkspaceRootStatus {
814 pub root: String,
815 pub generation: Option<WorkspaceGeneration>,
816 pub files: usize,
817 pub symbols: usize,
818 pub references: usize,
819 pub stale: bool,
820 pub stale_summary: String,
821}
822
823#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
824#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
825pub struct TreeChildrenResult {
826 pub root: String,
827 pub roots: Vec<String>,
828 pub rows: Vec<TreeNode>,
829 pub total: usize,
830 pub total_files: usize,
831 pub scoped_files: usize,
832 pub languages: Vec<CountDto>,
833 pub prefixes: Vec<CountDto>,
834}
835
836#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
837#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
838pub struct TreeNode {
839 pub root: String,
840 pub path: String,
841 pub kind: TreeNodeKind,
842 pub language: Option<String>,
843 pub defs: usize,
844 pub refs: usize,
845 pub change_count: usize,
846}
847
848#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
849#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
850#[serde(rename_all = "snake_case")]
851pub enum TreeNodeKind {
852 File,
853 Directory,
854}
855
856#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
857#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
858pub struct SymbolListResult {
859 pub rows: Vec<SymbolDto>,
860 pub total: usize,
861}
862
863#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
864#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
865pub struct SymbolDto {
866 pub root: String,
867 pub uri: String,
868 pub id: String,
869 pub name: String,
870 pub kind: String,
871 pub visibility: String,
872 pub signature: String,
873 pub file: String,
874 pub language: String,
875 pub line_range: Option<(u32, u32)>,
876 pub navigable: bool,
877 pub score: Option<u32>,
878 pub match_reason: Option<String>,
879 pub source: Option<SourceSnippet>,
880}
881
882#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
883#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
884pub struct SymbolInsightsResult {
885 pub files: usize,
886 pub symbols: usize,
887 pub references: usize,
888 pub navigable_symbols: usize,
889 pub non_navigable_symbols: usize,
890 pub languages: Vec<CountDto>,
891 pub kinds: Vec<CountDto>,
892 pub shapes: Vec<CountDto>,
893 pub top_files_by_symbols: Vec<CountDto>,
894 pub top_files_by_refs: Vec<CountDto>,
895}
896
897#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
898#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
899pub struct SymbolDetailResult {
900 pub symbol: SymbolDto,
901 pub source: Option<SourceSnippet>,
902}
903
904#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
905#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
906pub struct SourceSnippet {
907 pub file: String,
908 pub first_line: u32,
909 pub last_line: u32,
910 pub lines: Vec<SourceLine>,
911}
912
913#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
914#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
915pub struct SourceLine {
916 pub number: u32,
917 pub text: String,
918}
919
920#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
921#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
922pub struct SymbolUsagesResult {
923 pub target: SymbolDto,
924 pub direction: UsageDirection,
925 pub rows: Vec<UsageDto>,
926 pub total: usize,
927 pub incoming_summary: Option<UsageSummaryDto>,
928 pub outgoing_summary: Option<UsageSummaryDto>,
929}
930
931#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
932#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
933pub struct UsageSummaryDto {
934 pub refs: usize,
935 pub files: usize,
936 pub contexts: usize,
937 pub prefixes: usize,
938 pub dominant_prefix: String,
939 pub kinds: Vec<CountDto>,
940 pub top_actors: Vec<CountDto>,
941 pub top_prefixes: Vec<CountDto>,
942 pub shared_helper_signal: String,
943}
944
945#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
946#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
947pub struct UsageDto {
948 pub root: String,
949 pub direction: UsageDirection,
950 pub reference: String,
951 pub kind: String,
952 pub actor: String,
953 pub context: String,
954 pub endpoint: String,
955 pub file: String,
956 pub prefix: String,
957 pub location: String,
958 pub line_range: Option<(u32, u32)>,
959 pub via: Option<String>,
960}
961
962#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
963#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
964pub struct RulesListResult {
965 pub roots: Vec<String>,
966 pub rows: Vec<RuleDto>,
967 pub total: usize,
968}
969
970#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
971#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
972pub struct RuleDto {
973 pub root: String,
974 pub id: String,
975 pub severity: String,
976 pub lang: String,
977 pub domain: String,
978 pub kind: Option<String>,
979 pub expr: String,
980 pub expanded_expr: String,
981 pub message: Option<String>,
982 pub rationale: Option<String>,
983 pub require_doc_comment: Option<String>,
984}
985
986#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
987#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
988pub struct RulesCheckResult {
989 pub exit: String,
990 pub summary: CheckSummaryDto,
991 pub roots: Vec<RulesCheckRootResult>,
992 pub violations: Vec<ViolationDto>,
993 pub errors: Vec<FileErrorDto>,
994 pub rule_reports: Vec<RuleReportDto>,
995 pub skip_reasons: Vec<CheckSkipReasonDto>,
996}
997
998#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
999#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1000pub struct RulesCheckRootResult {
1001 pub root: String,
1002 pub exit: String,
1003 pub summary: CheckSummaryDto,
1004 pub violations: Vec<ViolationDto>,
1005 pub errors: Vec<FileErrorDto>,
1006 pub rule_reports: Vec<RuleReportDto>,
1007 pub skip_reason: Option<CheckSkipReasonDto>,
1008}
1009
1010#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
1011#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1012pub struct CheckSummaryDto {
1013 pub files_scanned: usize,
1014 pub files_with_violations: usize,
1015 pub total_violations: usize,
1016 pub total_rule_errors: usize,
1017 pub total_warnings: usize,
1018 pub files_with_errors: usize,
1019 pub total_errors: usize,
1020 pub elapsed_ms: u64,
1021 pub failed_rules: Vec<FailedRuleDto>,
1022}
1023
1024#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1025#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1026pub struct FailedRuleDto {
1027 pub rule_id: String,
1028 pub severity: String,
1029 pub violations: usize,
1030}
1031
1032#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1033#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1034pub struct ViolationDto {
1035 pub root: String,
1036 pub path: String,
1037 pub rule_id: String,
1038 pub severity: String,
1039 pub moniker: String,
1040 pub kind: String,
1041 pub lines: (u32, u32),
1042 pub message: String,
1043}
1044
1045#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1046#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1047pub struct FileErrorDto {
1048 pub root: String,
1049 pub path: String,
1050 pub error: String,
1051}
1052
1053#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1054#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1055pub struct RuleReportDto {
1056 pub root: String,
1057 pub path: Option<String>,
1058 pub rule_id: String,
1059 pub severity: String,
1060 pub domain: String,
1061 pub evaluated: usize,
1062 pub matches: usize,
1063 pub violations: usize,
1064 pub antecedent_matches: Option<usize>,
1065 pub warning: Option<String>,
1066}
1067
1068#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1069#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1070pub struct CheckSkipReasonDto {
1071 pub root: String,
1072 pub reason: String,
1073}
1074
1075#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1076#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1077pub struct NotesResult {
1078 pub action: String,
1079 pub total: usize,
1080 pub rows: Vec<NoteDto>,
1081 pub deleted: Option<NoteDto>,
1082}
1083
1084#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1085#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1086pub struct NoteDto {
1087 pub id: String,
1088 pub moniker: String,
1089 pub kind: String,
1090 pub status: String,
1091 pub title: String,
1092 pub body: String,
1093 pub created_by: String,
1094 pub updated_at: String,
1095 pub resolution: NoteResolutionDto,
1096}
1097
1098#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1099#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1100#[serde(tag = "status", rename_all = "snake_case")]
1101pub enum NoteResolutionDto {
1102 Resolved {
1103 target: String,
1104 file: String,
1105 slice: Option<(u32, u32)>,
1106 },
1107 Orphan,
1108}
1109
1110#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1112pub struct CountDto {
1113 pub name: String,
1114 pub count: usize,
1115}
1116
1117#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1119#[serde(tag = "event", rename_all = "snake_case")]
1120pub enum DaemonEvent {
1121 WorkspaceStale {
1122 generation: Option<WorkspaceGeneration>,
1123 summary: String,
1124 },
1125 WorkspaceRefreshed {
1126 generation: WorkspaceGeneration,
1127 },
1128}
1129
1130#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1132pub struct QueryError {
1133 pub code: String,
1134 pub message: String,
1135}
1136
1137impl QueryError {
1138 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
1139 Self {
1140 code: code.into(),
1141 message: message.into(),
1142 }
1143 }
1144}
1145
1146impl fmt::Display for QueryError {
1147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1148 write!(f, "{}: {}", self.code, self.message)
1149 }
1150}
1151
1152impl std::error::Error for QueryError {}
1153
1154#[derive(Debug, thiserror::Error)]
1155pub enum QueryParseError {
1156 #[error("empty query")]
1157 Empty,
1158 #[error("unknown query operation `{0}`")]
1159 UnknownOperation(String),
1160 #[error("invalid token `{0}`")]
1161 InvalidToken(String),
1162 #[error("invalid value for `{key}`: `{value}`")]
1163 InvalidValue { key: String, value: String },
1164 #[error("missing required `{0}`")]
1165 MissingRequired(&'static str),
1166 #[error("unknown field `{key}` for `{op}`{hint}")]
1167 UnknownField {
1168 op: String,
1169 key: String,
1170 hint: String,
1171 },
1172 #[error("unexpected argument `{value}` for `{op}`")]
1173 UnexpectedArgument { op: String, value: String },
1174 #[error("`project` is not supported by `{op}`")]
1175 UnsupportedProjection { op: String },
1176}
1177
1178pub fn parse_query(input: &str) -> Result<QueryRequest, QueryParseError> {
1179 let mut lines = input.lines().map(str::trim).filter(|line| !line.is_empty());
1180 let first = lines.next().ok_or(QueryParseError::Empty)?;
1181 let mut tokens = tokenize(first)?;
1182 let op = tokens.first().cloned().ok_or(QueryParseError::Empty)?;
1183 tokens.remove(0);
1184 let mut fields = FieldBag::default();
1185 let mut positional = Vec::new();
1186 collect_tokens(&tokens, &mut fields, &mut positional)?;
1187 for line in lines {
1188 collect_line(line, &mut fields, &mut positional)?;
1189 }
1190 fields.positional = positional;
1191 let spec = verb_spec(&op).ok_or_else(|| QueryParseError::UnknownOperation(op.clone()))?;
1192 validate_fields(&op, &spec, &fields)?;
1193 if let Some(value) = fields.one("consistency") {
1194 fields.consistency = value.parse()?;
1195 }
1196 let page = fields.page()?;
1197 let consistency = fields.consistency;
1198 let query = build_query(&op, fields)?;
1199 Ok(QueryRequest {
1200 query,
1201 consistency,
1202 page,
1203 })
1204}
1205
1206fn collect_line(
1207 line: &str,
1208 fields: &mut FieldBag,
1209 positional: &mut Vec<String>,
1210) -> Result<(), QueryParseError> {
1211 let mut tokens = tokenize(line)?;
1212 if tokens.is_empty() {
1213 return Ok(());
1214 }
1215 let section = tokens.remove(0);
1216 if section.contains(':') {
1217 let mut all = vec![section];
1218 all.extend(tokens);
1219 return collect_tokens(&all, fields, positional);
1220 }
1221 match section.as_str() {
1222 "filter" | "page" => collect_tokens(&tokens, fields, positional)?,
1223 "project" => {
1224 fields.projection.extend(
1225 tokens
1226 .into_iter()
1227 .map(|token| token.trim_end_matches(',').to_string())
1228 .filter(|token| !token.is_empty()),
1229 );
1230 }
1231 "consistency" => {
1232 let value = tokens
1233 .first()
1234 .ok_or(QueryParseError::MissingRequired("consistency value"))?;
1235 fields.consistency = value.parse()?;
1236 }
1237 "direction" => {
1238 let value = tokens
1239 .first()
1240 .ok_or(QueryParseError::MissingRequired("direction value"))?;
1241 fields.values.push(("direction".to_string(), value.clone()));
1242 }
1243 _ => return Err(QueryParseError::InvalidToken(section)),
1244 }
1245 Ok(())
1246}
1247
1248fn build_query(op: &str, fields: FieldBag) -> Result<Query, QueryParseError> {
1249 let query = match op {
1250 "workspace.status" => Query::WorkspaceStatus,
1251 "tree.children" => Query::TreeChildren(TreeChildrenQuery {
1252 workspace: fields.one("workspace"),
1253 path: fields.many("path"),
1254 depth: fields.usize("depth")?.unwrap_or(1),
1255 lang: fields.many("lang"),
1256 projection: fields.projection,
1257 }),
1258 "symbol.search" => Query::SymbolSearch(symbol_search_query(&fields)?),
1259 "symbol.insights" => Query::SymbolInsights(symbol_insights_query(&fields)?),
1260 "symbol.detail" => Query::SymbolDetail(SymbolDetailQuery {
1261 workspace: fields.one("workspace"),
1262 uri: fields
1263 .one("uri")
1264 .or_else(|| fields.positional.first().cloned())
1265 .ok_or(QueryParseError::MissingRequired("uri"))?,
1266 context_lines: fields.usize("context_lines")?.unwrap_or(2),
1267 }),
1268 "symbol.usages" => Query::SymbolUsages(SymbolUsagesQuery {
1269 workspace: fields.one("workspace"),
1270 uri: fields
1271 .one("uri")
1272 .or_else(|| fields.positional.first().cloned())
1273 .ok_or(QueryParseError::MissingRequired("uri"))?,
1274 direction: fields
1275 .one("direction")
1276 .unwrap_or_else(|| "incoming".to_string())
1277 .parse()?,
1278 path: fields.many("path"),
1279 lang: fields.many("lang"),
1280 projection: fields.projection,
1281 }),
1282 "view.read" => Query::ViewRead(ViewReadQuery {
1283 uri: fields
1284 .one("uri")
1285 .or_else(|| fields.positional.first().cloned())
1286 .ok_or(QueryParseError::MissingRequired("uri"))?,
1287 scheme: fields.one("scheme"),
1288 context_lines: fields.usize("context_lines")?.unwrap_or(2),
1289 include_code: fields.bool("include_code")?.unwrap_or(false),
1290 }),
1291 "rules.list" => Query::RulesList(RulesListQuery {
1292 workspace: fields.one("workspace"),
1293 profile: fields.one("profile"),
1294 rules: fields.one("rules"),
1295 lang: fields.many("lang"),
1296 severity: fields.many("severity"),
1297 }),
1298 "rules.check" => Query::RulesCheck(RulesCheckQuery {
1299 workspace: fields.one("workspace"),
1300 profile: fields.one("profile"),
1301 rules: fields.one("rules"),
1302 file: fields.many("file"),
1303 report: fields.bool("report")?.unwrap_or(true),
1304 }),
1305 "change.review" => Query::ChangeReview(ChangeReviewQuery {
1306 workspace: fields.one("workspace"),
1307 }),
1308 "symbol.graph" => Query::SymbolGraph(symbol_graph_query(&fields)?),
1309 "identity.children" => Query::IdentityChildren(identity_children_query(&fields)),
1310 "identity.graph" => Query::IdentityGraph(identity_children_query(&fields)),
1311 "resolution.audit" => Query::ResolutionAudit(ResolutionAuditQuery {
1312 workspace: fields.one("workspace"),
1313 prefix: fields.one("prefix").unwrap_or_default(),
1314 limit: fields
1315 .one("limit")
1316 .and_then(|value| value.parse().ok())
1317 .unwrap_or(20),
1318 }),
1319 "notes" => Query::Notes(notes_query(&fields)?),
1320 _ => return Err(QueryParseError::UnknownOperation(op.to_string())),
1321 };
1322 Ok(query)
1323}
1324
1325struct VerbSpec {
1326 fields: &'static [&'static str],
1327 positionals: usize,
1328 projection: bool,
1329}
1330
1331const COMMON_FIELDS: &[&str] = &["limit", "cursor", "consistency"];
1332const BRACKET_LIST_FIELDS: &[&str] = &["lang", "kind", "shape", "severity"];
1333
1334fn verb_spec(op: &str) -> Option<VerbSpec> {
1335 let spec = match op {
1336 "workspace.status" => VerbSpec {
1337 fields: &[],
1338 positionals: 0,
1339 projection: false,
1340 },
1341 "tree.children" => VerbSpec {
1342 fields: &["workspace", "path", "depth", "lang"],
1343 positionals: 0,
1344 projection: true,
1345 },
1346 "symbol.search" => VerbSpec {
1347 fields: &[
1348 "workspace",
1349 "path",
1350 "lang",
1351 "kind",
1352 "shape",
1353 "name",
1354 "include_non_navigable",
1355 "include_code",
1356 "context_lines",
1357 ],
1358 positionals: 1,
1359 projection: true,
1360 },
1361 "symbol.insights" => VerbSpec {
1362 fields: &[
1363 "workspace",
1364 "path",
1365 "lang",
1366 "kind",
1367 "shape",
1368 "name",
1369 "include_non_navigable",
1370 ],
1371 positionals: 0,
1372 projection: true,
1373 },
1374 "symbol.detail" => VerbSpec {
1375 fields: &["workspace", "uri", "context_lines"],
1376 positionals: 1,
1377 projection: false,
1378 },
1379 "symbol.usages" => VerbSpec {
1380 fields: &["workspace", "uri", "direction", "path", "lang"],
1381 positionals: 1,
1382 projection: true,
1383 },
1384 "view.read" => VerbSpec {
1385 fields: &["uri", "scheme", "context_lines", "include_code"],
1386 positionals: 1,
1387 projection: false,
1388 },
1389 "rules.list" => VerbSpec {
1390 fields: &["workspace", "profile", "rules", "lang", "severity"],
1391 positionals: 0,
1392 projection: false,
1393 },
1394 "rules.check" => VerbSpec {
1395 fields: &["workspace", "profile", "rules", "file", "report"],
1396 positionals: 0,
1397 projection: false,
1398 },
1399 "change.review" => VerbSpec {
1400 fields: &["workspace"],
1401 positionals: 0,
1402 projection: false,
1403 },
1404 "symbol.graph" => VerbSpec {
1405 fields: &["workspace", "focus"],
1406 positionals: 1,
1407 projection: false,
1408 },
1409 "resolution.audit" => VerbSpec {
1410 fields: &["workspace", "prefix", "limit", "consistency"],
1411 positionals: 1,
1412 projection: false,
1413 },
1414 "identity.children" | "identity.graph" => VerbSpec {
1415 fields: &["workspace", "prefix"],
1416 positionals: 1,
1417 projection: false,
1418 },
1419 "notes" => VerbSpec {
1420 fields: &[
1421 "action",
1422 "id",
1423 "moniker",
1424 "kind",
1425 "status",
1426 "title",
1427 "body",
1428 "created_by",
1429 "orphan",
1430 "include_done",
1431 ],
1432 positionals: 0,
1433 projection: false,
1434 },
1435 _ => return None,
1436 };
1437 Some(spec)
1438}
1439
1440fn validate_fields(op: &str, spec: &VerbSpec, fields: &FieldBag) -> Result<(), QueryParseError> {
1441 for (key, value) in &fields.values {
1442 let key = key.as_str();
1443 if !COMMON_FIELDS.contains(&key) && !spec.fields.contains(&key) {
1444 return Err(QueryParseError::UnknownField {
1445 op: op.to_string(),
1446 key: key.to_string(),
1447 hint: field_hint(key, spec.fields),
1448 });
1449 }
1450 if BRACKET_LIST_FIELDS.contains(&key) && value.starts_with('[') != value.ends_with(']') {
1451 return Err(QueryParseError::InvalidValue {
1452 key: key.to_string(),
1453 value: value.clone(),
1454 });
1455 }
1456 }
1457 if let Some(extra) = fields.positional.get(spec.positionals) {
1458 return Err(QueryParseError::UnexpectedArgument {
1459 op: op.to_string(),
1460 value: extra.clone(),
1461 });
1462 }
1463 if !spec.projection && !fields.projection.is_empty() {
1464 return Err(QueryParseError::UnsupportedProjection { op: op.to_string() });
1465 }
1466 Ok(())
1467}
1468
1469fn field_hint(key: &str, allowed: &'static [&'static str]) -> String {
1470 if let Some(suggestion) = suggest_field(key, allowed) {
1471 return format!(", did you mean `{suggestion}`?");
1472 }
1473 let mut valid: Vec<&str> = allowed.iter().chain(COMMON_FIELDS).copied().collect();
1474 valid.sort_unstable();
1475 format!(" (valid fields: {})", valid.join(", "))
1476}
1477
1478fn suggest_field(key: &str, allowed: &'static [&'static str]) -> Option<&'static str> {
1479 const ALIASES: &[(&str, &str)] = &[("text", "name"), ("query", "name"), ("filename", "path")];
1480 for (alias, target) in ALIASES {
1481 if *alias == key && allowed.contains(target) {
1482 return Some(target);
1483 }
1484 }
1485 allowed
1486 .iter()
1487 .chain(COMMON_FIELDS)
1488 .copied()
1489 .map(|candidate| (candidate, levenshtein(key, candidate)))
1490 .filter(|(_, distance)| *distance <= 2)
1491 .min_by_key(|(_, distance)| *distance)
1492 .map(|(candidate, _)| candidate)
1493}
1494
1495fn levenshtein(a: &str, b: &str) -> usize {
1496 let b: Vec<char> = b.chars().collect();
1497 let mut row: Vec<usize> = (0..=b.len()).collect();
1498 for (i, ca) in a.chars().enumerate() {
1499 let mut previous = row[0];
1500 row[0] = i + 1;
1501 for (j, cb) in b.iter().enumerate() {
1502 let substitution = previous + usize::from(ca != *cb);
1503 previous = row[j + 1];
1504 row[j + 1] = substitution.min(previous + 1).min(row[j] + 1);
1505 }
1506 }
1507 row[b.len()]
1508}
1509
1510fn symbol_search_query(fields: &FieldBag) -> Result<SymbolSearchQuery, QueryParseError> {
1511 Ok(SymbolSearchQuery {
1512 workspace: fields.one("workspace"),
1513 text: fields.positional.first().cloned(),
1514 path: fields.many("path"),
1515 lang: fields.many("lang"),
1516 kind: fields.many("kind"),
1517 shape: fields.many("shape"),
1518 name: fields.one("name"),
1519 include_non_navigable: fields.bool("include_non_navigable")?.unwrap_or(false),
1520 include_code: fields.bool("include_code")?.unwrap_or(false),
1521 context_lines: fields.usize("context_lines")?.unwrap_or(0),
1522 projection: fields.projection.clone(),
1523 })
1524}
1525
1526fn symbol_insights_query(fields: &FieldBag) -> Result<SymbolSearchQuery, QueryParseError> {
1527 let mut query = symbol_search_query(fields)?;
1528 query.text = None;
1529 query.include_code = false;
1530 query.context_lines = 0;
1531 Ok(query)
1532}
1533
1534fn notes_query(fields: &FieldBag) -> Result<NotesQuery, QueryParseError> {
1535 Ok(NotesQuery {
1536 action: parse_notes_action(fields.one("action").as_deref().unwrap_or("list"))?,
1537 id: fields.one("id"),
1538 moniker: fields.one("moniker"),
1539 kind: fields.one("kind"),
1540 status: fields.one("status"),
1541 title: fields.one("title"),
1542 body: fields.one("body"),
1543 created_by: fields.one("created_by"),
1544 orphan: fields.bool("orphan")?,
1545 include_done: fields.bool("include_done")?.unwrap_or(false),
1546 })
1547}
1548
1549fn identity_children_query(fields: &FieldBag) -> IdentityChildrenQuery {
1550 IdentityChildrenQuery {
1551 workspace: fields.one("workspace"),
1552 prefix: fields
1553 .one("prefix")
1554 .or_else(|| fields.positional.first().cloned())
1555 .unwrap_or_default(),
1556 }
1557}
1558
1559fn symbol_graph_query(fields: &FieldBag) -> Result<SymbolGraphQuery, QueryParseError> {
1560 Ok(SymbolGraphQuery {
1561 workspace: fields.one("workspace"),
1562 focus: fields
1563 .one("focus")
1564 .or_else(|| fields.positional.first().cloned())
1565 .ok_or(QueryParseError::MissingRequired("focus"))?,
1566 })
1567}
1568
1569fn parse_notes_action(value: &str) -> Result<NotesAction, QueryParseError> {
1570 match value {
1571 "list" => Ok(NotesAction::List),
1572 "get" => Ok(NotesAction::Get),
1573 "create" => Ok(NotesAction::Create),
1574 "update" => Ok(NotesAction::Update),
1575 "transition" => Ok(NotesAction::Transition),
1576 "delete" => Ok(NotesAction::Delete),
1577 _ => Err(QueryParseError::InvalidValue {
1578 key: "action".to_string(),
1579 value: value.to_string(),
1580 }),
1581 }
1582}
1583
1584pub fn format_query_response(response: &QueryResponse) -> String {
1585 let mut out = String::new();
1586 if let Some(generation) = response.generation {
1587 let _ = writeln!(out, "generation: {}", generation.0);
1588 }
1589 if let Some(cursor) = &response.next_cursor {
1590 if let Some(generation) = cursor.generation {
1591 let _ = writeln!(out, "next_cursor: {}:{}", generation.0, cursor.offset);
1592 } else {
1593 let _ = writeln!(out, "next_cursor: {}", cursor.offset);
1594 }
1595 }
1596 match &response.result {
1597 QueryResult::WorkspaceStatus(status) => {
1598 let _ = writeln!(out, "workspace: {}", status.root);
1599 let _ = writeln!(out, "phase: {}", status.phase);
1600 let _ = writeln!(
1601 out,
1602 "files: {} symbols: {} references: {}",
1603 status.files, status.symbols, status.references
1604 );
1605 let _ = writeln!(out, "stale: {} ({})", status.stale, status.stale_summary);
1606 if status.roots.len() > 1 {
1607 let _ = writeln!(out, "roots:");
1608 for root in &status.roots {
1609 let _ = writeln!(
1610 out,
1611 "- {} files:{} symbols:{} references:{} stale:{}",
1612 root.root, root.files, root.symbols, root.references, root.stale
1613 );
1614 }
1615 }
1616 }
1617 QueryResult::TreeChildren(result) => {
1618 let _ = writeln!(out, "tree: {}", result.root);
1619 for row in &result.rows {
1620 let kind = match row.kind {
1621 TreeNodeKind::File => "file",
1622 TreeNodeKind::Directory => "dir",
1623 };
1624 let _ = writeln!(
1625 out,
1626 "- {kind} {} defs:{} refs:{}",
1627 row.path, row.defs, row.refs
1628 );
1629 }
1630 }
1631 QueryResult::SymbolList(result) => {
1632 let _ = writeln!(out, "symbols: {}", result.total);
1633 for row in &result.rows {
1634 let _ = writeln!(out, "- {} {} {} {}", row.kind, row.name, row.file, row.uri);
1635 }
1636 }
1637 QueryResult::SymbolInsights(result) => format_symbol_insights(&mut out, result),
1638 QueryResult::SymbolDetail(result) => {
1639 let symbol = &result.symbol;
1640 let _ = writeln!(out, "symbol: {} {}", symbol.kind, symbol.name);
1641 let _ = writeln!(out, "uri: {}", symbol.uri);
1642 let _ = writeln!(out, "file: {}", symbol.file);
1643 if let Some(source) = &result.source {
1644 for line in &source.lines {
1645 let _ = writeln!(out, "{:>6} | {}", line.number, line.text);
1646 }
1647 }
1648 }
1649 QueryResult::SymbolUsages(result) => {
1650 let _ = writeln!(out, "uri: {}", result.target.uri);
1651 let _ = writeln!(out, "direction: {}", result.direction.as_str());
1652 let _ = writeln!(out, "usages: {}", result.total);
1653 for row in &result.rows {
1654 let _ = writeln!(
1655 out,
1656 "- {} {} {} {}",
1657 row.direction.as_str(),
1658 row.kind,
1659 row.actor,
1660 row.file
1661 );
1662 }
1663 }
1664 QueryResult::ViewRead(result) => match result {
1665 ViewReadResult::List(list) => {
1666 let _ = writeln!(out, "views: {}", list.views.len());
1667 for view in &list.views {
1668 let _ = writeln!(out, "- {} ({})", view.id, view.scope);
1669 }
1670 }
1671 ViewReadResult::Detail(detail) => {
1672 let _ = writeln!(out, "view: {}", detail.id);
1673 let _ = writeln!(out, "fragment: {}", detail.fragment);
1674 let _ = writeln!(out, "scope: {}", detail.scope);
1675 let _ = writeln!(
1676 out,
1677 "rules: {} boundaries: {} gotchas: {}",
1678 detail.rules.len(),
1679 detail.boundaries.len(),
1680 detail.gotchas.len()
1681 );
1682 }
1683 },
1684 QueryResult::RulesList(result) => {
1685 let _ = writeln!(out, "rules: {}", result.total);
1686 format_rules_list_rows(&mut out, result);
1687 }
1688 QueryResult::RulesCheck(result) => format_rules_check(&mut out, result),
1689 QueryResult::ChangeReview(result) => format_change_review(&mut out, result),
1690 QueryResult::SymbolGraph(result) => format_symbol_graph(&mut out, result),
1691 QueryResult::IdentityChildren(result) => format_identity_children(&mut out, result),
1692 QueryResult::IdentityGraph(result) => format_identity_graph(&mut out, result),
1693 QueryResult::ResolutionAudit(result) => format_resolution_audit(&mut out, result),
1694 QueryResult::Notes(result) => format_notes(&mut out, result),
1695 }
1696 out
1697}
1698
1699fn format_resolution_audit(out: &mut String, result: &ResolutionAuditResult) {
1700 let t = &result.totals;
1701 if !result.prefix.is_empty() {
1702 let _ = writeln!(out, "prefix: {}", result.prefix);
1703 }
1704 let _ = writeln!(
1705 out,
1706 "refs: {} resolved: {} external: {} blocked: {} unresolved: {} name_match_resolved: {}",
1707 t.references, t.resolved, t.external, t.blocked, t.unresolved, t.name_match_resolved
1708 );
1709 let _ = writeln!(out, "clusters:");
1710 for cluster in &result.clusters {
1711 let _ = writeln!(out, "- [{:>6}] {}", cluster.count, cluster.pattern);
1712 if let Some(sample) = cluster.samples.first() {
1713 let _ = writeln!(
1714 out,
1715 " ex: {} {} -> {}",
1716 sample.call_name, sample.receiver, sample.target
1717 );
1718 }
1719 }
1720 let _ = writeln!(out, "zones:");
1721 for zone in &result.zones {
1722 let _ = writeln!(
1723 out,
1724 "- [{:>5}] {} — {}",
1725 zone.unresolved, zone.zone, zone.dominant_pattern
1726 );
1727 }
1728}
1729
1730fn format_symbol_insights(out: &mut String, result: &SymbolInsightsResult) {
1731 let _ = writeln!(out, "files: {}", result.files);
1732 let _ = writeln!(out, "symbols: {}", result.symbols);
1733 let _ = writeln!(out, "refs: {}", result.references);
1734 let _ = writeln!(out, "languages:");
1735 for row in &result.languages {
1736 let _ = writeln!(out, "- {}: {}", row.name, row.count);
1737 }
1738}
1739
1740fn format_notes(out: &mut String, result: &NotesResult) {
1741 let _ = writeln!(out, "action: {}", result.action);
1742 let _ = writeln!(out, "notes: {}", result.total);
1743 for row in &result.rows {
1744 let _ = writeln!(out, "- {} [{}] {}", row.id, row.status, row.title);
1745 }
1746}
1747
1748fn format_rules_list_rows(out: &mut String, result: &RulesListResult) {
1749 for row in &result.rows {
1750 let _ = writeln!(
1751 out,
1752 "- {} [{}] root={} lang={} domain={}",
1753 row.id, row.severity, row.root, row.lang, row.domain
1754 );
1755 if let Some(message) = &row.message {
1756 let _ = writeln!(out, " message: {message}");
1757 }
1758 }
1759}
1760
1761fn format_identity_children(out: &mut String, result: &IdentityChildrenResult) {
1762 let prefix = if result.prefix.is_empty() {
1763 "<root>"
1764 } else {
1765 &result.prefix
1766 };
1767 let _ = writeln!(out, "prefix: {prefix}");
1768 let _ = writeln!(out, "children: {}", result.children.len());
1769 for child in &result.children {
1770 let marker = if child.symbol.is_some() { "def" } else { "…" };
1771 let _ = writeln!(
1772 out,
1773 "- {} [{}] defs={} {}",
1774 child.segment, marker, child.defs, child.identity
1775 );
1776 }
1777}
1778
1779fn format_unlinked(out: &mut String, unlinked: &UnlinkedRefsDto) {
1780 let _ = writeln!(
1781 out,
1782 "unlinked refs: external {} · manifest-blocked {} · unresolved {}",
1783 unlinked.external, unlinked.manifest_blocked, unlinked.unresolved
1784 );
1785 if !unlinked.unresolved_reasons.is_empty() {
1786 let reasons = unlinked
1787 .unresolved_reasons
1788 .iter()
1789 .map(|(reason, count)| format!("{reason} {count}"))
1790 .collect::<Vec<_>>()
1791 .join(" · ");
1792 let _ = writeln!(out, "unresolved by reason: {reasons}");
1793 }
1794}
1795
1796fn format_identity_graph(out: &mut String, result: &IdentityGraphResult) {
1797 let prefix = if result.prefix.is_empty() {
1798 "<root>"
1799 } else {
1800 &result.prefix
1801 };
1802 let _ = writeln!(out, "scope: {prefix}");
1803 let _ = writeln!(
1804 out,
1805 "nodes: {} edges: {}",
1806 result.nodes.len(),
1807 result.edges.len()
1808 );
1809 format_unlinked(out, &result.unlinked);
1810 for edge in &result.edges {
1811 let _ = writeln!(
1812 out,
1813 "- {} -> {} x{} [{}]",
1814 edge.source,
1815 edge.target,
1816 edge.count,
1817 edge.kinds.join(",")
1818 );
1819 }
1820 for port in &result.ports_in {
1821 let _ = writeln!(
1822 out,
1823 "< {} x{} [{}]",
1824 port.identity,
1825 port.count,
1826 port.kinds.join(",")
1827 );
1828 }
1829 for port in &result.ports_out {
1830 let _ = writeln!(
1831 out,
1832 "> {} x{} [{}]",
1833 port.identity,
1834 port.count,
1835 port.kinds.join(",")
1836 );
1837 }
1838}
1839
1840fn format_symbol_graph(out: &mut String, result: &SymbolGraphResult) {
1841 match &result.focus {
1842 SymbolGraphFocus::Symbol { symbol } => {
1843 let _ = writeln!(
1844 out,
1845 "focus: {} {} ({})",
1846 symbol.kind, symbol.name, symbol.file
1847 );
1848 }
1849 SymbolGraphFocus::File { path } => {
1850 let _ = writeln!(out, "focus: file {path}");
1851 }
1852 }
1853 let _ = writeln!(
1854 out,
1855 "members: {} internal edges: {}",
1856 result.members.len(),
1857 result.internal_edges.len()
1858 );
1859 format_unlinked(out, &result.unlinked);
1860 for caller in &result.callers {
1861 let _ = writeln!(
1862 out,
1863 "< {} {} ({}) x{} [{}]",
1864 caller.symbol.kind,
1865 caller.symbol.name,
1866 caller.symbol.file,
1867 caller.count,
1868 caller.kinds.join(",")
1869 );
1870 }
1871 for callee in &result.callees {
1872 let _ = writeln!(
1873 out,
1874 "> {} {} ({}) x{} [{}]",
1875 callee.symbol.kind,
1876 callee.symbol.name,
1877 callee.symbol.file,
1878 callee.count,
1879 callee.kinds.join(",")
1880 );
1881 }
1882}
1883
1884fn format_change_review(out: &mut String, result: &ChangeReviewResult) {
1885 let _ = writeln!(out, "scope: {}", result.scope);
1886 let _ = writeln!(
1887 out,
1888 "files: {} ({} analyzable) symbols: {} refs: {} ({} retargeted) residual: {}",
1889 result.summary.files,
1890 result.summary.analyzable_files,
1891 result.summary.symbol_changes,
1892 result.summary.ref_changes,
1893 result.summary.retargeted_refs,
1894 result.summary.residual_files
1895 );
1896 for file in &result.files {
1897 let path = match (&file.old_path, &file.new_path) {
1898 (Some(old), Some(new)) if old != new => format!("{old} -> {new}"),
1899 (_, Some(new)) => new.clone(),
1900 (Some(old), None) => old.clone(),
1901 (None, None) => "<unknown>".to_string(),
1902 };
1903 let _ = writeln!(
1904 out,
1905 "- {path} {}{}{}",
1906 file.disposition,
1907 if file.analyzable {
1908 ""
1909 } else {
1910 " (not analyzable)"
1911 },
1912 if file.coverage_explained {
1913 ""
1914 } else {
1915 " [residual]"
1916 }
1917 );
1918 }
1919 for change in &result.symbol_changes {
1920 let side = change.new.as_ref().or(change.old.as_ref());
1921 let Some(side) = side else { continue };
1922 let _ = writeln!(
1923 out,
1924 " {} {} {} [{}]",
1925 change.kind, side.kind, side.name, change.confidence
1926 );
1927 }
1928 for diagnostic in &result.diagnostics {
1929 let _ = writeln!(out, "diagnostic: {diagnostic}");
1930 }
1931}
1932
1933fn format_rules_check(out: &mut String, result: &RulesCheckResult) {
1934 let _ = writeln!(out, "exit: {}", result.exit);
1935 let _ = writeln!(
1936 out,
1937 "violations: {} errors: {} elapsed_ms: {}",
1938 result.summary.total_violations, result.summary.total_errors, result.summary.elapsed_ms
1939 );
1940 for violation in &result.violations {
1941 let _ = writeln!(
1942 out,
1943 "- {} {}:{}-{} [{}] {}",
1944 violation.root,
1945 violation.path,
1946 violation.lines.0,
1947 violation.lines.1,
1948 violation.rule_id,
1949 violation.message
1950 );
1951 }
1952 if !result.rule_reports.is_empty() {
1953 let _ = writeln!(out, "rule_reports: {}", result.rule_reports.len());
1954 }
1955}
1956
1957#[derive(Default)]
1958struct FieldBag {
1959 values: Vec<(String, String)>,
1960 positional: Vec<String>,
1961 projection: Vec<String>,
1962 consistency: Consistency,
1963}
1964
1965impl FieldBag {
1966 fn bool(&self, key: &str) -> Result<Option<bool>, QueryParseError> {
1967 self.one(key)
1968 .map(|value| match value.as_str() {
1969 "true" => Ok(true),
1970 "false" => Ok(false),
1971 _ => Err(QueryParseError::InvalidValue {
1972 key: key.to_string(),
1973 value,
1974 }),
1975 })
1976 .transpose()
1977 }
1978
1979 fn page(&self) -> Result<Page, QueryParseError> {
1980 let limit = self.usize("limit")?.unwrap_or(80);
1981 let cursor = self
1982 .one("cursor")
1983 .map(|value| parse_cursor(&value))
1984 .transpose()?;
1985 Ok(Page { cursor, limit })
1986 }
1987
1988 fn usize(&self, key: &str) -> Result<Option<usize>, QueryParseError> {
1989 self.one(key)
1990 .map(|value| {
1991 value
1992 .parse::<usize>()
1993 .map_err(|_| QueryParseError::InvalidValue {
1994 key: key.to_string(),
1995 value,
1996 })
1997 })
1998 .transpose()
1999 }
2000
2001 fn one(&self, key: &str) -> Option<String> {
2002 self.many(key).into_iter().next()
2003 }
2004
2005 fn many(&self, key: &str) -> Vec<String> {
2006 self.values
2007 .iter()
2008 .filter(|(candidate, _)| candidate == key)
2009 .flat_map(|(_, value)| split_csv(strip_bracket_list(key, value)))
2010 .collect()
2011 }
2012}
2013
2014fn collect_tokens(
2015 tokens: &[String],
2016 fields: &mut FieldBag,
2017 positional: &mut Vec<String>,
2018) -> Result<(), QueryParseError> {
2019 for token in tokens {
2020 if let Some((key, value)) = token.split_once(':') {
2021 fields.values.push((key.to_string(), value.to_string()));
2022 } else {
2023 positional.push(token.trim_end_matches(',').to_string());
2024 }
2025 }
2026 Ok(())
2027}
2028
2029fn parse_cursor(value: &str) -> Result<QueryCursor, QueryParseError> {
2030 let Some((generation, offset)) = value.split_once(':') else {
2031 return Err(QueryParseError::InvalidValue {
2032 key: "cursor".to_string(),
2033 value: value.to_string(),
2034 });
2035 };
2036 let generation = generation
2037 .parse::<u64>()
2038 .map_err(|_| QueryParseError::InvalidValue {
2039 key: "cursor".to_string(),
2040 value: value.to_string(),
2041 })?;
2042 let offset = offset
2043 .parse::<usize>()
2044 .map_err(|_| QueryParseError::InvalidValue {
2045 key: "cursor".to_string(),
2046 value: value.to_string(),
2047 })?;
2048 Ok(QueryCursor::new(
2049 offset,
2050 Some(WorkspaceGeneration(generation)),
2051 ))
2052}
2053
2054fn tokenize(input: &str) -> Result<Vec<String>, QueryParseError> {
2055 let mut tokens = Vec::new();
2056 let mut current = String::new();
2057 let mut chars = input.chars().peekable();
2058 let mut quoted = false;
2059 while let Some(ch) = chars.next() {
2060 match ch {
2061 '"' => {
2062 quoted = !quoted;
2063 }
2064 '\\' if quoted => {
2065 if let Some(next) = chars.next() {
2066 current.push(next);
2067 }
2068 }
2069 ch if ch.is_whitespace() && !quoted => {
2070 if !current.is_empty() {
2071 tokens.push(std::mem::take(&mut current));
2072 }
2073 }
2074 ch => current.push(ch),
2075 }
2076 }
2077 if quoted {
2078 return Err(QueryParseError::InvalidToken(input.to_string()));
2079 }
2080 if !current.is_empty() {
2081 tokens.push(current);
2082 }
2083 Ok(tokens)
2084}
2085
2086fn strip_bracket_list<'a>(key: &str, value: &'a str) -> &'a str {
2089 if !BRACKET_LIST_FIELDS.contains(&key) {
2090 return value;
2091 }
2092 value
2093 .strip_prefix('[')
2094 .and_then(|inner| inner.strip_suffix(']'))
2095 .unwrap_or(value)
2096}
2097
2098pub fn split_csv(value: &str) -> Vec<String> {
2099 value
2100 .split(',')
2101 .map(str::trim)
2102 .filter(|entry| !entry.is_empty())
2103 .map(ToOwned::to_owned)
2104 .collect()
2105}
2106
2107#[cfg(test)]
2108mod tests {
2109 use super::*;
2110
2111 #[test]
2112 fn parses_human_symbol_search() {
2113 let query = parse_query(
2114 r#"symbol.search "SharedWorkspaceIndex"
2115 filter path:"crates/**" shape:type
2116 project name, kind, uri
2117 page limit:20 cursor:7:40"#,
2118 )
2119 .expect("query");
2120 assert_eq!(query.page.limit, 20);
2121 assert_eq!(
2122 query.page.cursor,
2123 Some(QueryCursor::new(40, Some(WorkspaceGeneration(7))))
2124 );
2125 match query.query {
2126 Query::SymbolSearch(search) => {
2127 assert_eq!(search.text.as_deref(), Some("SharedWorkspaceIndex"));
2128 assert_eq!(search.path, vec!["crates/**"]);
2129 assert_eq!(search.shape, vec!["type"]);
2130 assert_eq!(search.projection, vec!["name", "kind", "uri"]);
2131 }
2132 other => panic!("unexpected query {other:?}"),
2133 }
2134 }
2135
2136 #[test]
2137 fn parses_rules_check_consistency() {
2138 let query = parse_query(
2139 r#"rules.check profile:"agent"
2140 consistency refresh-if-stale
2141 page limit:50"#,
2142 )
2143 .expect("query");
2144 assert_eq!(query.consistency, Consistency::RefreshIfStale);
2145 assert_eq!(query.page.limit, 50);
2146 }
2147
2148 #[test]
2149 fn rejects_offset_only_human_cursor() {
2150 let error =
2151 parse_query("symbol.search Customer\npage cursor:40").expect_err("offset-only cursor");
2152 assert!(matches!(
2153 error,
2154 QueryParseError::InvalidValue { ref key, .. } if key == "cursor"
2155 ));
2156 }
2157
2158 #[test]
2159 fn parses_bracket_list_shape() {
2160 let query = parse_query("symbol.search shape:[callable,type] limit:5").expect("query");
2161 match query.query {
2162 Query::SymbolSearch(search) => assert_eq!(search.shape, vec!["callable", "type"]),
2163 other => panic!("unexpected query {other:?}"),
2164 }
2165 }
2166
2167 #[test]
2168 fn rejects_unterminated_bracket_list() {
2169 let error = parse_query("symbol.search shape:[callable").expect_err("unterminated list");
2170 assert!(matches!(
2171 error,
2172 QueryParseError::InvalidValue { ref key, .. } if key == "shape"
2173 ));
2174 }
2175
2176 #[test]
2177 fn rejects_unknown_field_with_alias_suggestion() {
2178 let error = parse_query(r#"symbol.search text:"foo""#).expect_err("unknown field");
2179 let message = error.to_string();
2180 assert!(
2181 message.contains("unknown field `text` for `symbol.search`"),
2182 "{message}"
2183 );
2184 assert!(message.contains("did you mean `name`?"), "{message}");
2185 }
2186
2187 #[test]
2188 fn rejects_typo_field_with_suggestion() {
2189 let error = parse_query("rules.check profil:agent").expect_err("typo field");
2190 let message = error.to_string();
2191 assert!(message.contains("did you mean `profile`?"), "{message}");
2192 }
2193
2194 #[test]
2195 fn lists_valid_fields_without_close_match() {
2196 let error = parse_query("change.review foobarbaz:1").expect_err("unknown field");
2197 let message = error.to_string();
2198 assert!(
2199 message.contains("valid fields: consistency, cursor, limit, workspace"),
2200 "{message}"
2201 );
2202 }
2203
2204 #[test]
2205 fn rejects_unexpected_positional() {
2206 let error = parse_query("workspace.status extra").expect_err("positional");
2207 assert!(matches!(
2208 error,
2209 QueryParseError::UnexpectedArgument { ref value, .. } if value == "extra"
2210 ));
2211 }
2212
2213 #[test]
2214 fn rejects_projection_on_unsupported_verb() {
2215 let error = parse_query("rules.list\nproject name").expect_err("projection");
2216 assert!(matches!(
2217 error,
2218 QueryParseError::UnsupportedProjection { .. }
2219 ));
2220 }
2221
2222 #[test]
2223 fn parses_inline_consistency() {
2224 let query =
2225 parse_query("rules.check profile:agent consistency:refresh-if-stale").expect("query");
2226 assert_eq!(query.consistency, Consistency::RefreshIfStale);
2227 }
2228
2229 #[test]
2230 fn formats_generation_aware_cursor() {
2231 let response = QueryResponse {
2232 generation: Some(WorkspaceGeneration(7)),
2233 result: QueryResult::SymbolList(SymbolListResult {
2234 rows: Vec::new(),
2235 total: 0,
2236 }),
2237 next_cursor: Some(QueryCursor::new(40, Some(WorkspaceGeneration(7)))),
2238 };
2239 let formatted = format_query_response(&response);
2240 assert!(formatted.contains("next_cursor: 7:40"));
2241 }
2242}
2243
2244#[cfg(feature = "schema")]
2247#[derive(schemars::JsonSchema)]
2248#[allow(dead_code)]
2249pub struct DaemonProtocol {
2250 pub handshake: HandshakeResponse,
2251 pub registry_entry: DaemonRegistryEntry,
2252 pub workspace_config: DaemonWorkspaceConfig,
2253 pub query_request: QueryRequest,
2254 pub query: Query,
2255 pub query_response: QueryResponse,
2256 pub query_result: QueryResult,
2257 pub command_request: CommandRequest,
2258 pub command_response: CommandResponse,
2259 pub event: WorkspaceEventDto,
2260 pub error: QueryError,
2261}
2262
2263#[cfg(test)]
2264mod contract_tests {
2265 use super::*;
2268 use serde_json::json;
2269
2270 #[test]
2271 fn query_is_op_tagged() {
2272 let query = Query::SymbolSearch(SymbolSearchQuery {
2273 text: Some("widget".to_string()),
2274 ..Default::default()
2275 });
2276 let value = serde_json::to_value(&query).unwrap();
2277 assert_eq!(value["op"], "symbol_search");
2278 assert_eq!(value["text"], "widget");
2279 }
2280
2281 #[test]
2282 fn query_result_is_kind_and_data_tagged() {
2283 let result = QueryResult::SymbolList(SymbolListResult {
2284 rows: Vec::new(),
2285 total: 0,
2286 });
2287 assert_eq!(
2288 serde_json::to_value(&result).unwrap(),
2289 json!({ "kind": "symbol_list", "data": { "rows": [], "total": 0 } }),
2290 );
2291 }
2292
2293 #[test]
2294 fn generation_serializes_as_scalar() {
2295 assert_eq!(
2296 serde_json::to_value(WorkspaceGeneration(7)).unwrap(),
2297 json!(7)
2298 );
2299 }
2300
2301 #[test]
2302 fn line_range_is_a_two_element_array() {
2303 let range: Option<(u32, u32)> = Some((3, 9));
2304 assert_eq!(serde_json::to_value(range).unwrap(), json!([3, 9]));
2305 assert_eq!(
2306 serde_json::to_value(Option::<(u32, u32)>::None).unwrap(),
2307 json!(null)
2308 );
2309 }
2310
2311 #[test]
2312 fn consistency_is_snake_case() {
2313 assert_eq!(
2314 serde_json::to_value(Consistency::RefreshIfStale).unwrap(),
2315 json!("refresh_if_stale"),
2316 );
2317 }
2318
2319 #[test]
2320 fn event_kind_is_snake_case() {
2321 let event = WorkspaceEventDto {
2322 kind: WorkspaceEventKind::GitBase,
2323 generation: None,
2324 stale_summary: None,
2325 };
2326 assert_eq!(serde_json::to_value(&event).unwrap()["kind"], "git_base");
2327 }
2328}