1mod magic;
21mod service;
22#[cfg(test)]
23mod tests;
24#[cfg(test)]
25mod tests_more;
26
27use std::collections::BTreeMap;
28
29use serde::Serialize;
30
31use self::magic::Cx;
32use super::Template;
33use super::shared::{
34 Report, Tx, check_top_level, declared_volumes, finish, key_name, parse_compose, service_names,
35 yget, ymap, yscalar,
36};
37
38pub use self::magic::{Gen, Magic, classify};
39
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
42pub struct Meta {
43 pub id: String,
45 pub name: String,
47 pub description: String,
49 pub category: String,
50 pub tags: Vec<String>,
51 pub logo: Option<String>,
54 pub docs: Option<String>,
56 pub port: Option<u16>,
58 pub ignore: bool,
60}
61
62pub fn header(text: &str) -> Vec<(String, String)> {
64 let mut out = Vec::new();
65 for line in text.lines() {
66 let l = line.trim();
67 if l.is_empty() {
68 continue;
69 }
70 let Some(c) = l.strip_prefix('#') else { break };
71 if let Some((k, v)) = c.split_once(':') {
72 let k = k.trim().to_ascii_lowercase();
73 if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphabetic() || c == '_') {
74 out.push((k, v.trim().to_string()));
75 }
76 }
77 }
78 out
79}
80
81pub fn titled(id: &str) -> String {
83 id.split(['-', '_', '.'])
84 .filter(|w| !w.is_empty())
85 .map(|w| {
86 let mut c = w.chars();
87 match c.next() {
88 Some(f) => f.to_uppercase().chain(c).collect::<String>(),
89 None => String::new(),
90 }
91 })
92 .collect::<Vec<_>>()
93 .join(" ")
94}
95
96fn logo_path_ok(p: &str) -> bool {
98 !p.is_empty()
99 && p.len() <= 200
100 && !p.starts_with(['/', '.'])
101 && !p.contains("..")
102 && p.chars()
103 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'))
104}
105
106pub fn meta(id: &str, text: &str, logo_base: Option<&str>) -> Meta {
109 let h = header(text);
110 let get = |k: &str| h.iter().find(|(a, _)| a == k).map(|(_, v)| v.as_str());
111 let mut tags: Vec<String> = vec![];
112 for t in get("category")
113 .into_iter()
114 .chain(get("tags").unwrap_or("").split(','))
115 {
116 let t = t.trim().to_ascii_lowercase();
117 if !t.is_empty() && !tags.contains(&t) {
118 tags.push(t);
119 }
120 }
121 Meta {
122 id: id.to_string(),
123 name: titled(id),
124 description: get("slogan")
125 .unwrap_or("")
126 .trim_matches(['"', '\''])
127 .trim()
128 .to_string(),
129 category: get("category").unwrap_or("").to_string(),
130 tags,
131 logo: get("logo")
132 .filter(|l| logo_path_ok(l))
133 .zip(logo_base)
134 .map(|(l, b)| format!("{}/public/{l}", b.trim_end_matches('/'))),
135 docs: get("documentation")
136 .filter(|d| d.starts_with("https://") || d.starts_with("http://"))
137 .map(String::from),
138 port: get("port").and_then(|p| p.parse().ok()),
139 ignore: get("ignore").is_some_and(|v| v.eq_ignore_ascii_case("true")),
140 }
141}
142
143pub fn translate(meta: &Meta, compose: &str) -> (Option<Template>, Report) {
146 let mut tx = Tx {
147 notes: vec![],
148 refusals: vec![],
149 vars: vec![],
150 names: BTreeMap::new(),
151 };
152 let t = translate_inner(meta, compose, &mut tx);
153 finish(t, tx)
154}
155
156fn translate_inner(meta: &Meta, compose: &str, tx: &mut Tx) -> Option<Template> {
157 let doc = parse_compose(compose, tx)?;
158 let Some(top) = ymap(&doc) else {
159 tx.refuse("the compose file is not a mapping");
160 return None;
161 };
162 check_top_level(top, tx);
163 declared_volumes(top, tx);
164 let Some(services) = yget(top, "services").and_then(ymap) else {
165 tx.refuse("the compose file has no services");
166 return None;
167 };
168 let (keys, aliases) = service_names(services, tx);
169 let cx = Cx::new(&doc, services, (&keys, &aliases), meta.port, tx);
170 let mut acc = service::Acc::default();
171 let mut apps = Vec::new();
172 for (name, s) in services {
173 let name = yscalar(name).unwrap_or_default();
174 let Some(key) = keys.get(&name).cloned() else {
175 continue;
176 };
177 let Some(m) = ymap(s) else {
178 tx.refuse(format!("service {name} is not a mapping"));
179 continue;
180 };
181 if let Some(app) = service::translate(&cx, &mut acc, &name, key, m, tx) {
182 apps.push(app);
183 }
184 }
185 for (v, users) in &acc.vol_users {
186 if users.len() > 1 {
187 tx.refuse(format!(
188 "volume {v} is shared by {} (an app's volumes are its own)",
189 users.iter().cloned().collect::<Vec<_>>().join(", ")
190 ));
191 }
192 }
193 if apps.is_empty() {
194 tx.refuse("no service to deploy");
195 return None;
196 }
197 cx.finish(tx);
198 let main = cx
199 .decls
200 .iter()
201 .find_map(|d| keys.get(&d.service))
202 .cloned()
203 .unwrap_or_else(|| apps[0].name.clone());
204 Some(Template {
205 id: key_name(&meta.id),
206 name: meta.name.clone(),
207 description: meta.description.clone(),
208 version: String::new(),
209 logo: meta.logo.clone(),
210 tags: meta.tags.clone(),
211 links: meta
212 .docs
213 .iter()
214 .map(|d| ("docs".to_string(), d.clone()))
215 .collect(),
216 variables: tx.vars.clone(),
217 apps,
218 main: Some(main),
219 notes: vec![],
220 })
221}