fabro/lib/crates/fabro-devcontainer/src/compose.rs
Bryan Helmkamp 28884ae093 rename Arc to Fabro in all Rust crates, symbols, env vars, and supporting files
- Rename 20 crate directories lib/crates/arc-* → fabro-*
- Update all Cargo.toml: crate names, dep paths, feature flags, bin name
- Rename arc_server module → fabro_server in fabro-llm
- ArcError → FabroError across 30+ files
- ARC_VERSION/ARC_GIT_SHA/ARC_BUILD_DATE → FABRO_* constants
- All use/qualified paths: arc_agent:: → fabro_agent::, etc. (~1500 occurrences)
- Env vars ARC_* → FABRO_* in string literals and shell scripts
- String literals: X-Arc-Demo, arc-bot, arc@local, arc-web, arc-mcp, etc.
- Path strings: .arc/ → .fabro/, arc.toml → fabro.toml, refs/arc/ → refs/fabro/
- arc-api.yaml → fabro-api.yaml (OpenAPI spec)
- skills/arc-create-workflow → fabro-create-workflow
- trycmd fixtures: $ arc → $ fabro
- Inline snapshots (insta) updated
- CI, Docker, install.sh, scripts, CLAUDE.md, AGENTS.md
- TypeScript app: env vars, headers, JWT issuer
- Docs: page slugs, git refs, config paths, sandbox names, repo URLs
- Repo references: brynary/arc → fabro-sh/fabro

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:25:58 -04:00

426 lines
11 KiB
Rust

use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Extracted configuration from a Docker Compose service.
#[derive(Debug, Clone, Default)]
pub struct ComposeServiceConfig {
pub image: Option<String>,
pub build: Option<ComposeBuild>,
pub ports: Vec<u16>,
pub environment: HashMap<String, String>,
pub user: Option<String>,
}
/// Build configuration from a Docker Compose service.
#[derive(Debug, Clone)]
pub struct ComposeBuild {
pub context: String,
pub dockerfile: Option<String>,
}
/// Parse a Docker Compose file and extract config for the named service.
pub fn parse_compose(
compose_path: &Path,
service_name: &str,
) -> Result<ComposeServiceConfig, String> {
let contents = std::fs::read_to_string(compose_path)
.map_err(|e| format!("failed to read compose file: {e}"))?;
let doc: serde_yaml::Value =
serde_yaml::from_str(&contents).map_err(|e| format!("failed to parse YAML: {e}"))?;
let service = doc
.get("services")
.and_then(|s| s.get(service_name))
.ok_or_else(|| format!("service '{service_name}' not found in compose file"))?;
let image = service
.get("image")
.and_then(|v| v.as_str())
.map(String::from);
let build = parse_build(service);
let ports = parse_ports(service);
let environment = parse_environment(service);
let user = service
.get("user")
.and_then(|v| v.as_str())
.map(String::from);
Ok(ComposeServiceConfig {
image,
build,
ports,
environment,
user,
})
}
fn parse_build(service: &serde_yaml::Value) -> Option<ComposeBuild> {
let build_val = service.get("build")?;
if let Some(context) = build_val.as_str() {
return Some(ComposeBuild {
context: context.to_string(),
dockerfile: None,
});
}
if build_val.is_mapping() {
let context = build_val
.get("context")
.and_then(|v| v.as_str())
.unwrap_or(".")
.to_string();
let dockerfile = build_val
.get("dockerfile")
.and_then(|v| v.as_str())
.map(String::from);
return Some(ComposeBuild {
context,
dockerfile,
});
}
None
}
fn parse_ports(service: &serde_yaml::Value) -> Vec<u16> {
let Some(ports_val) = service.get("ports") else {
return Vec::new();
};
let Some(ports_seq) = ports_val.as_sequence() else {
return Vec::new();
};
ports_seq
.iter()
.filter_map(|entry| {
if let Some(n) = entry.as_u64() {
return u16::try_from(n).ok();
}
if let Some(s) = entry.as_str() {
// Formats: "8080:80", "3000", "8080:80/tcp"
let s = s.split('/').next().unwrap_or(s); // strip protocol
return if let Some((_host, container)) = s.split_once(':') {
container.parse::<u16>().ok()
} else {
s.parse::<u16>().ok()
};
}
None
})
.collect()
}
fn parse_environment(service: &serde_yaml::Value) -> HashMap<String, String> {
let Some(env_val) = service.get("environment") else {
return HashMap::new();
};
// Array form: ["KEY=VALUE", ...]
if let Some(seq) = env_val.as_sequence() {
return seq
.iter()
.filter_map(|v| {
let s = v.as_str()?;
let (key, value) = s.split_once('=')?;
Some((key.to_string(), value.to_string()))
})
.collect();
}
// Object form: { KEY: VALUE, ... }
if let Some(mapping) = env_val.as_mapping() {
return mapping
.iter()
.filter_map(|(k, v)| {
let key = k.as_str()?.to_string();
let value = match v {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
serde_yaml::Value::Null => String::new(),
_ => return None,
};
Some((key, value))
})
.collect();
}
HashMap::new()
}
/// Parse multiple Docker Compose files and merge config for the named service.
/// Later files override earlier files for image/build/user; ports accumulate (deduped);
/// environment keys from later files override earlier ones.
pub fn parse_compose_multi(
compose_paths: &[PathBuf],
service_name: &str,
) -> Result<ComposeServiceConfig, String> {
let mut merged = ComposeServiceConfig::default();
let mut found_service = false;
for path in compose_paths {
let contents = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read compose file {}: {e}", path.display()))?;
let doc: serde_yaml::Value = serde_yaml::from_str(&contents)
.map_err(|e| format!("failed to parse YAML {}: {e}", path.display()))?;
let Some(service) = doc.get("services").and_then(|s| s.get(service_name)) else {
continue;
};
found_service = true;
if let Some(image) = service.get("image").and_then(|v| v.as_str()) {
merged.image = Some(image.to_string());
}
if let Some(build) = parse_build(service) {
merged.build = Some(build);
}
if let Some(user) = service.get("user").and_then(|v| v.as_str()) {
merged.user = Some(user.to_string());
}
for port in parse_ports(service) {
if !merged.ports.contains(&port) {
merged.ports.push(port);
}
}
for (k, v) in parse_environment(service) {
merged.environment.insert(k, v);
}
}
if !found_service {
return Err(format!(
"service '{service_name}' not found in any compose file"
));
}
Ok(merged)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_compose(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn service_with_image_only() {
let f = write_compose(
r#"
services:
web:
image: nginx:latest
"#,
);
let cfg = parse_compose(f.path(), "web").unwrap();
assert_eq!(cfg.image.as_deref(), Some("nginx:latest"));
assert!(cfg.build.is_none());
assert!(cfg.ports.is_empty());
assert!(cfg.environment.is_empty());
assert!(cfg.user.is_none());
}
#[test]
fn service_with_build_string() {
let f = write_compose(
r#"
services:
app:
build: ./src
"#,
);
let cfg = parse_compose(f.path(), "app").unwrap();
let build = cfg.build.unwrap();
assert_eq!(build.context, "./src");
assert!(build.dockerfile.is_none());
}
#[test]
fn service_with_build_object() {
let f = write_compose(
r#"
services:
app:
build:
context: ./app
dockerfile: Dockerfile.dev
"#,
);
let cfg = parse_compose(f.path(), "app").unwrap();
let build = cfg.build.unwrap();
assert_eq!(build.context, "./app");
assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile.dev"));
}
#[test]
fn ports_various_formats() {
let f = write_compose(
r#"
services:
web:
image: nginx
ports:
- "8080:80"
- "3000"
- 5432
- "9090:9090/tcp"
"#,
);
let cfg = parse_compose(f.path(), "web").unwrap();
assert_eq!(cfg.ports, vec![80, 3000, 5432, 9090]);
}
#[test]
fn environment_as_array() {
let f = write_compose(
r#"
services:
app:
image: myapp
environment:
- "DATABASE_URL=postgres://localhost/db"
- "DEBUG=true"
"#,
);
let cfg = parse_compose(f.path(), "app").unwrap();
assert_eq!(cfg.environment.len(), 2);
assert_eq!(
cfg.environment.get("DATABASE_URL").unwrap(),
"postgres://localhost/db"
);
assert_eq!(cfg.environment.get("DEBUG").unwrap(), "true");
}
#[test]
fn environment_as_object() {
let f = write_compose(
r#"
services:
app:
image: myapp
environment:
RAILS_ENV: production
PORT: 3000
"#,
);
let cfg = parse_compose(f.path(), "app").unwrap();
assert_eq!(cfg.environment.len(), 2);
assert_eq!(cfg.environment.get("RAILS_ENV").unwrap(), "production");
assert_eq!(cfg.environment.get("PORT").unwrap(), "3000");
}
#[test]
fn service_not_found() {
let f = write_compose(
r#"
services:
web:
image: nginx
"#,
);
let err = parse_compose(f.path(), "missing").unwrap_err();
assert!(err.contains("service 'missing' not found"));
}
#[test]
fn file_not_found() {
let err = parse_compose(Path::new("/nonexistent/docker-compose.yml"), "web").unwrap_err();
assert!(err.contains("failed to read compose file"));
}
#[test]
fn service_with_user() {
let f = write_compose(
r#"
services:
app:
image: myapp
user: "1000:1000"
"#,
);
let cfg = parse_compose(f.path(), "app").unwrap();
assert_eq!(cfg.user.as_deref(), Some("1000:1000"));
}
#[test]
fn multi_compose_merge() {
let base = write_compose(
r#"
services:
app:
image: node:20
ports:
- "3000:3000"
environment:
- "NODE_ENV=development"
"#,
);
let over = write_compose(
r#"
services:
app:
image: node:22
ports:
- "3000:3000"
- "9229:9229"
environment:
- "DEBUG=true"
"#,
);
let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()];
let cfg = parse_compose_multi(&paths, "app").unwrap();
assert_eq!(cfg.image.as_deref(), Some("node:22"));
assert_eq!(cfg.ports, vec![3000, 9229]);
assert_eq!(cfg.environment.get("NODE_ENV").unwrap(), "development");
assert_eq!(cfg.environment.get("DEBUG").unwrap(), "true");
}
#[test]
fn multi_compose_service_not_found() {
let f = write_compose(
r#"
services:
web:
image: nginx
"#,
);
let paths = vec![f.path().to_path_buf()];
let err = parse_compose_multi(&paths, "missing").unwrap_err();
assert!(err.contains("service 'missing' not found"));
}
#[test]
fn multi_compose_skips_file_without_service() {
let base = write_compose(
r#"
services:
db:
image: postgres:15
"#,
);
let over = write_compose(
r#"
services:
app:
image: node:22
"#,
);
let paths = vec![base.path().to_path_buf(), over.path().to_path_buf()];
let cfg = parse_compose_multi(&paths, "app").unwrap();
assert_eq!(cfg.image.as_deref(), Some("node:22"));
}
}