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}