everruns_cli_contract/declare.rs
1//! How a command declares its place in the command line.
2//!
3//! A command's parameters are already described by the schema it publishes.
4//! What a schema cannot know is presentation: that `--harness` is worth a `-H`,
5//! that `agents get` reads better with the id as a bare word, that a reader
6//! needs two worked examples to choose this command over its neighbour. Those
7//! are choices a person makes, so they are declared next to the command rather
8//! than inferred from its fields.
9//!
10//! Everything here is `&'static` and const-constructible, so a command
11//! declares its route as a constant and the compiler checks it.
12
13/// Presentation for one of a command's parameters.
14///
15/// Only the parameters that need something beyond the default are declared.
16/// The default is a kebab-cased long flag, which is what most parameters want.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct CliArg {
19 /// Parameter name as the command deserializes it, e.g. `harness_name`.
20 pub field: &'static str,
21 /// Short option, when the parameter is common enough to earn one.
22 ///
23 /// Short options are declared, never derived: deriving from first letters
24 /// collides, and worse, shifts as fields are added, so a script written
25 /// today breaks when an unrelated parameter appears next to it.
26 pub short: Option<char>,
27 /// Position when the parameter is also spelled as a bare word, 1-based.
28 pub position: Option<usize>,
29 /// Long spelling, when it differs from the kebab-cased field name.
30 pub long: Option<&'static str>,
31}
32
33impl CliArg {
34 pub const fn new(field: &'static str) -> Self {
35 Self {
36 field,
37 short: None,
38 position: None,
39 long: None,
40 }
41 }
42
43 /// Give this parameter a short option, e.g. `-H` for `--harness`.
44 pub const fn short(mut self, short: char) -> Self {
45 self.short = Some(short);
46 self
47 }
48
49 /// Also accept this parameter as a bare word at `position` (1-based).
50 pub const fn at(mut self, position: usize) -> Self {
51 self.position = Some(position);
52 self
53 }
54
55 /// Spell the long flag differently from the field name, e.g. the field
56 /// `harness_name` spelled `--harness`.
57 pub const fn long(mut self, long: &'static str) -> Self {
58 self.long = Some(long);
59 self
60 }
61}
62
63/// One worked example, declared as an intent and the command line that serves
64/// it. See [`crate::ContractExample`] for why both halves are required.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct CliExample {
67 pub intent: &'static str,
68 pub command: &'static str,
69}
70
71impl CliExample {
72 pub const fn new(intent: &'static str, command: &'static str) -> Self {
73 Self { intent, command }
74 }
75}
76
77/// Where a command sits in the command line, and how it presents itself.
78///
79/// Opt-in by construction: a command joins the command line only by declaring
80/// one, so internal plumbing cannot reach an agent- or human-facing surface by
81/// being written.
82///
83/// `path` is a slice rather than a single noun because flat command names hide
84/// a hierarchy: `list_session_participants` is `sessions participants list`.
85/// Deriving that by string surgery is wrong for exactly the irregular names
86/// that matter, so the shape is declared.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct CliRoute {
89 /// Noun path from the root, e.g. `["agents"]` or `["agents", "versions"]`.
90 pub path: &'static [&'static str],
91 /// Leaf verb, e.g. `"list"`.
92 pub verb: &'static str,
93 /// Presentation for the parameters that need more than the default.
94 pub args: &'static [CliArg],
95 /// Worked examples, rendered under the command's help.
96 pub examples: &'static [CliExample],
97}
98
99impl CliRoute {
100 pub const fn new(path: &'static [&'static str], verb: &'static str) -> Self {
101 Self {
102 path,
103 verb,
104 args: &[],
105 examples: &[],
106 }
107 }
108
109 pub const fn with_args(mut self, args: &'static [CliArg]) -> Self {
110 self.args = args;
111 self
112 }
113
114 pub const fn with_examples(mut self, examples: &'static [CliExample]) -> Self {
115 self.examples = examples;
116 self
117 }
118
119 /// Space-joined spelling, e.g. `"agents versions list"`.
120 pub fn spelling(&self) -> String {
121 let mut parts = self.path.to_vec();
122 parts.push(self.verb);
123 parts.join(" ")
124 }
125
126 /// The declaration for one parameter, if it has one.
127 pub fn arg(&self, field: &str) -> Option<&CliArg> {
128 self.args.iter().find(|arg| arg.field == field)
129 }
130}
131
132/// The spelling a command gets when it declares no [`CliRoute`].
133///
134/// Every command is part of the command line, because a surface where most
135/// operations have no spelling is not a CLI: a caller who cannot find a command
136/// by walking `--help` has to be told it exists some other way, and that other
137/// way is the thing the tree replaces.
138///
139/// Two facts each command already carries are enough for almost all of them.
140/// The REST path holds the hierarchy that flat names hide:
141/// `/v1/agents/{id}/versions` is `agents versions`, which no amount of string
142/// surgery on `list_agent_versions` would have found. The verb is the flat
143/// name's first token.
144///
145/// Derivation is a default, never an override. A command that declares a route
146/// keeps it, which is how `destroy_agent` stays `agents destroy` rather than
147/// the `agents delete destroy` its REST path implies. Measured against the
148/// hand-written routes, this reproduces 38 of 50 exactly and every difference
149/// is one where the declaration is better.
150///
151/// Returns `None` when the path carries no noun at all, which is a command that
152/// has to declare its own spelling.
153pub fn derived_route(wire_name: &str, http_path: &str) -> Option<(Vec<String>, String)> {
154 // Fixtures are not part of anyone's command line.
155 if http_path.starts_with("/test/") {
156 return None;
157 }
158 let verb = wire_name.split('_').next()?.to_string();
159 let mut nouns: Vec<String> = http_path
160 .trim_matches('/')
161 .split('/')
162 // Skip the version segment; drop path parameters.
163 .skip(1)
164 .filter(|segment| !segment.is_empty() && !segment.starts_with('{'))
165 // REST paths are not uniformly spelled; a CLI is. `plugin_marketplaces`
166 // is a path segment, `plugin-marketplaces` is a command.
167 .map(|segment| segment.replace('_', "-"))
168 .collect();
169
170 // A REST action endpoint repeats the verb as its last segment, so
171 // `/v1/sessions/{id}/archive` would otherwise spell `sessions archive
172 // archive`.
173 if nouns
174 .last()
175 .is_some_and(|last| last.replace('-', "_") == verb)
176 {
177 nouns.pop();
178 }
179
180 if nouns.is_empty() {
181 return None;
182 }
183 Some((nouns, verb.replace('_', "-")))
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn the_rest_path_supplies_the_hierarchy_a_flat_name_hides() {
192 assert_eq!(
193 derived_route("list_agent_versions", "/v1/agents/{agent_id}/versions"),
194 Some((vec!["agents".into(), "versions".into()], "list".into()))
195 );
196 assert_eq!(
197 derived_route(
198 "list_session_participants",
199 "/v1/sessions/{id}/participants"
200 ),
201 Some((
202 vec!["sessions".into(), "participants".into()],
203 "list".into()
204 ))
205 );
206 }
207
208 #[test]
209 fn an_action_endpoint_does_not_repeat_its_verb() {
210 assert_eq!(
211 derived_route("archive_session", "/v1/sessions/{id}/archive"),
212 Some((vec!["sessions".into()], "archive".into()))
213 );
214 }
215
216 #[test]
217 fn a_multi_word_verb_is_kebab() {
218 assert_eq!(
219 derived_route(
220 "set_default_agent_version",
221 "/v1/agents/{id}/versions/default"
222 ),
223 Some((
224 vec!["agents".into(), "versions".into(), "default".into()],
225 "set".into()
226 ))
227 );
228 }
229
230 /// A command whose path carries no noun has to say what it is called.
231 #[test]
232 fn a_pathless_command_derives_nothing() {
233 assert_eq!(derived_route("health_check", "/health"), None);
234 }
235
236 /// A REST path may be snake_case; a command line is not.
237 #[test]
238 fn nouns_are_kebab_even_when_the_path_is_not() {
239 assert_eq!(
240 derived_route("list_plugin_marketplaces", "/v1/plugin_marketplaces"),
241 Some((vec!["plugin-marketplaces".into()], "list".into()))
242 );
243 }
244
245 /// A test fixture is not part of the command line, and a derived surface
246 /// would otherwise hand one to every operator.
247 #[test]
248 fn a_fixture_route_is_not_derived() {
249 assert_eq!(
250 derived_route("test_transport_conflict", "/test/transport-conflict"),
251 None
252 );
253 }
254}