Skip to main content

osp_cli/
services.rs

1//! Small embeddable service-layer command surface.
2//!
3//! This module exists for callers that want `osp`-style command parsing and DSL
4//! pipelines without bootstrapping the full CLI host or REPL runtime.
5//!
6//! High level flow:
7//!
8//! - parse a narrow service command grammar
9//! - execute it against abstract [`crate::ports`] traits
10//! - optionally apply trailing DSL stages to the returned rows
11//!
12//! Contract:
13//!
14//! - this layer is intentionally small and port-driven
15//! - richer host concerns like plugin dispatch, prompt handling, and terminal
16//!   rendering belong elsewhere
17//!
18//! Public API shape:
19//!
20//! - [`crate::services::ServiceContext::new`] is the main construction surface
21//! - parsed commands and output values stay plain semantic data
22//! - callers that outgrow this small surface should move up to [`crate::app`]
23//!   or down to [`crate::ports`] rather than reassembling host machinery here
24
25use crate::config::RuntimeConfig;
26use crate::core::output_model::OutputResult;
27use crate::core::row::Row;
28use crate::dsl::{apply_pipeline, parse_pipeline};
29use crate::ports::{LdapDirectory, parse_attributes};
30use anyhow::{Result, anyhow};
31
32/// Embeddable execution inputs for the small service-layer command API.
33///
34/// This keeps the surface intentionally narrow: a default user identity, an
35/// abstract LDAP backend, and the resolved runtime config snapshot that the
36/// service layer should share with the full host when embedded.
37pub struct ServiceContext<L: LdapDirectory> {
38    /// Default user identity used when a command omits its explicit subject.
39    pub user: Option<String>,
40    /// Abstract LDAP backend used by service commands.
41    pub ldap: L,
42    /// Resolved runtime config snapshot carried alongside service execution.
43    pub config: RuntimeConfig,
44}
45
46impl<L: LdapDirectory> ServiceContext<L> {
47    /// Creates a new service context with the active user, directory port, and
48    /// resolved runtime config.
49    ///
50    /// # Examples
51    ///
52    /// ```
53    /// use osp_cli::api::MockLdapClient;
54    /// use osp_cli::config::RuntimeConfig;
55    /// use osp_cli::services::ServiceContext;
56    ///
57    /// let ctx = ServiceContext::new(
58    ///     Some("oistes".to_string()),
59    ///     MockLdapClient::default(),
60    ///     RuntimeConfig::default(),
61    /// );
62    ///
63    /// assert_eq!(ctx.user.as_deref(), Some("oistes"));
64    /// ```
65    pub fn new(user: Option<String>, ldap: L, config: RuntimeConfig) -> Self {
66        Self { user, ldap, config }
67    }
68}
69
70/// Parsed subset of commands understood by [`execute_line`].
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum ParsedCommand {
73    /// Lookup a user entry in LDAP.
74    LdapUser {
75        /// Explicit user identifier to query.
76        uid: Option<String>,
77        /// Optional LDAP filter expression.
78        filter: Option<String>,
79        /// Optional comma-separated attribute selection.
80        attributes: Option<String>,
81    },
82    /// Lookup a netgroup entry in LDAP.
83    LdapNetgroup {
84        /// Explicit netgroup name to query.
85        name: Option<String>,
86        /// Optional LDAP filter expression.
87        filter: Option<String>,
88        /// Optional comma-separated attribute selection.
89        attributes: Option<String>,
90    },
91}
92
93/// Executes one service-layer command line and applies any trailing DSL stages.
94///
95/// This is the small, embeddable surface for callers that want `osp`-style
96/// command parsing plus pipelines without bootstrapping the full CLI host.
97pub fn execute_line<L: LdapDirectory>(ctx: &ServiceContext<L>, line: &str) -> Result<OutputResult> {
98    let parsed_pipeline = parse_pipeline(line)?;
99    if parsed_pipeline.command.is_empty() {
100        return Ok(OutputResult::from_rows(Vec::new()));
101    }
102
103    let tokens = shell_words::split(&parsed_pipeline.command)
104        .map_err(|err| anyhow!("failed to parse command: {err}"))?;
105    let command = parse_repl_command(&tokens)?;
106    apply_pipeline(execute_command(ctx, &command)?, &parsed_pipeline.stages)
107}
108
109/// Interprets tokenized service-layer input using the minimal LDAP command grammar.
110///
111/// Unlike the full CLI parser, this only accepts the subset modeled by
112/// [`ParsedCommand`].
113///
114/// # Examples
115///
116/// ```
117/// use osp_cli::services::{ParsedCommand, parse_repl_command};
118///
119/// let tokens = vec![
120///     "ldap".to_string(),
121///     "user".to_string(),
122///     "alice".to_string(),
123///     "--attributes".to_string(),
124///     "uid,mail".to_string(),
125/// ];
126///
127/// let parsed = parse_repl_command(&tokens).unwrap();
128/// assert!(matches!(
129///     parsed,
130///     ParsedCommand::LdapUser {
131///         uid: Some(uid),
132///         attributes: Some(attributes),
133///         ..
134///     } if uid == "alice" && attributes == "uid,mail"
135/// ));
136/// ```
137pub fn parse_repl_command(tokens: &[String]) -> Result<ParsedCommand> {
138    if tokens.is_empty() {
139        return Err(anyhow!("empty command"));
140    }
141    if tokens[0] != "ldap" {
142        return Err(anyhow!("unsupported command: {}", tokens[0]));
143    }
144    if tokens.len() < 2 {
145        return Err(anyhow!("missing ldap subcommand"));
146    }
147
148    match tokens[1].as_str() {
149        "user" => parse_ldap_user_tokens(tokens),
150        "netgroup" => parse_ldap_netgroup_tokens(tokens),
151        other => Err(anyhow!("unsupported ldap subcommand: {other}")),
152    }
153}
154
155fn parse_ldap_user_tokens(tokens: &[String]) -> Result<ParsedCommand> {
156    let mut uid: Option<String> = None;
157    let mut filter: Option<String> = None;
158    let mut attributes: Option<String> = None;
159
160    let mut i = 2usize;
161    while i < tokens.len() {
162        match tokens[i].as_str() {
163            "--filter" => {
164                i += 1;
165                let value = tokens
166                    .get(i)
167                    .ok_or_else(|| anyhow!("--filter requires a value"))?;
168                filter = Some(value.clone());
169            }
170            "--attributes" | "-a" => {
171                i += 1;
172                let value = tokens
173                    .get(i)
174                    .ok_or_else(|| anyhow!("--attributes requires a value"))?;
175                attributes = Some(value.clone());
176            }
177            token if token.starts_with('-') => return Err(anyhow!("unknown option: {token}")),
178            value => {
179                if uid.is_some() {
180                    return Err(anyhow!("ldap user accepts one uid positional argument"));
181                }
182                uid = Some(value.to_string());
183            }
184        }
185        i += 1;
186    }
187
188    Ok(ParsedCommand::LdapUser {
189        uid,
190        filter,
191        attributes,
192    })
193}
194
195fn parse_ldap_netgroup_tokens(tokens: &[String]) -> Result<ParsedCommand> {
196    let mut name: Option<String> = None;
197    let mut filter: Option<String> = None;
198    let mut attributes: Option<String> = None;
199
200    let mut i = 2usize;
201    while i < tokens.len() {
202        match tokens[i].as_str() {
203            "--filter" => {
204                i += 1;
205                let value = tokens
206                    .get(i)
207                    .ok_or_else(|| anyhow!("--filter requires a value"))?;
208                filter = Some(value.clone());
209            }
210            "--attributes" | "-a" => {
211                i += 1;
212                let value = tokens
213                    .get(i)
214                    .ok_or_else(|| anyhow!("--attributes requires a value"))?;
215                attributes = Some(value.clone());
216            }
217            token if token.starts_with('-') => return Err(anyhow!("unknown option: {token}")),
218            value => {
219                if name.is_some() {
220                    return Err(anyhow!(
221                        "ldap netgroup accepts one name positional argument"
222                    ));
223                }
224                name = Some(value.to_string());
225            }
226        }
227        i += 1;
228    }
229
230    Ok(ParsedCommand::LdapNetgroup {
231        name,
232        filter,
233        attributes,
234    })
235}
236
237/// Executes a parsed service-layer command against the configured LDAP port.
238///
239/// # Examples
240///
241/// ```
242/// use osp_cli::api::MockLdapClient;
243/// use osp_cli::config::RuntimeConfig;
244/// use osp_cli::services::{ParsedCommand, ServiceContext, execute_command};
245///
246/// let ctx = ServiceContext::new(
247///     Some("oistes".to_string()),
248///     MockLdapClient::default(),
249///     RuntimeConfig::default(),
250/// );
251/// let rows = execute_command(
252///     &ctx,
253///     &ParsedCommand::LdapUser {
254///         uid: None,
255///         filter: Some("uid=oistes".to_string()),
256///         attributes: Some("uid,cn".to_string()),
257///     },
258/// )
259/// .unwrap();
260///
261/// assert_eq!(rows.len(), 1);
262/// assert_eq!(rows[0].get("uid").unwrap(), "oistes");
263/// assert!(rows[0].contains_key("cn"));
264/// ```
265pub fn execute_command<L: LdapDirectory>(
266    ctx: &ServiceContext<L>,
267    command: &ParsedCommand,
268) -> Result<Vec<Row>> {
269    match command {
270        ParsedCommand::LdapUser {
271            uid,
272            filter,
273            attributes,
274        } => {
275            let resolved_uid = uid
276                .clone()
277                .or_else(|| ctx.user.clone())
278                .ok_or_else(|| anyhow!("ldap user requires <uid> or -u/--user"))?;
279            let attrs = parse_attributes(attributes.as_deref())?;
280            ctx.ldap
281                .user(&resolved_uid, filter.as_deref(), attrs.as_deref())
282        }
283        ParsedCommand::LdapNetgroup {
284            name,
285            filter,
286            attributes,
287        } => {
288            let resolved_name = name
289                .clone()
290                .ok_or_else(|| anyhow!("ldap netgroup requires <name>"))?;
291            let attrs = parse_attributes(attributes.as_deref())?;
292            ctx.ldap
293                .netgroup(&resolved_name, filter.as_deref(), attrs.as_deref())
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use crate::api::MockLdapClient;
301    use crate::core::output_model::OutputResult;
302
303    use super::{ParsedCommand, ServiceContext, execute_command, execute_line, parse_repl_command};
304
305    fn output_rows(output: &OutputResult) -> &[crate::core::row::Row] {
306        output.as_rows().expect("expected row output")
307    }
308
309    fn test_ctx() -> ServiceContext<MockLdapClient> {
310        ServiceContext::new(
311            Some("oistes".to_string()),
312            MockLdapClient::default(),
313            crate::config::RuntimeConfig::default(),
314        )
315    }
316
317    #[test]
318    fn parses_repl_user_command_with_options() {
319        let cmd = parse_repl_command(&[
320            "ldap".to_string(),
321            "user".to_string(),
322            "oistes".to_string(),
323            "--filter".to_string(),
324            "uid=oistes".to_string(),
325            "--attributes".to_string(),
326            "uid,cn".to_string(),
327        ])
328        .expect("command should parse");
329
330        assert_eq!(
331            cmd,
332            ParsedCommand::LdapUser {
333                uid: Some("oistes".to_string()),
334                filter: Some("uid=oistes".to_string()),
335                attributes: Some("uid,cn".to_string())
336            }
337        );
338    }
339
340    #[test]
341    fn ldap_user_defaults_to_global_user() {
342        let ctx = test_ctx();
343        let rows = execute_command(
344            &ctx,
345            &ParsedCommand::LdapUser {
346                uid: None,
347                filter: None,
348                attributes: None,
349            },
350        )
351        .expect("ldap user should default to global user");
352
353        assert_eq!(rows.len(), 1);
354        assert_eq!(rows[0].get("uid").and_then(|v| v.as_str()), Some("oistes"));
355    }
356
357    #[test]
358    fn execute_line_supports_pipeline() {
359        let ctx = test_ctx();
360        let rows = execute_line(&ctx, "ldap user oistes | P uid,cn")
361            .expect("pipeline command should execute");
362        assert_eq!(output_rows(&rows).len(), 1);
363        assert!(output_rows(&rows)[0].contains_key("uid"));
364        assert!(output_rows(&rows)[0].contains_key("cn"));
365    }
366
367    #[test]
368    fn parse_repl_command_rejects_empty_and_unknown_commands() {
369        let empty = parse_repl_command(&[]).expect_err("empty command should fail");
370        assert!(empty.to_string().contains("empty command"));
371
372        let unsupported = parse_repl_command(&["mreg".to_string()])
373            .expect_err("unsupported root command should fail");
374        assert!(unsupported.to_string().contains("unsupported command"));
375
376        let missing_subcommand = parse_repl_command(&["ldap".to_string()])
377            .expect_err("missing ldap subcommand should fail");
378        assert!(
379            missing_subcommand
380                .to_string()
381                .contains("missing ldap subcommand")
382        );
383    }
384
385    #[test]
386    fn parse_repl_command_supports_netgroup_and_short_attribute_flag() {
387        let cmd = parse_repl_command(&[
388            "ldap".to_string(),
389            "netgroup".to_string(),
390            "ops".to_string(),
391            "-a".to_string(),
392            "cn,description".to_string(),
393            "--filter".to_string(),
394            "ops".to_string(),
395        ])
396        .expect("netgroup command should parse");
397
398        assert_eq!(
399            cmd,
400            ParsedCommand::LdapNetgroup {
401                name: Some("ops".to_string()),
402                filter: Some("ops".to_string()),
403                attributes: Some("cn,description".to_string()),
404            }
405        );
406    }
407
408    #[test]
409    fn parse_repl_command_rejects_unknown_options_and_extra_positionals() {
410        let unknown =
411            parse_repl_command(&["ldap".to_string(), "user".to_string(), "--wat".to_string()])
412                .expect_err("unknown flag should fail");
413        assert!(unknown.to_string().contains("unknown option"));
414
415        let extra = parse_repl_command(&[
416            "ldap".to_string(),
417            "netgroup".to_string(),
418            "ops".to_string(),
419            "extra".to_string(),
420        ])
421        .expect_err("extra positional should fail");
422        assert!(
423            extra
424                .to_string()
425                .contains("ldap netgroup accepts one name positional argument")
426        );
427    }
428
429    #[test]
430    fn execute_command_requires_explicit_subject_when_defaults_are_missing() {
431        let ctx = ServiceContext::new(
432            None,
433            MockLdapClient::default(),
434            crate::config::RuntimeConfig::default(),
435        );
436        let err = execute_command(
437            &ctx,
438            &ParsedCommand::LdapUser {
439                uid: None,
440                filter: None,
441                attributes: None,
442            },
443        )
444        .expect_err("ldap user should require uid when global user is missing");
445        assert!(
446            err.to_string()
447                .contains("ldap user requires <uid> or -u/--user")
448        );
449
450        let err = execute_command(
451            &ctx,
452            &ParsedCommand::LdapNetgroup {
453                name: None,
454                filter: None,
455                attributes: None,
456            },
457        )
458        .expect_err("ldap netgroup should require a name");
459        assert!(err.to_string().contains("ldap netgroup requires <name>"));
460    }
461
462    #[test]
463    fn execute_line_handles_blank_and_shell_parse_errors() {
464        let ctx = test_ctx();
465
466        let blank = execute_line(&ctx, "   ").expect("blank line should be a no-op");
467        assert!(output_rows(&blank).is_empty());
468
469        let err = execute_line(&ctx, "ldap user \"unterminated")
470            .expect_err("invalid shell quoting should fail");
471        assert!(err.to_string().contains("unterminated"));
472    }
473}