1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use anyhow::Result;
5
6use crate::completion::CommandSpec;
7use crate::config::ResolvedConfig;
8use crate::core::command_policy::CommandPolicyRegistry;
9use crate::core::plugin::{DescribeCommandAuthV1, DescribeCommandV1, ResponseV1};
10use crate::core::runtime::RuntimeHints;
11
12#[derive(Debug, Clone)]
13pub struct NativeCommandCatalogEntry {
14 pub name: String,
15 pub about: String,
16 pub auth: Option<DescribeCommandAuthV1>,
17 pub subcommands: Vec<String>,
18 pub completion: CommandSpec,
19}
20
21pub struct NativeCommandContext<'a> {
22 pub config: &'a ResolvedConfig,
23 pub runtime_hints: RuntimeHints,
24}
25
26pub enum NativeCommandOutcome {
27 Help(String),
28 Response(Box<ResponseV1>),
29 Exit(i32),
30}
31
32pub trait NativeCommand: Send + Sync {
33 fn describe(&self) -> DescribeCommandV1;
34
35 fn execute(
36 &self,
37 args: &[String],
38 context: &NativeCommandContext<'_>,
39 ) -> Result<NativeCommandOutcome>;
40}
41
42#[derive(Clone, Default)]
43pub struct NativeCommandRegistry {
44 commands: Arc<BTreeMap<String, Arc<dyn NativeCommand>>>,
45}
46
47impl NativeCommandRegistry {
48 pub fn new() -> Self {
49 Self::default()
50 }
51
52 pub fn with_command(mut self, command: impl NativeCommand + 'static) -> Self {
53 self.register(command);
54 self
55 }
56
57 pub fn register(&mut self, command: impl NativeCommand + 'static) {
58 let mut next = (*self.commands).clone();
59 let command = Arc::new(command) as Arc<dyn NativeCommand>;
60 let name = normalize_name(&command.describe().name);
61 next.insert(name, command);
62 self.commands = Arc::new(next);
63 }
64
65 pub fn is_empty(&self) -> bool {
66 self.commands.is_empty()
67 }
68
69 pub fn command(&self, name: &str) -> Option<&Arc<dyn NativeCommand>> {
70 self.commands.get(&normalize_name(name))
71 }
72
73 pub fn catalog(&self) -> Vec<NativeCommandCatalogEntry> {
74 self.commands
75 .values()
76 .map(|command| {
77 let describe = command.describe();
78 let completion = crate::plugin::conversion::to_command_spec(&describe);
79 NativeCommandCatalogEntry {
80 name: describe.name.clone(),
81 about: describe.about.clone(),
82 auth: describe.auth.clone(),
83 subcommands: crate::plugin::conversion::direct_subcommand_names(&completion),
84 completion,
85 }
86 })
87 .collect()
88 }
89
90 pub fn command_policy_registry(&self) -> CommandPolicyRegistry {
91 let mut registry = CommandPolicyRegistry::new();
92 for command in self.commands.values() {
93 let describe = command.describe();
94 register_describe_command_policies(&mut registry, &describe, &[]);
95 }
96 registry
97 }
98}
99
100fn register_describe_command_policies(
101 registry: &mut CommandPolicyRegistry,
102 command: &DescribeCommandV1,
103 parent: &[String],
104) {
105 let mut segments = parent.to_vec();
106 segments.push(command.name.clone());
107 if let Some(policy) = command.command_policy(crate::core::command_policy::CommandPath::new(
108 segments.clone(),
109 )) {
110 registry.register(policy);
111 }
112 for subcommand in &command.subcommands {
113 register_describe_command_policies(registry, subcommand, &segments);
114 }
115}
116
117fn normalize_name(value: &str) -> String {
118 value.trim().to_ascii_lowercase()
119}
120
121#[cfg(test)]
122mod tests {
123 use super::{NativeCommand, NativeCommandContext, NativeCommandOutcome, NativeCommandRegistry};
124 use crate::core::command_policy::CommandPath;
125 use crate::core::plugin::{
126 DescribeCommandAuthV1, DescribeCommandV1, DescribeVisibilityModeV1, PLUGIN_PROTOCOL_V1,
127 ResponseMetaV1, ResponseV1,
128 };
129 use serde_json::json;
130
131 struct TestNativeCommand;
132
133 impl NativeCommand for TestNativeCommand {
134 fn describe(&self) -> DescribeCommandV1 {
135 DescribeCommandV1 {
136 name: "ldap".to_string(),
137 about: "Directory lookups".to_string(),
138 auth: Some(DescribeCommandAuthV1 {
139 visibility: Some(DescribeVisibilityModeV1::Public),
140 required_capabilities: Vec::new(),
141 feature_flags: vec!["uio".to_string()],
142 }),
143 args: Vec::new(),
144 flags: Default::default(),
145 subcommands: vec![DescribeCommandV1 {
146 name: "user".to_string(),
147 about: "Look up a user".to_string(),
148 auth: Some(DescribeCommandAuthV1 {
149 visibility: Some(DescribeVisibilityModeV1::CapabilityGated),
150 required_capabilities: vec!["ldap.user.read".to_string()],
151 feature_flags: Vec::new(),
152 }),
153 args: Vec::new(),
154 flags: Default::default(),
155 subcommands: Vec::new(),
156 }],
157 }
158 }
159
160 fn execute(
161 &self,
162 args: &[String],
163 _context: &NativeCommandContext<'_>,
164 ) -> anyhow::Result<NativeCommandOutcome> {
165 Ok(NativeCommandOutcome::Response(Box::new(ResponseV1 {
166 protocol_version: PLUGIN_PROTOCOL_V1,
167 ok: true,
168 data: json!([{ "args": args }]),
169 error: None,
170 messages: Vec::new(),
171 meta: ResponseMetaV1::default(),
172 })))
173 }
174 }
175
176 #[test]
177 fn registry_catalog_exposes_completion_and_auth_metadata_unit() {
178 let registry = NativeCommandRegistry::new().with_command(TestNativeCommand);
179
180 let catalog = registry.catalog();
181 assert_eq!(catalog.len(), 1);
182 assert_eq!(catalog[0].name, "ldap");
183 assert_eq!(catalog[0].about, "Directory lookups");
184 assert_eq!(
185 catalog[0]
186 .auth
187 .as_ref()
188 .and_then(|auth| auth.hint())
189 .as_deref(),
190 Some("feature: uio")
191 );
192 assert_eq!(catalog[0].subcommands, vec!["user".to_string()]);
193 assert!(
194 catalog[0]
195 .completion
196 .subcommands
197 .iter()
198 .any(|child| child.name == "user")
199 );
200 }
201
202 #[test]
203 fn registry_normalizes_lookup_and_collects_recursive_policy_unit() {
204 let registry = NativeCommandRegistry::new().with_command(TestNativeCommand);
205
206 assert!(registry.command("LDAP").is_some());
207 assert!(registry.command(" ldap ").is_some());
208
209 let policy = registry.command_policy_registry();
210 assert!(policy.contains(&CommandPath::new(["ldap"])));
211 assert!(policy.contains(&CommandPath::new(["ldap", "user"])));
212
213 let user_policy = policy
214 .resolved_policy(&CommandPath::new(["ldap", "user"]))
215 .expect("nested native policy should exist");
216 assert_eq!(
217 user_policy.required_capabilities,
218 ["ldap.user.read".to_string()].into_iter().collect()
219 );
220 }
221}