Skip to main content

foundry_cli/introspect/
registry.rs

1//! Per-binary registry of stable command metadata.
2//!
3//! The registry overlays metadata that cannot be expressed via clap
4//! attributes onto the command tree: the stable `command_id`, capability
5//! flags, and any command-specific exit codes.
6//!
7//! Each binary owns and ships its own [`CommandRegistry`]. Entries are keyed
8//! by the **clap path excluding the binary name** (e.g. `["build"]` for
9//! `forge build`); the binary name is implicit from the owning binary. When
10//! no entry is found for a command, the
11//! [`build_document`](super::build_document) helper fills in safe defaults: a
12//! derived `command_id` (path joined by `.`) marked `command_id_stable=false`
13//! and `Capabilities::NONE` marked `capabilities_declared=false`.
14//!
15//! Only commands intended to be referenced by stable identifiers need to be
16//! registered explicitly. Once a command is registered, its `command_id` is
17//! considered frozen and CI uniqueness checks ensure it cannot collide.
18
19use super::document::{Capabilities, ExitCodeInfo};
20
21/// Per-leaf metadata that overlays the clap-derived defaults.
22#[derive(Clone, Debug)]
23pub struct CommandMeta {
24    /// Stable machine identifier, e.g. `"forge.build"`.
25    ///
26    /// When set, this overrides the path-derived default.
27    pub command_id: Option<&'static str>,
28    /// Capabilities reported for agent consumers.
29    pub capabilities: Capabilities,
30    /// Set to `true` when `capabilities` is intentionally authored; partial
31    /// entries that pin only `command_id` or `exit_codes` must leave this `false`.
32    pub capabilities_declared: bool,
33    /// Command-specific exit codes, in addition to the global table.
34    pub exit_codes: &'static [ExitCodeInfo],
35}
36
37impl CommandMeta {
38    /// Const-constructible default suitable for use in `static` registries.
39    pub const DEFAULT: Self = Self {
40        command_id: None,
41        capabilities: Capabilities::NONE,
42        capabilities_declared: false,
43        exit_codes: &[],
44    };
45}
46
47impl Default for CommandMeta {
48    fn default() -> Self {
49        Self::DEFAULT
50    }
51}
52
53/// A binary's command registry.
54///
55/// Implemented as a thin wrapper over a `&'static` slice of (path, metadata) pairs to keep the call
56/// sites declarative without pulling in a hash map at startup.
57#[derive(Clone, Copy, Debug)]
58pub struct CommandRegistry {
59    entries: &'static [RegistryEntry],
60}
61
62/// A single registry entry.
63#[derive(Clone, Debug)]
64pub struct RegistryEntry {
65    /// Clap path components, e.g. `&["build"]` for `forge build`.
66    ///
67    /// The binary name (`forge`, `cast`, …) is not included; it is implicit
68    /// from which binary owns the registry.
69    pub path: &'static [&'static str],
70    /// Metadata overlay for the command at `path`.
71    pub meta: CommandMeta,
72}
73
74impl CommandRegistry {
75    /// Construct a new registry from a static slice of entries.
76    pub const fn new(entries: &'static [RegistryEntry]) -> Self {
77        Self { entries }
78    }
79
80    /// An empty registry. Every command falls back to defaults.
81    pub const EMPTY: Self = Self::new(&[]);
82
83    /// Look up metadata for the command at `path` (clap path, excluding the
84    /// binary name).
85    pub fn lookup(&self, path: &[&str]) -> Option<&CommandMeta> {
86        self.entries.iter().find(|e| e.path == path).map(|e| &e.meta)
87    }
88
89    /// Iterate over all entries.
90    pub fn entries(&self) -> impl Iterator<Item = &RegistryEntry> {
91        self.entries.iter()
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::introspect::document::OutputMode;
99    use std::borrow::Cow;
100
101    fn fixture_registry() -> CommandRegistry {
102        static ENTRIES: &[RegistryEntry] = &[RegistryEntry {
103            path: &["build"],
104            meta: CommandMeta {
105                command_id: Some("forge.build"),
106                capabilities: Capabilities {
107                    output_mode: OutputMode::Envelope,
108                    result_schema_ref: None,
109                    event_schema_ref: None,
110                    session_schema_ref: None,
111                    reads_stdin: false,
112                    supports_output_path: false,
113                    requires_project: true,
114                    side_effects: super::super::document::SideEffects::FsWrite,
115                    long_running: false,
116                    stateful: false,
117                },
118                capabilities_declared: true,
119                exit_codes: &[],
120            },
121        }];
122        CommandRegistry::new(ENTRIES)
123    }
124
125    #[test]
126    fn lookup_returns_registered_meta() {
127        let r = fixture_registry();
128        let meta = r.lookup(&["build"]).expect("registered");
129        assert_eq!(meta.command_id, Some("forge.build"));
130        assert!(matches!(meta.capabilities.output_mode, OutputMode::Envelope));
131    }
132
133    #[test]
134    fn lookup_returns_none_for_unregistered_path() {
135        assert!(fixture_registry().lookup(&["unknown"]).is_none());
136    }
137
138    #[test]
139    fn empty_registry_yields_no_entries() {
140        assert_eq!(CommandRegistry::EMPTY.entries().count(), 0);
141    }
142
143    /// The root/default invocation is keyed by the empty path; verify that
144    /// `lookup(&[])` finds an entry registered at `path: &[]`.
145    #[test]
146    fn lookup_supports_empty_path_for_root_default() {
147        static ENTRIES: &[RegistryEntry] = &[RegistryEntry {
148            path: &[],
149            meta: CommandMeta {
150                command_id: Some("anvil.start"),
151                capabilities: Capabilities::NONE,
152                capabilities_declared: false,
153                exit_codes: &[],
154            },
155        }];
156        let registry = CommandRegistry::new(ENTRIES);
157        let meta = registry.lookup(&[]).expect("root/default entry");
158        assert_eq!(meta.command_id, Some("anvil.start"));
159    }
160
161    /// A registry with real strings (schema refs, exit-code names) must be
162    /// authorable in a plain `static` without lazy allocation.
163    #[test]
164    fn static_registry_supports_real_strings() {
165        static EXITS: &[ExitCodeInfo] = &[ExitCodeInfo {
166            code: 2,
167            name: Cow::Borrowed("TestFailure"),
168            description: Cow::Borrowed("at least one test failed"),
169        }];
170        static ENTRIES: &[RegistryEntry] = &[RegistryEntry {
171            path: &["test"],
172            meta: CommandMeta {
173                command_id: Some("forge.test"),
174                capabilities: Capabilities {
175                    output_mode: OutputMode::Envelope,
176                    result_schema_ref: Some(Cow::Borrowed("foundry:test-result@v1")),
177                    event_schema_ref: None,
178                    session_schema_ref: None,
179                    reads_stdin: false,
180                    supports_output_path: false,
181                    requires_project: true,
182                    side_effects: super::super::document::SideEffects::None,
183                    long_running: false,
184                    stateful: false,
185                },
186                capabilities_declared: true,
187                exit_codes: EXITS,
188            },
189        }];
190        let registry = CommandRegistry::new(ENTRIES);
191
192        let meta = registry.lookup(&["test"]).unwrap();
193        assert_eq!(meta.capabilities.result_schema_ref.as_deref(), Some("foundry:test-result@v1"));
194        assert_eq!(meta.exit_codes[0].name, "TestFailure");
195    }
196}