Skip to main content

osp_cli/
services.rs

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