1use 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
37pub struct ServiceContext<L: LdapDirectory> {
43 pub user: Option<String>,
45 pub ldap: L,
47 pub config: RuntimeConfig,
49}
50
51impl<L: LdapDirectory> ServiceContext<L> {
52 pub fn new(user: Option<String>, ldap: L, config: RuntimeConfig) -> Self {
73 Self { user, ldap, config }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum ParsedCommand {
80 LdapUser {
82 uid: Option<String>,
84 filter: Option<String>,
86 attributes: Option<String>,
88 },
89 LdapNetgroup {
91 name: Option<String>,
93 filter: Option<String>,
95 attributes: Option<String>,
97 },
98}
99
100pub 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
145pub 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
273pub 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}