1use 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
32pub struct ServiceContext<L: LdapDirectory> {
38 pub user: Option<String>,
40 pub ldap: L,
42 pub config: RuntimeConfig,
44}
45
46impl<L: LdapDirectory> ServiceContext<L> {
47 pub fn new(user: Option<String>, ldap: L, config: RuntimeConfig) -> Self {
66 Self { user, ldap, config }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum ParsedCommand {
73 LdapUser {
75 uid: Option<String>,
77 filter: Option<String>,
79 attributes: Option<String>,
81 },
82 LdapNetgroup {
84 name: Option<String>,
86 filter: Option<String>,
88 attributes: Option<String>,
90 },
91}
92
93pub 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
109pub 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
237pub 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}