1use std::collections::HashMap;
7use yaml_rust2::{Yaml, YamlLoader};
8
9#[derive(Debug, Clone, thiserror::Error)]
11pub enum ParseError {
12 #[error("YAML parse error: {0}")]
13 YamlError(String),
14 #[error("Empty document")]
15 EmptyDocument,
16 #[error("Invalid structure: {0}")]
17 InvalidStructure(String),
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub struct Position {
23 pub line: u32,
24 pub column: u32,
25}
26
27impl Position {
28 pub fn new(line: u32, column: u32) -> Self {
29 Self { line, column }
30 }
31}
32
33#[derive(Debug, Clone, Default)]
35pub struct ComposeFile {
36 pub version: Option<String>,
38 pub version_pos: Option<Position>,
40 pub name: Option<String>,
42 pub name_pos: Option<Position>,
44 pub services: HashMap<String, Service>,
46 pub services_pos: Option<Position>,
48 pub networks: HashMap<String, serde_json::Value>,
50 pub volumes: HashMap<String, serde_json::Value>,
52 pub configs: HashMap<String, serde_json::Value>,
54 pub secrets: HashMap<String, serde_json::Value>,
56 pub top_level_keys: Vec<String>,
58 pub source: String,
60}
61
62#[derive(Debug, Clone, Default)]
64pub struct Service {
65 pub name: String,
67 pub position: Position,
69 pub image: Option<String>,
71 pub image_pos: Option<Position>,
73 pub build: Option<ServiceBuild>,
75 pub build_pos: Option<Position>,
77 pub container_name: Option<String>,
79 pub container_name_pos: Option<Position>,
81 pub ports: Vec<ServicePort>,
83 pub ports_pos: Option<Position>,
85 pub volumes: Vec<ServiceVolume>,
87 pub volumes_pos: Option<Position>,
89 pub depends_on: Vec<String>,
91 pub depends_on_pos: Option<Position>,
93 pub environment: HashMap<String, String>,
95 pub pull_policy: Option<String>,
97 pub keys: Vec<String>,
99 pub raw: Option<Yaml>,
101}
102
103#[derive(Debug, Clone)]
105pub enum ServiceBuild {
106 Simple(String),
108 Extended {
110 context: Option<String>,
111 dockerfile: Option<String>,
112 args: HashMap<String, String>,
113 target: Option<String>,
114 },
115}
116
117impl Default for ServiceBuild {
118 fn default() -> Self {
119 Self::Simple(".".to_string())
120 }
121}
122
123#[derive(Debug, Clone)]
125pub struct ServicePort {
126 pub raw: String,
128 pub position: Position,
130 pub is_quoted: bool,
132 pub host_port: Option<u16>,
134 pub container_port: u16,
136 pub host_ip: Option<String>,
138 pub protocol: Option<String>,
140}
141
142impl ServicePort {
143 pub fn parse(raw: &str, position: Position, is_quoted: bool) -> Option<Self> {
145 let raw = raw.trim();
146 if raw.is_empty() {
147 return None;
148 }
149
150 let (port_part, protocol) = if raw.contains('/') {
152 let parts: Vec<&str> = raw.rsplitn(2, '/').collect();
153 (parts[1], Some(parts[0].to_string()))
154 } else {
155 (raw, None)
156 };
157
158 let parts: Vec<&str> = port_part.split(':').collect();
164
165 let (host_ip, host_port, container_port) = match parts.len() {
166 1 => {
167 let cp = parts[0].parse().ok()?;
169 (None, None, cp)
170 }
171 2 => {
172 let hp = parts[0].parse().ok();
174 let cp = parts[1].parse().ok()?;
175 (None, hp, cp)
176 }
177 3 => {
178 let ip = Some(parts[0].to_string());
180 let hp = parts[1].parse().ok();
181 let cp = parts[2].parse().ok()?;
182 (ip, hp, cp)
183 }
184 _ => return None,
185 };
186
187 Some(Self {
188 raw: raw.to_string(),
189 position,
190 is_quoted,
191 host_port,
192 container_port,
193 host_ip,
194 protocol,
195 })
196 }
197
198 pub fn has_explicit_interface(&self) -> bool {
200 self.host_ip.is_some()
201 }
202
203 pub fn exported_port(&self) -> Option<String> {
205 self.host_port.map(|p| {
206 if let Some(ip) = &self.host_ip {
207 format!("{}:{}", ip, p)
208 } else {
209 p.to_string()
210 }
211 })
212 }
213}
214
215#[derive(Debug, Clone)]
217pub struct ServiceVolume {
218 pub raw: String,
220 pub position: Position,
222 pub is_quoted: bool,
224 pub source: Option<String>,
226 pub target: String,
228 pub options: Option<String>,
230}
231
232impl ServiceVolume {
233 pub fn parse(raw: &str, position: Position, is_quoted: bool) -> Option<Self> {
235 let raw = raw.trim();
236 if raw.is_empty() {
237 return None;
238 }
239
240 let parts: Vec<&str> = raw.splitn(3, ':').collect();
246
247 let (source, target, options) = match parts.len() {
248 1 => (None, parts[0].to_string(), None),
249 2 => (Some(parts[0].to_string()), parts[1].to_string(), None),
250 3 => (
251 Some(parts[0].to_string()),
252 parts[1].to_string(),
253 Some(parts[2].to_string()),
254 ),
255 _ => return None,
256 };
257
258 Some(Self {
259 raw: raw.to_string(),
260 position,
261 is_quoted,
262 source,
263 target,
264 options,
265 })
266 }
267}
268
269pub fn parse_compose(content: &str) -> Result<ComposeFile, ParseError> {
271 parse_compose_with_positions(content)
272}
273
274pub fn parse_compose_with_positions(content: &str) -> Result<ComposeFile, ParseError> {
276 let docs =
277 YamlLoader::load_from_str(content).map_err(|e| ParseError::YamlError(e.to_string()))?;
278
279 let doc = docs.into_iter().next().ok_or(ParseError::EmptyDocument)?;
280
281 let hash = match &doc {
282 Yaml::Hash(h) => h,
283 _ => {
284 return Err(ParseError::InvalidStructure(
285 "Root must be a mapping".to_string(),
286 ));
287 }
288 };
289
290 let mut compose = ComposeFile {
291 source: content.to_string(),
292 ..Default::default()
293 };
294
295 for (key, _) in hash {
297 if let Yaml::String(k) = key {
298 compose.top_level_keys.push(k.clone());
299 }
300 }
301
302 if let Some(Yaml::String(version)) = hash.get(&Yaml::String("version".to_string())) {
304 compose.version = Some(version.clone());
305 compose.version_pos =
306 super::find_line_for_key(content, &["version"]).map(|l| Position::new(l, 1));
307 }
308
309 if let Some(Yaml::String(name)) = hash.get(&Yaml::String("name".to_string())) {
311 compose.name = Some(name.clone());
312 compose.name_pos =
313 super::find_line_for_key(content, &["name"]).map(|l| Position::new(l, 1));
314 }
315
316 if let Some(Yaml::Hash(services)) = hash.get(&Yaml::String("services".to_string())) {
318 compose.services_pos =
319 super::find_line_for_key(content, &["services"]).map(|l| Position::new(l, 1));
320
321 for (name_yaml, service_yaml) in services {
322 if let Yaml::String(name) = name_yaml {
323 let service = parse_service(name, service_yaml, content)?;
324 compose.services.insert(name.clone(), service);
325 }
326 }
327 }
328
329 if let Some(Yaml::Hash(networks)) = hash.get(&Yaml::String("networks".to_string())) {
331 for (name_yaml, value_yaml) in networks {
332 if let Yaml::String(name) = name_yaml {
333 compose
334 .networks
335 .insert(name.clone(), yaml_to_json(value_yaml));
336 }
337 }
338 }
339
340 if let Some(Yaml::Hash(volumes)) = hash.get(&Yaml::String("volumes".to_string())) {
342 for (name_yaml, value_yaml) in volumes {
343 if let Yaml::String(name) = name_yaml {
344 compose
345 .volumes
346 .insert(name.clone(), yaml_to_json(value_yaml));
347 }
348 }
349 }
350
351 Ok(compose)
352}
353
354fn parse_service(name: &str, yaml: &Yaml, source: &str) -> Result<Service, ParseError> {
356 let hash = match yaml {
357 Yaml::Hash(h) => h,
358 Yaml::Null => {
359 return Ok(Service {
360 name: name.to_string(),
361 ..Default::default()
362 });
363 }
364 _ => {
365 return Err(ParseError::InvalidStructure(format!(
366 "Service '{}' must be a mapping",
367 name
368 )));
369 }
370 };
371
372 let position = super::find_line_for_service(source, name)
373 .map(|l| Position::new(l, 1))
374 .unwrap_or_default();
375
376 let mut service = Service {
377 name: name.to_string(),
378 position,
379 raw: Some(yaml.clone()),
380 ..Default::default()
381 };
382
383 for (key, _) in hash {
385 if let Yaml::String(k) = key {
386 service.keys.push(k.clone());
387 }
388 }
389
390 if let Some(Yaml::String(image)) = hash.get(&Yaml::String("image".to_string())) {
392 service.image = Some(image.clone());
393 service.image_pos =
394 super::find_line_for_service_key(source, name, "image").map(|l| Position::new(l, 1));
395 }
396
397 if let Some(build_yaml) = hash.get(&Yaml::String("build".to_string())) {
399 service.build_pos =
400 super::find_line_for_service_key(source, name, "build").map(|l| Position::new(l, 1));
401
402 service.build = Some(match build_yaml {
403 Yaml::String(s) => ServiceBuild::Simple(s.clone()),
404 Yaml::Hash(h) => {
405 let context = h
406 .get(&Yaml::String("context".to_string()))
407 .and_then(|v| match v {
408 Yaml::String(s) => Some(s.clone()),
409 _ => None,
410 });
411 let dockerfile =
412 h.get(&Yaml::String("dockerfile".to_string()))
413 .and_then(|v| match v {
414 Yaml::String(s) => Some(s.clone()),
415 _ => None,
416 });
417 let target = h
418 .get(&Yaml::String("target".to_string()))
419 .and_then(|v| match v {
420 Yaml::String(s) => Some(s.clone()),
421 _ => None,
422 });
423
424 ServiceBuild::Extended {
425 context,
426 dockerfile,
427 args: HashMap::new(),
428 target,
429 }
430 }
431 _ => ServiceBuild::Simple(".".to_string()),
432 });
433 }
434
435 if let Some(Yaml::String(container_name)) =
437 hash.get(&Yaml::String("container_name".to_string()))
438 {
439 service.container_name = Some(container_name.clone());
440 service.container_name_pos =
441 super::find_line_for_service_key(source, name, "container_name")
442 .map(|l| Position::new(l, 1));
443 }
444
445 if let Some(Yaml::Array(ports)) = hash.get(&Yaml::String("ports".to_string())) {
447 service.ports_pos =
448 super::find_line_for_service_key(source, name, "ports").map(|l| Position::new(l, 1));
449
450 let ports_start_line = service.ports_pos.map(|p| p.line).unwrap_or(1);
451
452 for (idx, port_yaml) in ports.iter().enumerate() {
453 let line = ports_start_line + 1 + idx as u32;
454 let position = Position::new(line, 1);
455
456 match port_yaml {
457 Yaml::String(s) => {
458 let is_quoted = is_value_quoted_at_line(source, line);
460 if let Some(port) = ServicePort::parse(s, position, is_quoted) {
461 service.ports.push(port);
462 }
463 }
464 Yaml::Integer(i) => {
465 let raw = i.to_string();
467 if let Some(port) = ServicePort::parse(&raw, position, false) {
468 service.ports.push(port);
469 }
470 }
471 Yaml::Hash(h) => {
472 let target = h
474 .get(&Yaml::String("target".to_string()))
475 .and_then(|v| match v {
476 Yaml::Integer(i) => Some(*i as u16),
477 Yaml::String(s) => s.parse().ok(),
478 _ => None,
479 });
480 let published =
481 h.get(&Yaml::String("published".to_string()))
482 .and_then(|v| match v {
483 Yaml::Integer(i) => Some(*i as u16),
484 Yaml::String(s) => s.parse().ok(),
485 _ => None,
486 });
487 let host_ip =
488 h.get(&Yaml::String("host_ip".to_string()))
489 .and_then(|v| match v {
490 Yaml::String(s) => Some(s.clone()),
491 _ => None,
492 });
493
494 if let Some(container_port) = target {
495 service.ports.push(ServicePort {
496 raw: format!(
497 "{}:{}",
498 published.unwrap_or(container_port),
499 container_port
500 ),
501 position,
502 is_quoted: false,
503 host_port: published,
504 container_port,
505 host_ip,
506 protocol: None,
507 });
508 }
509 }
510 _ => {}
511 }
512 }
513 }
514
515 if let Some(Yaml::Array(volumes)) = hash.get(&Yaml::String("volumes".to_string())) {
517 service.volumes_pos =
518 super::find_line_for_service_key(source, name, "volumes").map(|l| Position::new(l, 1));
519
520 let volumes_start_line = service.volumes_pos.map(|p| p.line).unwrap_or(1);
521
522 for (idx, vol_yaml) in volumes.iter().enumerate() {
523 let line = volumes_start_line + 1 + idx as u32;
524 let position = Position::new(line, 1);
525
526 if let Yaml::String(s) = vol_yaml {
527 let is_quoted = is_value_quoted_at_line(source, line);
528 if let Some(vol) = ServiceVolume::parse(s, position, is_quoted) {
529 service.volumes.push(vol);
530 }
531 }
532 }
533 }
534
535 if let Some(depends_on_yaml) = hash.get(&Yaml::String("depends_on".to_string())) {
537 service.depends_on_pos = super::find_line_for_service_key(source, name, "depends_on")
538 .map(|l| Position::new(l, 1));
539
540 match depends_on_yaml {
541 Yaml::Array(arr) => {
542 for dep in arr {
543 if let Yaml::String(s) = dep {
544 service.depends_on.push(s.clone());
545 }
546 }
547 }
548 Yaml::Hash(h) => {
549 for (dep_name, _) in h {
551 if let Yaml::String(s) = dep_name {
552 service.depends_on.push(s.clone());
553 }
554 }
555 }
556 _ => {}
557 }
558 }
559
560 if let Some(env_yaml) = hash.get(&Yaml::String("environment".to_string())) {
562 match env_yaml {
563 Yaml::Hash(h) => {
564 for (key, value) in h {
565 if let (Yaml::String(k), v) = (key, value) {
566 let val = match v {
567 Yaml::String(s) => s.clone(),
568 Yaml::Integer(i) => i.to_string(),
569 Yaml::Boolean(b) => b.to_string(),
570 Yaml::Null => String::new(),
571 _ => continue,
572 };
573 service.environment.insert(k.clone(), val);
574 }
575 }
576 }
577 Yaml::Array(arr) => {
578 for item in arr {
579 if let Yaml::String(s) = item {
580 if let Some((k, v)) = s.split_once('=') {
581 service.environment.insert(k.to_string(), v.to_string());
582 } else {
583 service.environment.insert(s.clone(), String::new());
584 }
585 }
586 }
587 }
588 _ => {}
589 }
590 }
591
592 if let Some(Yaml::String(pull_policy)) = hash.get(&Yaml::String("pull_policy".to_string())) {
594 service.pull_policy = Some(pull_policy.clone());
595 }
596
597 Ok(service)
598}
599
600fn is_value_quoted_at_line(source: &str, line: u32) -> bool {
602 let lines: Vec<&str> = source.lines().collect();
603 if let Some(line_content) = lines.get((line - 1) as usize) {
604 let trimmed = line_content.trim();
605 if trimmed.starts_with('-') {
607 let after_dash = trimmed.trim_start_matches('-').trim();
608 return after_dash.starts_with('"') || after_dash.starts_with('\'');
609 }
610 if let Some(pos) = trimmed.find(':') {
612 let after_colon = trimmed[pos + 1..].trim();
613 return after_colon.starts_with('"') || after_colon.starts_with('\'');
614 }
615 }
616 false
617}
618
619fn yaml_to_json(yaml: &Yaml) -> serde_json::Value {
621 match yaml {
622 Yaml::Null => serde_json::Value::Null,
623 Yaml::Boolean(b) => serde_json::Value::Bool(*b),
624 Yaml::Integer(i) => serde_json::json!(i),
625 Yaml::Real(r) => {
626 if let Ok(f) = r.parse::<f64>() {
627 serde_json::json!(f)
628 } else {
629 serde_json::Value::String(r.clone())
630 }
631 }
632 Yaml::String(s) => serde_json::Value::String(s.clone()),
633 Yaml::Array(arr) => serde_json::Value::Array(arr.iter().map(yaml_to_json).collect()),
634 Yaml::Hash(h) => {
635 let mut map = serde_json::Map::new();
636 for (k, v) in h {
637 if let Yaml::String(key) = k {
638 map.insert(key.clone(), yaml_to_json(v));
639 }
640 }
641 serde_json::Value::Object(map)
642 }
643 _ => serde_json::Value::Null,
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
652 fn test_parse_simple_compose() {
653 let yaml = r#"
654version: "3.8"
655name: myproject
656services:
657 web:
658 image: nginx:latest
659 ports:
660 - "8080:80"
661 db:
662 image: postgres:15
663"#;
664
665 let compose = parse_compose(yaml).unwrap();
666 assert_eq!(compose.version, Some("3.8".to_string()));
667 assert_eq!(compose.name, Some("myproject".to_string()));
668 assert_eq!(compose.services.len(), 2);
669
670 let web = compose.services.get("web").unwrap();
671 assert_eq!(web.image, Some("nginx:latest".to_string()));
672 assert_eq!(web.ports.len(), 1);
673 assert_eq!(web.ports[0].container_port, 80);
674 assert_eq!(web.ports[0].host_port, Some(8080));
675 }
676
677 #[test]
678 fn test_parse_build_and_image() {
679 let yaml = r#"
680services:
681 app:
682 build: .
683 image: myapp:latest
684"#;
685
686 let compose = parse_compose(yaml).unwrap();
687 let app = compose.services.get("app").unwrap();
688 assert!(app.build.is_some());
689 assert!(app.image.is_some());
690 }
691
692 #[test]
693 fn test_parse_port_formats() {
694 let yaml = r#"
695services:
696 web:
697 image: nginx
698 ports:
699 - 80
700 - "8080:80"
701 - "127.0.0.1:8081:80"
702"#;
703
704 let compose = parse_compose(yaml).unwrap();
705 let web = compose.services.get("web").unwrap();
706 assert_eq!(web.ports.len(), 3);
707
708 assert_eq!(web.ports[0].container_port, 80);
709 assert_eq!(web.ports[0].host_port, None);
710
711 assert_eq!(web.ports[1].container_port, 80);
712 assert_eq!(web.ports[1].host_port, Some(8080));
713
714 assert_eq!(web.ports[2].container_port, 80);
715 assert_eq!(web.ports[2].host_port, Some(8081));
716 assert_eq!(web.ports[2].host_ip, Some("127.0.0.1".to_string()));
717 }
718
719 #[test]
720 fn test_parse_depends_on() {
721 let yaml = r#"
722services:
723 web:
724 image: nginx
725 depends_on:
726 - db
727 - redis
728 db:
729 image: postgres
730 redis:
731 image: redis
732"#;
733
734 let compose = parse_compose(yaml).unwrap();
735 let web = compose.services.get("web").unwrap();
736 assert_eq!(web.depends_on, vec!["db", "redis"]);
737 }
738
739 #[test]
740 fn test_port_parsing() {
741 let pos = Position::new(1, 1);
742
743 let p1 = ServicePort::parse("80", pos, false).unwrap();
744 assert_eq!(p1.container_port, 80);
745 assert_eq!(p1.host_port, None);
746
747 let p2 = ServicePort::parse("8080:80", pos, true).unwrap();
748 assert_eq!(p2.container_port, 80);
749 assert_eq!(p2.host_port, Some(8080));
750 assert!(p2.is_quoted);
751
752 let p3 = ServicePort::parse("127.0.0.1:8080:80", pos, false).unwrap();
753 assert_eq!(p3.container_port, 80);
754 assert_eq!(p3.host_port, Some(8080));
755 assert_eq!(p3.host_ip, Some("127.0.0.1".to_string()));
756
757 let p4 = ServicePort::parse("80/udp", pos, false).unwrap();
758 assert_eq!(p4.container_port, 80);
759 assert_eq!(p4.protocol, Some("udp".to_string()));
760 }
761
762 #[test]
763 fn test_volume_parsing() {
764 let pos = Position::new(1, 1);
765
766 let v1 = ServiceVolume::parse("/data", pos, false).unwrap();
767 assert_eq!(v1.target, "/data");
768 assert_eq!(v1.source, None);
769
770 let v2 = ServiceVolume::parse("./host:/container", pos, false).unwrap();
771 assert_eq!(v2.source, Some("./host".to_string()));
772 assert_eq!(v2.target, "/container");
773
774 let v3 = ServiceVolume::parse("./host:/container:ro", pos, false).unwrap();
775 assert_eq!(v3.source, Some("./host".to_string()));
776 assert_eq!(v3.target, "/container");
777 assert_eq!(v3.options, Some("ro".to_string()));
778 }
779}