Skip to main content

code_moniker_query/
lib.rs

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// One level of the identity tree: children of a moniker identity prefix
313// (`""` = the workspace root). The symbolic navigation surface - no
314// filesystem involved.
315#[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/// A workspace change pushed to attached clients over a daemon subscription.
423#[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// Refs without an in-workspace target, decomposed so external-by-design
471// never masquerades as a resolution gap: `external` links to declared
472// packages, `manifest_blocked` hit the manifest policy, `unresolved` are the
473// real misses, ventilated by reason.
474#[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// One child segment under the requested prefix. `symbol` is attached when the
527// segment itself is a navigable definition; organizational segments (package,
528// dir, srcset, lang, module wrappers) only aggregate what lives below.
529#[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// The embedded resolution audit: unresolved references (and name-match
542// resolutions, the false-link candidates) clustered under mechanical pattern
543// keys, with samples and per-zone rollups — the daemon's own diagnosis
544// surface, so agents stop rebuilding external harnesses.
545#[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// The scoped exploration graph: one level of the identity tree projected as
591// a graph. Nodes are the prefix's children; edges are resolved references
592// rolled up to the pair of child segments they connect; ports aggregate what
593// crosses the scope boundary.
594#[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// source/target are child segment identities of the requested prefix.
606#[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// Aggregated boundary crossing: `identity` is the nearest out-of-scope
616// segment (rolled up to the scope's own depth in the identity tree).
617#[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
2086// `shape:[callable,type]` list sugar, restricted to enum-like fields so glob
2087// character classes in `path:`/`file:` values stay untouched.
2088fn 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/// Umbrella over every root RPC type, used only to emit a single JSON Schema
2245/// document (`export-schema`) whose definitions cover the whole wire contract.
2246#[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	//! Lock the serde wire shapes the JSON Schema (and every generated client)
2266	//! depends on. These guard the contract, not the Rust layout.
2267	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}