mod magic;
mod service;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_more;
use std::collections::BTreeMap;
use serde::Serialize;
use self::magic::Cx;
use super::Template;
use super::shared::{
Report, Tx, check_top_level, declared_volumes, finish, key_name, parse_compose, service_names,
yget, ymap, yscalar,
};
pub use self::magic::{Gen, Magic, classify};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Meta {
pub id: String,
pub name: String,
pub description: String,
pub category: String,
pub tags: Vec<String>,
pub logo: Option<String>,
pub docs: Option<String>,
pub port: Option<u16>,
pub ignore: bool,
}
pub fn header(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for line in text.lines() {
let l = line.trim();
if l.is_empty() {
continue;
}
let Some(c) = l.strip_prefix('#') else { break };
if let Some((k, v)) = c.split_once(':') {
let k = k.trim().to_ascii_lowercase();
if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphabetic() || c == '_') {
out.push((k, v.trim().to_string()));
}
}
}
out
}
pub fn titled(id: &str) -> String {
id.split(['-', '_', '.'])
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(f) => f.to_uppercase().chain(c).collect::<String>(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn logo_path_ok(p: &str) -> bool {
!p.is_empty()
&& p.len() <= 200
&& !p.starts_with(['/', '.'])
&& !p.contains("..")
&& p.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'))
}
pub fn meta(id: &str, text: &str, logo_base: Option<&str>) -> Meta {
let h = header(text);
let get = |k: &str| h.iter().find(|(a, _)| a == k).map(|(_, v)| v.as_str());
let mut tags: Vec<String> = vec![];
for t in get("category")
.into_iter()
.chain(get("tags").unwrap_or("").split(','))
{
let t = t.trim().to_ascii_lowercase();
if !t.is_empty() && !tags.contains(&t) {
tags.push(t);
}
}
Meta {
id: id.to_string(),
name: titled(id),
description: get("slogan")
.unwrap_or("")
.trim_matches(['"', '\''])
.trim()
.to_string(),
category: get("category").unwrap_or("").to_string(),
tags,
logo: get("logo")
.filter(|l| logo_path_ok(l))
.zip(logo_base)
.map(|(l, b)| format!("{}/public/{l}", b.trim_end_matches('/'))),
docs: get("documentation")
.filter(|d| d.starts_with("https://") || d.starts_with("http://"))
.map(String::from),
port: get("port").and_then(|p| p.parse().ok()),
ignore: get("ignore").is_some_and(|v| v.eq_ignore_ascii_case("true")),
}
}
pub fn translate(meta: &Meta, compose: &str) -> (Option<Template>, Report) {
let mut tx = Tx {
notes: vec![],
refusals: vec![],
vars: vec![],
names: BTreeMap::new(),
};
let t = translate_inner(meta, compose, &mut tx);
finish(t, tx)
}
fn translate_inner(meta: &Meta, compose: &str, tx: &mut Tx) -> Option<Template> {
let doc = parse_compose(compose, tx)?;
let Some(top) = ymap(&doc) else {
tx.refuse("the compose file is not a mapping");
return None;
};
check_top_level(top, tx);
declared_volumes(top, tx);
let Some(services) = yget(top, "services").and_then(ymap) else {
tx.refuse("the compose file has no services");
return None;
};
let (keys, aliases) = service_names(services, tx);
let cx = Cx::new(&doc, services, (&keys, &aliases), meta.port, tx);
let mut acc = service::Acc::default();
let mut apps = Vec::new();
for (name, s) in services {
let name = yscalar(name).unwrap_or_default();
let Some(key) = keys.get(&name).cloned() else {
continue;
};
let Some(m) = ymap(s) else {
tx.refuse(format!("service {name} is not a mapping"));
continue;
};
if let Some(app) = service::translate(&cx, &mut acc, &name, key, m, tx) {
apps.push(app);
}
}
for (v, users) in &acc.vol_users {
if users.len() > 1 {
tx.refuse(format!(
"volume {v} is shared by {} (an app's volumes are its own)",
users.iter().cloned().collect::<Vec<_>>().join(", ")
));
}
}
if apps.is_empty() {
tx.refuse("no service to deploy");
return None;
}
cx.finish(tx);
let main = cx
.decls
.iter()
.find_map(|d| keys.get(&d.service))
.cloned()
.unwrap_or_else(|| apps[0].name.clone());
Some(Template {
id: key_name(&meta.id),
name: meta.name.clone(),
description: meta.description.clone(),
version: String::new(),
logo: meta.logo.clone(),
tags: meta.tags.clone(),
links: meta
.docs
.iter()
.map(|d| ("docs".to_string(), d.clone()))
.collect(),
variables: tx.vars.clone(),
apps,
main: Some(main),
notes: vec![],
})
}