Skip to main content

osp_cli/
native.rs

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}