1pub mod mappings;
2pub mod parser;
3
4use indexmap::IndexMap;
5use serde_yaml::Value;
6use std::fs;
7use std::path::Path;
8
9pub fn composerize(
10 input: &str,
11 _existing_compose: &str,
12 format: &str,
13 _indent: usize,
14) -> Result<String, String> {
15 let (image, command, args) = parser::parse_docker_command(input)?;
16
17 let network = args.get("network")
18 .or_else(|| args.get("net"))
19 .and_then(|v| v.first())
20 .map(|s| s.as_str())
21 .unwrap_or("default");
22
23 let mut service_value = parser::build_compose_value(&args, network)?;
24
25 if let Value::Mapping(ref mut map) = service_value {
27 map.insert(
28 Value::String("image".to_string()),
29 Value::String(image.clone())
30 );
31
32 if !command.is_empty() {
34 map.insert(
35 Value::String("command".to_string()),
36 Value::String(command.join(" "))
37 );
38 }
39 }
40
41 let service_name = get_service_name(&image);
42
43 let mut services = IndexMap::new();
44 services.insert(service_name, service_value);
45
46 let version = match format {
47 "v2x" => Some("2".to_string()),
48 "v3x" => Some("3".to_string()),
49 "latest" => None,
50 _ => return Err(format!("Unknown format: {}", format)),
51 };
52
53 let mut compose = IndexMap::new();
54
55 if let Some(v) = version {
56 compose.insert(
57 Value::String("version".to_string()),
58 Value::String(v)
59 );
60 }
61
62 compose.insert(
63 Value::String("services".to_string()),
64 Value::Mapping(services.into_iter()
65 .map(|(k, v)| (Value::String(k), v))
66 .collect())
67 );
68
69 let (networks, volumes) = collect_resources(&args);
71
72 if !networks.is_empty() {
74 let mut networks_map = IndexMap::new();
75 for net in networks {
76 if net != "default" && net != "bridge" && net != "host" && net != "none" {
77 let mut net_config = IndexMap::new();
78 net_config.insert(
79 Value::String("external".to_string()),
80 Value::Bool(true)
81 );
82 networks_map.insert(net.to_string(), Value::Mapping(net_config.into_iter().collect()));
83 }
84 }
85 if !networks_map.is_empty() {
86 compose.insert(
87 Value::String("networks".to_string()),
88 Value::Mapping(networks_map.into_iter()
89 .map(|(k, v)| (Value::String(k), v))
90 .collect())
91 );
92 }
93 }
94
95 if !volumes.is_empty() {
97 let mut volumes_map = IndexMap::new();
98 for vol in volumes {
99 volumes_map.insert(vol.to_string(), Value::Null);
100 }
101 compose.insert(
102 Value::String("volumes".to_string()),
103 Value::Mapping(volumes_map.into_iter()
104 .map(|(k, v)| (Value::String(k), v))
105 .collect())
106 );
107 }
108
109 let compose_value = Value::Mapping(compose.into_iter().collect());
110
111 serde_yaml::to_string(&compose_value)
112 .map_err(|e| format!("Failed to serialize: {}", e))
113}
114
115fn collect_resources(args: &IndexMap<String, Vec<String>>) -> (Vec<String>, Vec<String>) {
117 let mut networks = Vec::new();
118 let mut volumes = Vec::new();
119
120 if let Some(nets) = args.get("network").or_else(|| args.get("net")) {
122 for net in nets {
123 if !networks.contains(net) {
124 networks.push(net.clone());
125 }
126 }
127 }
128
129 if let Some(vols) = args.get("volume").or_else(|| args.get("v")) {
131 for vol in vols {
132 if !vol.starts_with('/') && !vol.starts_with('.') && !vol.starts_with('~') {
134 if let Some(vol_name) = vol.split(':').next() {
135 if !volumes.contains(&vol_name.to_string()) {
136 volumes.push(vol_name.to_string());
137 }
138 }
139 }
140 }
141 }
142
143 (networks, volumes)
144}
145
146pub fn get_service_name(image: &str) -> String {
147 let name = if image.contains('/') {
148 image.split('/').last().unwrap_or(image)
149 } else {
150 image
151 };
152
153 let name = if name.contains(':') {
154 name.split(':').next().unwrap_or(name)
155 } else {
156 name
157 };
158
159 name.to_string()
160}
161
162pub fn composerize_to_json(
164 input: &str,
165 _existing_compose: &str,
166 format: &str,
167 indent: usize,
168) -> Result<String, String> {
169 let (image, command, args) = parser::parse_docker_command(input)?;
170
171 let network = args
172 .get("network")
173 .or_else(|| args.get("net"))
174 .and_then(|v| v.first())
175 .map(|s| s.as_str())
176 .unwrap_or("default");
177
178 let mut service_value = parser::build_compose_value(&args, network)?;
179
180 if let Value::Mapping(ref mut map) = service_value {
181 map.insert(
182 Value::String("image".to_string()),
183 Value::String(image.clone()),
184 );
185
186 if !command.is_empty() {
187 map.insert(
188 Value::String("command".to_string()),
189 Value::String(command.join(" ")),
190 );
191 }
192 }
193
194 let service_name = get_service_name(&image);
195
196 let mut services = IndexMap::new();
197 services.insert(service_name, service_value);
198
199 let version = match format {
200 "v2x" => Some("2".to_string()),
201 "v3x" => Some("3".to_string()),
202 "latest" => None,
203 _ => return Err(format!("Unknown format: {}", format)),
204 };
205
206 let mut compose = IndexMap::new();
207
208 if let Some(v) = version {
209 compose.insert(Value::String("version".to_string()), Value::String(v));
210 }
211
212 compose.insert(
213 Value::String("services".to_string()),
214 Value::Mapping(
215 services
216 .into_iter()
217 .map(|(k, v)| (Value::String(k), v))
218 .collect(),
219 ),
220 );
221
222 let (networks, volumes) = collect_resources(&args);
224
225 if !networks.is_empty() {
227 let mut networks_map = IndexMap::new();
228 for net in networks {
229 if net != "default" && net != "bridge" && net != "host" && net != "none" {
230 let mut net_config = IndexMap::new();
231 net_config.insert(
232 Value::String("external".to_string()),
233 Value::Bool(true)
234 );
235 networks_map.insert(net.to_string(), Value::Mapping(net_config.into_iter().collect()));
236 }
237 }
238 if !networks_map.is_empty() {
239 compose.insert(
240 Value::String("networks".to_string()),
241 Value::Mapping(networks_map.into_iter()
242 .map(|(k, v)| (Value::String(k), v))
243 .collect())
244 );
245 }
246 }
247
248 if !volumes.is_empty() {
250 let mut volumes_map = IndexMap::new();
251 for vol in volumes {
252 volumes_map.insert(vol.to_string(), Value::Null);
253 }
254 compose.insert(
255 Value::String("volumes".to_string()),
256 Value::Mapping(volumes_map.into_iter()
257 .map(|(k, v)| (Value::String(k), v))
258 .collect())
259 );
260 }
261
262 let compose_value = Value::Mapping(compose.into_iter().collect());
263
264 let json_value: serde_json::Value = serde_yaml::from_value(compose_value)
266 .map_err(|e| format!("Failed to convert to JSON: {}", e))?;
267
268 if indent > 0 {
269 serde_json::to_string_pretty(&json_value)
270 .map_err(|e| format!("Failed to serialize JSON: {}", e))
271 } else {
272 serde_json::to_string(&json_value)
273 .map_err(|e| format!("Failed to serialize JSON: {}", e))
274 }
275}
276
277pub fn yaml_to_json(yaml_content: &str, pretty: bool) -> Result<String, String> {
279 let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_content)
280 .map_err(|e| format!("Failed to parse YAML: {}", e))?;
281
282 let json_value: serde_json::Value = serde_yaml::from_value(yaml_value)
283 .map_err(|e| format!("Failed to convert to JSON: {}", e))?;
284
285 if pretty {
286 serde_json::to_string_pretty(&json_value)
287 .map_err(|e| format!("Failed to serialize JSON: {}", e))
288 } else {
289 serde_json::to_string(&json_value)
290 .map_err(|e| format!("Failed to serialize JSON: {}", e))
291 }
292}
293
294pub fn json_to_yaml(json_content: &str) -> Result<String, String> {
296 let json_value: serde_json::Value = serde_json::from_str(json_content)
297 .map_err(|e| format!("Failed to parse JSON: {}", e))?;
298
299 let yaml_value: serde_yaml::Value = serde_json::from_value(json_value)
300 .map_err(|e| format!("Failed to convert to YAML: {}", e))?;
301
302 serde_yaml::to_string(&yaml_value).map_err(|e| format!("Failed to serialize YAML: {}", e))
303}
304
305pub fn convert_file(
307 input_path: &Path,
308 output_path: &Path,
309 output_format: &str,
310) -> Result<(), String> {
311 let content =
312 fs::read_to_string(input_path).map_err(|e| format!("Failed to read file: {}", e))?;
313
314 let input_ext = input_path
315 .extension()
316 .and_then(|s| s.to_str())
317 .unwrap_or("");
318
319 let result = match (input_ext, output_format) {
320 ("yml" | "yaml", "json") => yaml_to_json(&content, true)?,
321 ("json", "yml" | "yaml") => json_to_yaml(&content)?,
322 _ => {
323 return Err(format!(
324 "Unsupported conversion: {} to {}",
325 input_ext, output_format
326 ))
327 }
328 };
329
330 fs::write(output_path, result).map_err(|e| format!("Failed to write file: {}", e))?;
331
332 Ok(())
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn test_simple_nginx() {
341 let result = composerize("docker run nginx", "", "latest", 2);
342 assert!(result.is_ok());
343 let yaml = result.unwrap();
344 assert!(yaml.contains("nginx"));
345 assert!(yaml.contains("image: nginx"));
346 }
347
348 #[test]
349 fn test_with_ports() {
350 let result = composerize("docker run -p 80:80 nginx", "", "latest", 2);
351 assert!(result.is_ok());
352 let yaml = result.unwrap();
353 assert!(yaml.contains("ports:"));
354 assert!(yaml.contains("80:80"));
355 }
356
357 #[test]
358 fn test_with_environment() {
359 let result = composerize("docker run -e NODE_ENV=production nginx", "", "latest", 2);
360 assert!(result.is_ok());
361 let yaml = result.unwrap();
362 assert!(yaml.contains("environment:"));
363 assert!(yaml.contains("NODE_ENV=production"));
364 }
365
366 #[test]
367 fn test_with_volumes() {
368 let result = composerize("docker run -v /data:/app nginx", "", "latest", 2);
369 assert!(result.is_ok());
370 let yaml = result.unwrap();
371 assert!(yaml.contains("volumes:"));
372 assert!(yaml.contains("/data:/app"));
373 }
374
375 #[test]
376 fn test_with_name() {
377 let result = composerize("docker run --name my-app nginx", "", "latest", 2);
378 assert!(result.is_ok());
379 let yaml = result.unwrap();
380 assert!(yaml.contains("container_name: my-app"));
381 }
382
383 #[test]
384 fn test_with_restart() {
385 let result = composerize("docker run --restart always nginx", "", "latest", 2);
386 assert!(result.is_ok());
387 let yaml = result.unwrap();
388 assert!(yaml.contains("restart: always"));
389 }
390
391 #[test]
392 fn test_privileged() {
393 let result = composerize("docker run --privileged nginx", "", "latest", 2);
394 if let Err(e) = &result {
395 eprintln!("Error: {}", e);
396 }
397 assert!(result.is_ok());
398 let yaml = result.unwrap();
399 assert!(yaml.contains("privileged: true"));
400 }
401
402 #[test]
403 fn test_interactive_tty() {
404 let result = composerize("docker run -it ubuntu bash", "", "latest", 2);
405 assert!(result.is_ok());
406 let yaml = result.unwrap();
407 assert!(yaml.contains("stdin_open: true"));
408 assert!(yaml.contains("tty: true"));
409 assert!(yaml.contains("command: bash"));
410 }
411
412 #[test]
413 fn test_memory_limit() {
414 let result = composerize("docker run --memory 512m nginx", "", "latest", 2);
415 assert!(result.is_ok());
416 let yaml = result.unwrap();
417 assert!(yaml.contains("memory: 512m"));
418 }
419
420 #[test]
421 fn test_cpu_limit() {
422 let result = composerize("docker run --cpus 2.5 nginx", "", "latest", 2);
423 assert!(result.is_ok());
424 let yaml = result.unwrap();
425 assert!(yaml.contains("cpus: 2.5"));
426 }
427
428 #[test]
429 fn test_multiple_ports() {
430 let result = composerize("docker run -p 80:80 -p 443:443 nginx", "", "latest", 2);
431 assert!(result.is_ok());
432 let yaml = result.unwrap();
433 assert!(yaml.contains("80:80"));
434 assert!(yaml.contains("443:443"));
435 }
436
437 #[test]
438 fn test_multiple_env_vars() {
439 let result = composerize("docker run -e VAR1=value1 -e VAR2=value2 nginx", "", "latest", 2);
440 assert!(result.is_ok());
441 let yaml = result.unwrap();
442 assert!(yaml.contains("VAR1=value1"));
443 assert!(yaml.contains("VAR2=value2"));
444 }
445
446 #[test]
447 fn test_complex_command() {
448 let result = composerize(
449 "docker run -d -p 8080:80 --name web -e NODE_ENV=production --restart always nginx:alpine",
450 "",
451 "latest",
452 2
453 );
454 assert!(result.is_ok());
455 let yaml = result.unwrap();
456 assert!(yaml.contains("8080:80"));
457 assert!(yaml.contains("container_name: web"));
458 assert!(yaml.contains("NODE_ENV=production"));
459 assert!(yaml.contains("restart: always"));
460 assert!(yaml.contains("image: nginx:alpine"));
461 }
462
463 #[test]
464 fn test_version_v2x() {
465 let result = composerize("docker run nginx", "", "v2x", 2);
466 assert!(result.is_ok());
467 let yaml = result.unwrap();
468 assert!(yaml.contains("version: '2'") || yaml.contains("version: \"2\""));
469 }
470
471 #[test]
472 fn test_version_v3x() {
473 let result = composerize("docker run nginx", "", "v3x", 2);
474 assert!(result.is_ok());
475 let yaml = result.unwrap();
476 assert!(yaml.contains("version: '3'") || yaml.contains("version: \"3\""));
477 }
478
479 #[test]
480 fn test_version_latest_no_version() {
481 let result = composerize("docker run nginx", "", "latest", 2);
482 assert!(result.is_ok());
483 let yaml = result.unwrap();
484 assert!(!yaml.contains("version:"));
485 }
486
487 #[test]
488 fn test_get_service_name_simple() {
489 assert_eq!(get_service_name("nginx"), "nginx");
490 }
491
492 #[test]
493 fn test_get_service_name_with_tag() {
494 assert_eq!(get_service_name("nginx:alpine"), "nginx");
495 }
496
497 #[test]
498 fn test_get_service_name_with_registry() {
499 assert_eq!(get_service_name("docker.io/library/nginx"), "nginx");
500 }
501
502 #[test]
503 fn test_get_service_name_with_registry_and_tag() {
504 assert_eq!(get_service_name("docker.io/library/nginx:1.21"), "nginx");
505 }
506
507 #[test]
508 fn test_healthcheck() {
509 let result = composerize(
510 "docker run --health-cmd 'curl -f http://localhost' --health-interval 30s nginx",
511 "",
512 "latest",
513 2
514 );
515 assert!(result.is_ok());
516 let yaml = result.unwrap();
517 assert!(yaml.contains("healthcheck:"));
518 assert!(yaml.contains("test:"));
519 assert!(yaml.contains("interval: 30s"));
520 }
521
522 #[test]
523 fn test_labels() {
524 let result = composerize("docker run -l app=web -l env=prod nginx", "", "latest", 2);
525 assert!(result.is_ok());
526 let yaml = result.unwrap();
527 assert!(yaml.contains("labels:"));
528 assert!(yaml.contains("app=web"));
529 assert!(yaml.contains("env=prod"));
530 }
531
532 #[test]
533 fn test_hostname() {
534 let result = composerize("docker run --hostname myhost nginx", "", "latest", 2);
535 assert!(result.is_ok());
536 let yaml = result.unwrap();
537 assert!(yaml.contains("hostname: myhost"));
538 }
539
540 #[test]
541 fn test_user() {
542 let result = composerize("docker run --user 1000:1000 nginx", "", "latest", 2);
543 assert!(result.is_ok());
544 let yaml = result.unwrap();
545 assert!(yaml.contains("user: 1000:1000"));
546 }
547
548 #[test]
549 fn test_workdir() {
550 let result = composerize("docker run --workdir /app nginx", "", "latest", 2);
551 assert!(result.is_ok());
552 let yaml = result.unwrap();
553 assert!(yaml.contains("working_dir: /app"));
554 }
555
556 #[test]
557 fn test_entrypoint() {
558 let result = composerize("docker run --entrypoint /bin/sh nginx", "", "latest", 2);
559 assert!(result.is_ok());
560 let yaml = result.unwrap();
561 assert!(yaml.contains("entrypoint:"));
562 assert!(yaml.contains("/bin/sh"));
563 }
564
565 #[test]
566 fn test_cap_add() {
567 let result = composerize("docker run --cap-add NET_ADMIN nginx", "", "latest", 2);
568 assert!(result.is_ok());
569 let yaml = result.unwrap();
570 assert!(yaml.contains("cap_add:"));
571 assert!(yaml.contains("NET_ADMIN"));
572 }
573
574 #[test]
575 fn test_dns() {
576 let result = composerize("docker run --dns 8.8.8.8 nginx", "", "latest", 2);
577 assert!(result.is_ok());
578 let yaml = result.unwrap();
579 assert!(yaml.contains("dns:"));
580 assert!(yaml.contains("8.8.8.8"));
581 }
582
583 #[test]
584 fn test_no_image_error() {
585 let result = composerize("docker run -d", "", "latest", 2);
586 assert!(result.is_err());
587 assert!(result.unwrap_err().contains("No image specified"));
588 }
589
590 #[test]
591 fn test_invalid_format() {
592 let result = composerize("docker run nginx", "", "invalid", 2);
593 assert!(result.is_err());
594 assert!(result.unwrap_err().contains("Unknown format"));
595 }
596
597 #[test]
598 fn test_composerize_to_json() {
599 let result = composerize_to_json("docker run -p 80:80 nginx", "", "latest", 2);
600 assert!(result.is_ok());
601 let json = result.unwrap();
602 assert!(json.contains("\"nginx\""));
603 assert!(json.contains("\"80:80\""));
604 assert!(json.contains("\"services\""));
605 }
606
607 #[test]
608 fn test_yaml_to_json() {
609 let yaml = r#"
610services:
611 nginx:
612 image: nginx
613 ports:
614 - 80:80
615"#;
616 let result = yaml_to_json(yaml, true);
617 assert!(result.is_ok());
618 let json = result.unwrap();
619 assert!(json.contains("\"nginx\""));
620 assert!(json.contains("\"80:80\""));
621 }
622
623 #[test]
624 fn test_json_to_yaml() {
625 let json = r#"{
626 "services": {
627 "nginx": {
628 "image": "nginx",
629 "ports": ["80:80"]
630 }
631 }
632}"#;
633 let result = json_to_yaml(json);
634 assert!(result.is_ok());
635 let yaml = result.unwrap();
636 assert!(yaml.contains("nginx"));
637 assert!(yaml.contains("80:80"));
638 }
639
640 #[test]
641 fn test_yaml_to_json_to_yaml() {
642 let original_yaml = r#"
643services:
644 nginx:
645 image: nginx
646 ports:
647 - 80:80
648"#;
649 let json = yaml_to_json(original_yaml, false).unwrap();
650 let yaml = json_to_yaml(&json).unwrap();
651 assert!(yaml.contains("nginx"));
652 assert!(yaml.contains("80:80"));
653 }
654
655 #[test]
656 fn test_json_with_complex_structure() {
657 let result = composerize_to_json(
658 "docker run -d -p 8080:80 -e NODE_ENV=production --restart always nginx",
659 "",
660 "v3x",
661 2,
662 );
663 assert!(result.is_ok());
664 let json = result.unwrap();
665 assert!(json.contains("\"version\""));
666 assert!(json.contains("\"3\""));
667 assert!(json.contains("\"NODE_ENV=production\""));
668 assert!(json.contains("\"restart\""));
669 assert!(json.contains("\"always\""));
670 }
671
672 #[test]
673 fn test_networks_section() {
674 let result = composerize("docker run --network ml-net nginx", "", "latest", 2);
675 assert!(result.is_ok());
676 let yaml = result.unwrap();
677 assert!(yaml.contains("networks:"));
678 assert!(yaml.contains("ml-net:"));
679 assert!(yaml.contains("external: true"));
680 }
681
682 #[test]
683 fn test_volumes_section() {
684 let result = composerize("docker run -v data:/data -v cache:/cache nginx", "", "latest", 2);
685 assert!(result.is_ok());
686 let yaml = result.unwrap();
687 assert!(yaml.contains("volumes:"));
688 assert!(yaml.contains("data:"));
689 assert!(yaml.contains("cache:"));
690 }
691
692 #[test]
693 fn test_no_volumes_for_bind_mounts() {
694 let result = composerize("docker run -v /host:/container nginx", "", "latest", 2);
695 assert!(result.is_ok());
696 let yaml = result.unwrap();
697 let lines: Vec<&str> = yaml.lines().collect();
699 let volumes_line = lines.iter().position(|&l| l.starts_with("volumes:"));
700 assert!(volumes_line.is_none());
701 }
702
703 #[test]
704 fn test_mixed_volumes() {
705 let result = composerize("docker run -v data:/data -v /host:/host nginx", "", "latest", 2);
706 assert!(result.is_ok());
707 let yaml = result.unwrap();
708 assert!(yaml.contains("volumes:"));
710 assert!(yaml.contains("data:"));
711 assert!(yaml.contains("- data:/data"));
713 assert!(yaml.contains("- /host:/host"));
714 }
715
716 #[test]
717 fn test_default_network_not_in_section() {
718 let result = composerize("docker run nginx", "", "latest", 2);
719 assert!(result.is_ok());
720 let yaml = result.unwrap();
721 let lines: Vec<&str> = yaml.lines().collect();
723 let networks_line = lines.iter().position(|&l| l.starts_with("networks:"));
724 assert!(networks_line.is_none());
725 }
726
727 #[test]
728 fn test_full_compose_with_resources() {
729 let result = composerize(
730 "docker run -d --name ml-service --network ml-net -v ml-models:/models -v ml-cache:/cache nginx",
731 "",
732 "latest",
733 2
734 );
735 assert!(result.is_ok());
736 let yaml = result.unwrap();
737 assert!(yaml.contains("services:"));
739 assert!(yaml.contains("networks:"));
740 assert!(yaml.contains("volumes:"));
741 assert!(yaml.contains("ml-net:"));
742 assert!(yaml.contains("ml-models:"));
743 assert!(yaml.contains("ml-cache:"));
744 }
745
746 #[test]
747 fn test_mount_to_volume_conversion() {
748 let result = composerize(
749 "docker run --mount=type=bind,source=/host/data,target=/container/data,readonly nginx",
750 "",
751 "latest",
752 2
753 );
754 assert!(result.is_ok());
755 let yaml = result.unwrap();
756 assert!(yaml.contains("/host/data:/container/data:ro"));
758 assert!(!yaml.contains("type=bind"));
760 }
761
762 #[test]
763 fn test_mount_without_readonly() {
764 let result = composerize(
765 "docker run --mount=type=bind,source=/src,target=/dst nginx",
766 "",
767 "latest",
768 2
769 );
770 assert!(result.is_ok());
771 let yaml = result.unwrap();
772 assert!(yaml.contains("/src:/dst"));
773 assert!(!yaml.contains(":ro"));
774 }
775}