1use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct CreatedResource {
18 pub provider: String,
20 pub service: String,
22 pub resource_type: String,
24 pub identifier: String,
26}
27
28impl CreatedResource {
29 fn aws(service: &str, resource_type: &str, identifier: &str) -> Self {
30 Self {
31 provider: "aws".to_string(),
32 service: service.to_string(),
33 resource_type: resource_type.to_string(),
34 identifier: identifier.to_string(),
35 }
36 }
37}
38
39pub fn parse(command: &[String]) -> Vec<CreatedResource> {
45 if command.is_empty() {
46 return Vec::new();
47 }
48 match command[0].as_str() {
49 "aws" => parse_aws(&command[1..]),
50 _ => Vec::new(),
51 }
52}
53
54fn parse_aws(args: &[String]) -> Vec<CreatedResource> {
56 if args.len() < 2 {
57 return Vec::new();
58 }
59 let service = args[0].as_str();
60 let verb = args[1].as_str();
61 let flags = &args[2..];
62
63 match (service, verb) {
64 ("s3", "mb") => parse_s3_mb(flags),
66 ("s3", "cp") | ("s3", "sync") | ("s3", "mv") => parse_s3_cp(flags),
67 ("s3api", "create-bucket") => flag("--bucket", flags)
69 .map(|v| vec![CreatedResource::aws("s3", "bucket", v)])
70 .unwrap_or_default(),
71 ("s3api", "put-object") => {
72 let mut out = Vec::new();
73 if let (Some(bucket), Some(key)) = (flag("--bucket", flags), flag("--key", flags)) {
74 out.push(CreatedResource::aws(
75 "s3",
76 "object",
77 &format!("s3://{bucket}/{key}"),
78 ));
79 }
80 out
81 }
82 ("dynamodb", "create-table") => flag("--table-name", flags)
84 .map(|v| vec![CreatedResource::aws("dynamodb", "table", v)])
85 .unwrap_or_default(),
86 ("sqs", "create-queue") => flag("--queue-name", flags)
88 .map(|v| vec![CreatedResource::aws("sqs", "queue", v)])
89 .unwrap_or_default(),
90 ("sns", "create-topic") => flag("--name", flags)
92 .map(|v| vec![CreatedResource::aws("sns", "topic", v)])
93 .unwrap_or_default(),
94 ("ssm", "put-parameter") => flag("--name", flags)
96 .map(|v| vec![CreatedResource::aws("ssm", "parameter", v)])
97 .unwrap_or_default(),
98 ("secretsmanager", "create-secret") => flag("--name", flags)
100 .map(|v| vec![CreatedResource::aws("secretsmanager", "secret", v)])
101 .unwrap_or_default(),
102 ("lambda", "create-function") => flag("--function-name", flags)
104 .map(|v| vec![CreatedResource::aws("lambda", "function", v)])
105 .unwrap_or_default(),
106 ("rds", "create-db-instance") => flag("--db-instance-identifier", flags)
108 .map(|v| vec![CreatedResource::aws("rds", "db-instance", v)])
109 .unwrap_or_default(),
110 ("rds", "create-db-cluster") => flag("--db-cluster-identifier", flags)
111 .map(|v| vec![CreatedResource::aws("rds", "db-cluster", v)])
112 .unwrap_or_default(),
113 ("cloudformation", "create-stack") => flag("--stack-name", flags)
115 .map(|v| vec![CreatedResource::aws("cloudformation", "stack", v)])
116 .unwrap_or_default(),
117 ("ecr", "create-repository") => flag("--repository-name", flags)
119 .map(|v| vec![CreatedResource::aws("ecr", "repository", v)])
120 .unwrap_or_default(),
121 ("ecs", "create-cluster") => flag("--cluster-name", flags)
123 .map(|v| vec![CreatedResource::aws("ecs", "cluster", v)])
124 .unwrap_or_default(),
125 ("iam", "create-role") => flag("--role-name", flags)
127 .map(|v| vec![CreatedResource::aws("iam", "role", v)])
128 .unwrap_or_default(),
129 ("iam", "create-user") => flag("--user-name", flags)
130 .map(|v| vec![CreatedResource::aws("iam", "user", v)])
131 .unwrap_or_default(),
132 ("iam", "create-policy") => flag("--policy-name", flags)
133 .map(|v| vec![CreatedResource::aws("iam", "policy", v)])
134 .unwrap_or_default(),
135 ("logs", "create-log-group") => flag("--log-group-name", flags)
137 .map(|v| vec![CreatedResource::aws("logs", "log-group", v)])
138 .unwrap_or_default(),
139 ("logs", "create-log-stream") => {
140 let mut out = Vec::new();
141 if let (Some(group), Some(stream)) = (
142 flag("--log-group-name", flags),
143 flag("--log-stream-name", flags),
144 ) {
145 out.push(CreatedResource::aws(
146 "logs",
147 "log-stream",
148 &format!("{group}/{stream}"),
149 ));
150 }
151 out
152 }
153 ("route53", "create-hosted-zone") => flag("--name", flags)
155 .map(|v| vec![CreatedResource::aws("route53", "hosted-zone", v)])
156 .unwrap_or_default(),
157 _ => Vec::new(),
158 }
159}
160
161fn parse_s3_mb(flags: &[String]) -> Vec<CreatedResource> {
163 flags
164 .iter()
165 .find(|a| a.starts_with("s3://"))
166 .and_then(|uri| uri.strip_prefix("s3://"))
167 .map(|bucket| {
168 let name = bucket.trim_end_matches('/').split('/').next().unwrap_or("");
170 if name.is_empty() {
171 Vec::new()
172 } else {
173 vec![CreatedResource::aws("s3", "bucket", name)]
174 }
175 })
176 .unwrap_or_default()
177}
178
179fn parse_s3_cp(flags: &[String]) -> Vec<CreatedResource> {
181 let s3_uris: Vec<&String> = flags.iter().filter(|a| a.starts_with("s3://")).collect();
184 if let Some(dest) = s3_uris.last() {
185 if let Some(rest) = dest.strip_prefix("s3://") {
188 if !rest.is_empty() && rest.contains('/') {
189 return vec![CreatedResource::aws("s3", "object", dest)];
190 }
191 }
192 }
193 Vec::new()
194}
195
196fn flag<'a>(name: &str, args: &'a [String]) -> Option<&'a str> {
199 let mut iter = args.iter();
200 while let Some(arg) = iter.next() {
201 if arg == name {
202 return iter.next().map(|s| s.as_str());
203 }
204 if let Some(rest) = arg.strip_prefix(&format!("{name}=")) {
206 return Some(rest);
207 }
208 }
209 None
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 fn cmd(parts: &[&str]) -> Vec<String> {
217 parts.iter().map(|s| s.to_string()).collect()
218 }
219
220 #[test]
221 fn parses_s3api_create_bucket() {
222 let c = cmd(&[
223 "aws",
224 "s3api",
225 "create-bucket",
226 "--bucket",
227 "foo",
228 "--region",
229 "us-east-1",
230 ]);
231 let r = parse(&c);
232 assert_eq!(r.len(), 1);
233 assert_eq!(r[0].service, "s3");
234 assert_eq!(r[0].resource_type, "bucket");
235 assert_eq!(r[0].identifier, "foo");
236 }
237
238 #[test]
239 fn parses_s3_mb() {
240 let c = cmd(&["aws", "s3", "mb", "s3://my-bucket"]);
241 let r = parse(&c);
242 assert_eq!(r.len(), 1);
243 assert_eq!(r[0].identifier, "my-bucket");
244 }
245
246 #[test]
247 fn parses_s3_cp_to_object() {
248 let c = cmd(&["aws", "s3", "cp", "/tmp/file.txt", "s3://my-bucket/key.txt"]);
249 let r = parse(&c);
250 assert_eq!(r.len(), 1);
251 assert_eq!(r[0].service, "s3");
252 assert_eq!(r[0].resource_type, "object");
253 assert_eq!(r[0].identifier, "s3://my-bucket/key.txt");
254 }
255
256 #[test]
257 fn parses_dynamodb_create_table() {
258 let c = cmd(&["aws", "dynamodb", "create-table", "--table-name", "users"]);
259 let r = parse(&c);
260 assert_eq!(r.len(), 1);
261 assert_eq!(r[0].service, "dynamodb");
262 assert_eq!(r[0].identifier, "users");
263 }
264
265 #[test]
266 fn parses_sqs_sns_ssm() {
267 assert_eq!(
268 parse(&cmd(&["aws", "sqs", "create-queue", "--queue-name", "q"]))[0].identifier,
269 "q"
270 );
271 assert_eq!(
272 parse(&cmd(&["aws", "sns", "create-topic", "--name", "t"]))[0].identifier,
273 "t"
274 );
275 assert_eq!(
276 parse(&cmd(&[
277 "aws",
278 "ssm",
279 "put-parameter",
280 "--name",
281 "/p",
282 "--value",
283 "x"
284 ]))[0]
285 .identifier,
286 "/p"
287 );
288 }
289
290 #[test]
291 fn parses_secrets_and_lambda() {
292 assert_eq!(
293 parse(&cmd(&[
294 "aws",
295 "secretsmanager",
296 "create-secret",
297 "--name",
298 "s"
299 ]))[0]
300 .identifier,
301 "s"
302 );
303 assert_eq!(
304 parse(&cmd(&[
305 "aws",
306 "lambda",
307 "create-function",
308 "--function-name",
309 "f"
310 ]))[0]
311 .identifier,
312 "f"
313 );
314 }
315
316 #[test]
317 fn unknown_verb_returns_empty() {
318 assert!(parse(&cmd(&["aws", "s3", "ls"])).is_empty());
319 assert!(parse(&cmd(&["aws", "s3api", "list-buckets"])).is_empty());
320 assert!(parse(&cmd(&["aws", "dynamodb", "scan", "--table-name", "t"])).is_empty());
321 }
322
323 #[test]
324 fn missing_name_flag_returns_empty() {
325 assert!(parse(&cmd(&["aws", "s3api", "create-bucket"])).is_empty());
326 assert!(parse(&cmd(&["aws", "dynamodb", "create-table"])).is_empty());
327 }
328
329 #[test]
330 fn empty_command_returns_empty() {
331 let empty: Vec<String> = Vec::new();
332 assert!(parse(&empty).is_empty());
333 assert!(parse(&cmd(&["aws"])).is_empty());
334 assert!(parse(&cmd(&["aws", "s3"])).is_empty());
335 }
336
337 #[test]
338 fn non_aws_command_returns_empty() {
339 assert!(parse(&cmd(&["gcloud", "storage", "buckets", "create"])).is_empty());
340 assert!(parse(&cmd(&["terraform", "apply"])).is_empty());
341 }
342
343 #[test]
344 fn handles_flag_equals_value_form() {
345 let c = cmd(&["aws", "s3api", "create-bucket", "--bucket=my-bucket"]);
346 let r = parse(&c);
347 assert_eq!(r.len(), 1);
348 assert_eq!(r[0].identifier, "my-bucket");
349 }
350
351 #[test]
352 fn s3_cp_bucket_only_dest_not_counted_as_object() {
353 let c = cmd(&["aws", "s3", "cp", "/tmp/file", "s3://bucket"]);
355 let r = parse(&c);
356 assert!(r.is_empty());
357 }
358
359 #[test]
360 fn parses_iam_create_role() {
361 let r = parse(&cmd(&[
362 "aws",
363 "iam",
364 "create-role",
365 "--role-name",
366 "MyRole",
367 ]));
368 assert_eq!(r[0].service, "iam");
369 assert_eq!(r[0].resource_type, "role");
370 assert_eq!(r[0].identifier, "MyRole");
371 }
372
373 #[test]
374 fn parses_cloudformation_stack() {
375 let r = parse(&cmd(&[
376 "aws",
377 "cloudformation",
378 "create-stack",
379 "--stack-name",
380 "prod-stack",
381 "--template-body",
382 "file://t.yaml",
383 ]));
384 assert_eq!(r[0].identifier, "prod-stack");
385 }
386
387 #[test]
388 fn parses_logs_create_log_stream_composite() {
389 let r = parse(&cmd(&[
390 "aws",
391 "logs",
392 "create-log-stream",
393 "--log-group-name",
394 "g",
395 "--log-stream-name",
396 "s",
397 ]));
398 assert_eq!(r[0].identifier, "g/s");
399 }
400}