Skip to main content

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}