turbovault_plugin_api/completion.rs
1use std::collections::BTreeMap;
2
3/// What a completion request is asking a plugin to complete.
4///
5/// Both variants name the plugin-local identifier, not the published one: a
6/// plugin never spells its own namespace here any more than it does when
7/// declaring a tool.
8#[derive(Debug, Clone, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum CompletionTarget {
11 /// An argument of a prompt, by local name.
12 Prompt(String),
13 /// An expression in a resource template, by local URI template.
14 ResourceTemplate(String),
15}
16
17/// A request to suggest values for an argument a user is part-way through.
18///
19/// Completion is what makes a resource template usable: without it a client can
20/// only show `note/{path}` and hope the user types a real path. It is called
21/// while the user types, so answer from state already in memory rather than
22/// scanning the vault on each keystroke.
23#[derive(Debug, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct CompletionRequest {
26 /// The prompt or resource template being filled in.
27 pub target: CompletionTarget,
28 /// Name of the argument or template expression being completed.
29 pub argument: String,
30 /// What the user has typed so far. Often empty on the first request.
31 pub value: String,
32 /// Arguments of the same target the client has already resolved.
33 ///
34 /// A template with more than one expression can use these to narrow later
35 /// suggestions to what is reachable given the earlier choices.
36 pub resolved: BTreeMap<String, String>,
37}
38
39impl CompletionRequest {
40 /// Construct a request for `argument` with the partial input `value`.
41 pub fn new(
42 target: CompletionTarget,
43 argument: impl Into<String>,
44 value: impl Into<String>,
45 ) -> Self {
46 Self {
47 target,
48 argument: argument.into(),
49 value: value.into(),
50 resolved: BTreeMap::new(),
51 }
52 }
53
54 /// Attach the arguments the client has already resolved.
55 pub fn with_resolved(mut self, resolved: BTreeMap<String, String>) -> Self {
56 self.resolved = resolved;
57 self
58 }
59}
60
61/// Suggested values for one argument.
62///
63/// The host truncates [`Self::values`] to the limit MCP places on a single
64/// completion response and marks the result as having more, so a plugin may
65/// return everything it knows without tracking the protocol's cap.
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct Completion {
69 /// Suggestions, best first.
70 pub values: Vec<String>,
71 /// How many exist in total, when the plugin knows and it exceeds what it
72 /// returned.
73 pub total: Option<u32>,
74 /// Whether more exist beyond [`Self::values`].
75 ///
76 /// Leave unset unless the plugin itself truncated; the host sets it when
77 /// its own truncation drops values.
78 pub has_more: Option<bool>,
79}
80
81impl Completion {
82 /// Suggest nothing. A valid answer, and the default.
83 pub fn none() -> Self {
84 Self::default()
85 }
86
87 /// Suggest `values`, best first.
88 pub fn new(values: impl IntoIterator<Item = String>) -> Self {
89 Self {
90 values: values.into_iter().collect(),
91 ..Self::default()
92 }
93 }
94
95 /// Report how many suggestions exist in total.
96 pub fn with_total(mut self, total: u32) -> Self {
97 self.total = Some(total);
98 self
99 }
100
101 /// Report that the plugin itself returned only part of what it knows.
102 pub fn with_has_more(mut self, has_more: bool) -> Self {
103 self.has_more = Some(has_more);
104 self
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn completions_default_to_suggesting_nothing() {
114 let empty = Completion::none();
115 assert!(empty.values.is_empty());
116 assert_eq!(empty.total, None);
117 assert_eq!(empty.has_more, None);
118 assert_eq!(empty, Completion::default());
119 }
120
121 #[test]
122 fn requests_carry_previously_resolved_arguments() {
123 let request = CompletionRequest::new(
124 CompletionTarget::ResourceTemplate("note/{folder}/{name}".to_string()),
125 "name",
126 "dai",
127 )
128 .with_resolved(BTreeMap::from([(
129 "folder".to_string(),
130 "journal".to_string(),
131 )]));
132 assert_eq!(
133 request.resolved.get("folder").map(String::as_str),
134 Some("journal")
135 );
136 assert_eq!(request.value, "dai");
137 }
138}