Skip to main content

osp_cli/
services.rs

1use crate::config::RuntimeConfig;
2use crate::core::output_model::OutputResult;
3use crate::core::row::Row;
4use crate::dsl::{apply_pipeline, parse_pipeline};
5use crate::ports::{LdapDirectory, parse_attributes};
6use anyhow::{Result, anyhow};
7
8pub struct ServiceContext<L: LdapDirectory> {
9    pub user: Option<String>,
10    pub ldap: L,
11    pub config: RuntimeConfig,
12}
13
14impl<L: LdapDirectory> ServiceContext<L> {
15    pub fn new(user: Option<String>, ldap: L, config: RuntimeConfig) -> Self {
16        Self { user, ldap, config }
17    }
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ParsedCommand {
22    LdapUser {
23        uid: Option<String>,
24        filter: Option<String>,
25        attributes: Option<String>,
26    },
27    LdapNetgroup {
28        name: Option<String>,
29        filter: Option<String>,
30        attributes: Option<String>,
31    },
32}
33
34pub fn execute_line<L: LdapDirectory>(ctx: &ServiceContext<L>, line: &str) -> Result<OutputResult> {
35    let parsed_pipeline = parse_pipeline(line)?;
36    if parsed_pipeline.command.is_empty() {
37        return Ok(OutputResult::from_rows(Vec::new()));
38    }
39
40    let tokens = shell_words::split(&parsed_pipeline.command)
41        .map_err(|err| anyhow!("failed to parse command: {err}"))?;
42    let command = parse_repl_command(&tokens)?;
43    apply_pipeline(execute_command(ctx, &command)?, &parsed_pipeline.stages)
44}
45
46pub fn parse_repl_command(tokens: &[String]) -> Result<ParsedCommand> {
47    if tokens.is_empty() {
48        return Err(anyhow!("empty command"));
49    }
50    if tokens[0] != "ldap" {
51        return Err(anyhow!("unsupported command: {}", tokens[0]));
52    }
53    if tokens.len() < 2 {
54        return Err(anyhow!("missing ldap subcommand"));
55    }
56
57    match tokens[1].as_str() {
58        "user" => parse_ldap_user_tokens(tokens),
59        "netgroup" => parse_ldap_netgroup_tokens(tokens),
60        other => Err(anyhow!("unsupported ldap subcommand: {other}")),
61    }
62}
63
64fn parse_ldap_user_tokens(tokens: &[String]) -> Result<ParsedCommand> {
65    let mut uid: Option<String> = None;
66    let mut filter: Option<String> = None;
67    let mut attributes: Option<String> = None;
68
69    let mut i = 2usize;
70    while i < tokens.len() {
71        match tokens[i].as_str() {
72            "--filter" => {
73                i += 1;
74                let value = tokens
75                    .get(i)
76                    .ok_or_else(|| anyhow!("--filter requires a value"))?;
77                filter = Some(value.clone());
78            }
79            "--attributes" | "-a" => {
80                i += 1;
81                let value = tokens
82                    .get(i)
83                    .ok_or_else(|| anyhow!("--attributes requires a value"))?;
84                attributes = Some(value.clone());
85            }
86            token if token.starts_with('-') => return Err(anyhow!("unknown option: {token}")),
87            value => {
88                if uid.is_some() {
89                    return Err(anyhow!("ldap user accepts one uid positional argument"));
90                }
91                uid = Some(value.to_string());
92            }
93        }
94        i += 1;
95    }
96
97    Ok(ParsedCommand::LdapUser {
98        uid,
99        filter,
100        attributes,
101    })
102}
103
104fn parse_ldap_netgroup_tokens(tokens: &[String]) -> Result<ParsedCommand> {
105    let mut name: Option<String> = None;
106    let mut filter: Option<String> = None;
107    let mut attributes: Option<String> = None;
108
109    let mut i = 2usize;
110    while i < tokens.len() {
111        match tokens[i].as_str() {
112            "--filter" => {
113                i += 1;
114                let value = tokens
115                    .get(i)
116                    .ok_or_else(|| anyhow!("--filter requires a value"))?;
117                filter = Some(value.clone());
118            }
119            "--attributes" | "-a" => {
120                i += 1;
121                let value = tokens
122                    .get(i)
123                    .ok_or_else(|| anyhow!("--attributes requires a value"))?;
124                attributes = Some(value.clone());
125            }
126            token if token.starts_with('-') => return Err(anyhow!("unknown option: {token}")),
127            value => {
128                if name.is_some() {
129                    return Err(anyhow!(
130                        "ldap netgroup accepts one name positional argument"
131                    ));
132                }
133                name = Some(value.to_string());
134            }
135        }
136        i += 1;
137    }
138
139    Ok(ParsedCommand::LdapNetgroup {
140        name,
141        filter,
142        attributes,
143    })
144}
145
146pub fn execute_command<L: LdapDirectory>(
147    ctx: &ServiceContext<L>,
148    command: &ParsedCommand,
149) -> Result<Vec<Row>> {
150    match command {
151        ParsedCommand::LdapUser {
152            uid,
153            filter,
154            attributes,
155        } => {
156            let resolved_uid = uid
157                .clone()
158                .or_else(|| ctx.user.clone())
159                .ok_or_else(|| anyhow!("ldap user requires <uid> or -u/--user"))?;
160            let attrs = parse_attributes(attributes.as_deref())?;
161            ctx.ldap
162                .user(&resolved_uid, filter.as_deref(), attrs.as_deref())
163        }
164        ParsedCommand::LdapNetgroup {
165            name,
166            filter,
167            attributes,
168        } => {
169            let resolved_name = name
170                .clone()
171                .ok_or_else(|| anyhow!("ldap netgroup requires <name>"))?;
172            let attrs = parse_attributes(attributes.as_deref())?;
173            ctx.ldap
174                .netgroup(&resolved_name, filter.as_deref(), attrs.as_deref())
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use crate::api::MockLdapClient;
182    use crate::core::output_model::OutputResult;
183
184    use super::{ParsedCommand, ServiceContext, execute_command, execute_line, parse_repl_command};
185
186    fn output_rows(output: &OutputResult) -> &[crate::core::row::Row] {
187        output.as_rows().expect("expected row output")
188    }
189
190    fn test_ctx() -> ServiceContext<MockLdapClient> {
191        ServiceContext::new(
192            Some("oistes".to_string()),
193            MockLdapClient::default(),
194            crate::config::RuntimeConfig::default(),
195        )
196    }
197
198    #[test]
199    fn parses_repl_user_command_with_options() {
200        let cmd = parse_repl_command(&[
201            "ldap".to_string(),
202            "user".to_string(),
203            "oistes".to_string(),
204            "--filter".to_string(),
205            "uid=oistes".to_string(),
206            "--attributes".to_string(),
207            "uid,cn".to_string(),
208        ])
209        .expect("command should parse");
210
211        assert_eq!(
212            cmd,
213            ParsedCommand::LdapUser {
214                uid: Some("oistes".to_string()),
215                filter: Some("uid=oistes".to_string()),
216                attributes: Some("uid,cn".to_string())
217            }
218        );
219    }
220
221    #[test]
222    fn ldap_user_defaults_to_global_user() {
223        let ctx = test_ctx();
224        let rows = execute_command(
225            &ctx,
226            &ParsedCommand::LdapUser {
227                uid: None,
228                filter: None,
229                attributes: None,
230            },
231        )
232        .expect("ldap user should default to global user");
233
234        assert_eq!(rows.len(), 1);
235        assert_eq!(rows[0].get("uid").and_then(|v| v.as_str()), Some("oistes"));
236    }
237
238    #[test]
239    fn execute_line_supports_pipeline() {
240        let ctx = test_ctx();
241        let rows = execute_line(&ctx, "ldap user oistes | P uid,cn")
242            .expect("pipeline command should execute");
243        assert_eq!(output_rows(&rows).len(), 1);
244        assert!(output_rows(&rows)[0].contains_key("uid"));
245        assert!(output_rows(&rows)[0].contains_key("cn"));
246    }
247
248    #[test]
249    fn parse_repl_command_rejects_empty_and_unknown_commands() {
250        let empty = parse_repl_command(&[]).expect_err("empty command should fail");
251        assert!(empty.to_string().contains("empty command"));
252
253        let unsupported = parse_repl_command(&["mreg".to_string()])
254            .expect_err("unsupported root command should fail");
255        assert!(unsupported.to_string().contains("unsupported command"));
256
257        let missing_subcommand = parse_repl_command(&["ldap".to_string()])
258            .expect_err("missing ldap subcommand should fail");
259        assert!(
260            missing_subcommand
261                .to_string()
262                .contains("missing ldap subcommand")
263        );
264    }
265
266    #[test]
267    fn parse_repl_command_supports_netgroup_and_short_attribute_flag() {
268        let cmd = parse_repl_command(&[
269            "ldap".to_string(),
270            "netgroup".to_string(),
271            "ops".to_string(),
272            "-a".to_string(),
273            "cn,description".to_string(),
274            "--filter".to_string(),
275            "ops".to_string(),
276        ])
277        .expect("netgroup command should parse");
278
279        assert_eq!(
280            cmd,
281            ParsedCommand::LdapNetgroup {
282                name: Some("ops".to_string()),
283                filter: Some("ops".to_string()),
284                attributes: Some("cn,description".to_string()),
285            }
286        );
287    }
288
289    #[test]
290    fn parse_repl_command_rejects_unknown_options_and_extra_positionals() {
291        let unknown =
292            parse_repl_command(&["ldap".to_string(), "user".to_string(), "--wat".to_string()])
293                .expect_err("unknown flag should fail");
294        assert!(unknown.to_string().contains("unknown option"));
295
296        let extra = parse_repl_command(&[
297            "ldap".to_string(),
298            "netgroup".to_string(),
299            "ops".to_string(),
300            "extra".to_string(),
301        ])
302        .expect_err("extra positional should fail");
303        assert!(
304            extra
305                .to_string()
306                .contains("ldap netgroup accepts one name positional argument")
307        );
308    }
309
310    #[test]
311    fn execute_command_requires_explicit_subject_when_defaults_are_missing() {
312        let ctx = ServiceContext::new(
313            None,
314            MockLdapClient::default(),
315            crate::config::RuntimeConfig::default(),
316        );
317        let err = execute_command(
318            &ctx,
319            &ParsedCommand::LdapUser {
320                uid: None,
321                filter: None,
322                attributes: None,
323            },
324        )
325        .expect_err("ldap user should require uid when global user is missing");
326        assert!(
327            err.to_string()
328                .contains("ldap user requires <uid> or -u/--user")
329        );
330
331        let err = execute_command(
332            &ctx,
333            &ParsedCommand::LdapNetgroup {
334                name: None,
335                filter: None,
336                attributes: None,
337            },
338        )
339        .expect_err("ldap netgroup should require a name");
340        assert!(err.to_string().contains("ldap netgroup requires <name>"));
341    }
342
343    #[test]
344    fn execute_line_handles_blank_and_shell_parse_errors() {
345        let ctx = test_ctx();
346
347        let blank = execute_line(&ctx, "   ").expect("blank line should be a no-op");
348        assert!(output_rows(&blank).is_empty());
349
350        let err = execute_line(&ctx, "ldap user \"unterminated")
351            .expect_err("invalid shell quoting should fail");
352        assert!(err.to_string().contains("unterminated"));
353    }
354}