1use serde::Deserialize;
12use serde_json::Value;
13use std::collections::BTreeMap;
14use std::sync::OnceLock;
15
16#[derive(Deserialize)]
17struct TableFile {
18 tag: String,
19 frameworks: Vec<Framework>,
20}
21
22#[derive(Debug, Clone, Deserialize)]
26pub struct Framework {
27 pub key: String,
28 pub class: String,
29 pub custom: bool,
30 pub locations: BTreeMap<String, String>,
31}
32
33#[derive(Debug)]
35pub struct Table {
36 pub tag: String,
37 frameworks_desc: Vec<Framework>,
39}
40
41const TABLE_SOURCES: &[(&str, &str)] = &[
42 ("2.0.0", include_str!("../assets/installers/v2.0.0.json")),
43 ("2.0.1", include_str!("../assets/installers/v2.0.1.json")),
44 ("2.1.0", include_str!("../assets/installers/v2.1.0.json")),
45 ("2.1.1", include_str!("../assets/installers/v2.1.1.json")),
46 ("2.2.0", include_str!("../assets/installers/v2.2.0.json")),
47 ("2.3.0", include_str!("../assets/installers/v2.3.0.json")),
48];
49
50fn tables() -> &'static BTreeMap<&'static str, Table> {
51 static TABLES: OnceLock<BTreeMap<&'static str, Table>> = OnceLock::new();
52 TABLES.get_or_init(|| {
53 TABLE_SOURCES
54 .iter()
55 .map(|(version, json)| {
56 let file: TableFile =
59 serde_json::from_str(json).unwrap_or_else(|e| panic!("asset {version}: {e}"));
60 let mut frameworks = file.frameworks;
61 frameworks.sort_by(|a, b| b.key.cmp(&a.key));
62 (
63 *version,
64 Table {
65 tag: file.tag,
66 frameworks_desc: frameworks,
67 },
68 )
69 })
70 .collect()
71 })
72}
73
74pub fn ported_versions() -> impl Iterator<Item = &'static str> {
76 TABLE_SOURCES.iter().map(|(v, _)| *v)
77}
78
79pub fn table_for(locked_version: &str) -> Option<&'static Table> {
82 let v = locked_version.strip_prefix('v').unwrap_or(locked_version);
83 tables().get(v)
84}
85
86impl Table {
87 pub fn frameworks(&self) -> impl Iterator<Item = &Framework> {
88 self.frameworks_desc.iter()
89 }
90
91 pub fn framework(&self, key: &str) -> Option<&Framework> {
92 self.frameworks_desc.iter().find(|f| f.key == key)
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum Unsupported {
99 CustomFramework { key: String, class: String },
101 UnknownLocation { package_type: String },
103 BadInstallerPaths(String),
105 BadInstallerName(String),
107 UnknownTemplateVar(String),
109}
110
111impl std::fmt::Display for Unsupported {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 Unsupported::CustomFramework { key, class } => write!(
115 f,
116 "framework `{key}` ({class}) has custom path logic (not emulated yet)"
117 ),
118 Unsupported::UnknownLocation { package_type } => {
119 write!(
120 f,
121 "package type `{package_type}` has no location in composer/installers"
122 )
123 }
124 Unsupported::BadInstallerPaths(why) => {
125 write!(f, "extra.installer-paths is malformed: {why}")
126 }
127 Unsupported::BadInstallerName(why) => {
128 write!(f, "extra.installer-name is malformed: {why}")
129 }
130 Unsupported::UnknownTemplateVar(var) => {
131 write!(
132 f,
133 "installer path template uses unknown variable `{{${var}}}`"
134 )
135 }
136 }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum Placement {
145 Vendor,
146 Custom(String),
147}
148
149fn disabled_keys(root_extra: Option<&Value>) -> DisabledKeys {
153 let Some(v) = root_extra.and_then(|e| e.get("installer-disable")) else {
154 return DisabledKeys::None;
155 };
156 if matches!(v, Value::Bool(false) | Value::Null) {
157 return DisabledKeys::None;
158 }
159 let items: Vec<&Value> = match v {
161 Value::Array(a) => a.iter().collect(),
162 Value::Object(o) => o.values().collect(),
163 other => vec![other],
164 };
165 let all = items.iter().any(|i| match i {
166 Value::Bool(true) => true,
167 Value::String(s) => s == "1" || s == "all" || s == "*",
168 Value::Number(n) => n.as_i64() == Some(1) || n.as_f64() == Some(1.0),
169 _ => false,
170 });
171 if all {
172 return DisabledKeys::All;
173 }
174 DisabledKeys::Some(
175 items
176 .iter()
177 .filter_map(|i| i.as_str().map(str::to_owned))
178 .collect(),
179 )
180}
181
182enum DisabledKeys {
183 None,
184 All,
185 Some(Vec<String>),
186}
187
188impl DisabledKeys {
189 fn contains(&self, key: &str) -> bool {
190 match self {
191 DisabledKeys::None => false,
192 DisabledKeys::All => true,
193 DisabledKeys::Some(keys) => keys.iter().any(|k| k == key),
194 }
195 }
196}
197
198fn find_framework<'t>(
201 table: &'t Table,
202 disabled: &DisabledKeys,
203 package_type: &str,
204) -> Option<&'t Framework> {
205 table
206 .frameworks_desc
207 .iter()
208 .filter(|f| !disabled.contains(&f.key))
209 .find(|f| package_type.starts_with(&f.key))
210}
211
212fn supports(fw: &Framework, package_type: &str) -> bool {
215 let prefix = format!("{}-", fw.key);
216 let mut start = 0;
217 while let Some(i) = package_type[start..].find(&prefix) {
218 let rest = &package_type[start + i + prefix.len()..];
219 let hit = if fw.locations.is_empty() {
220 rest.bytes()
221 .next()
222 .is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_')
223 } else {
224 fw.locations.keys().any(|k| rest.starts_with(k.as_str()))
225 };
226 if hit {
227 return true;
228 }
229 start += i + 1;
230 }
231 false
232}
233
234fn custom_install_path<'a>(
238 paths: &'a Value,
239 name: &str,
240 package_type: &str,
241 vendor: &str,
242) -> Result<Option<&'a str>, Unsupported> {
243 let Some(map) = paths.as_object() else {
244 return Err(Unsupported::BadInstallerPaths(
245 "expected an object of template => package list".into(),
246 ));
247 };
248 let by_type = format!("type:{package_type}");
249 let by_vendor = format!("vendor:{vendor}");
250 for (template, names) in map {
251 let list: Vec<&Value> = match names {
252 Value::Array(a) => a.iter().collect(),
253 other => vec![other],
254 };
255 for n in &list {
256 match n {
257 Value::String(s) => {
258 if s == name || *s == by_type || *s == by_vendor {
259 return Ok(Some(template.as_str()));
260 }
261 }
262 Value::Bool(true) => return Ok(Some(template.as_str())),
263 Value::Bool(false) | Value::Null | Value::Number(_) => {}
264 Value::Array(_) | Value::Object(_) => {
265 return Err(Unsupported::BadInstallerPaths(format!(
266 "nested value under `{template}`"
267 )))
268 }
269 }
270 }
271 }
272 Ok(None)
273}
274
275fn template_path(template: &str, vars: &BTreeMap<&str, &str>) -> Result<String, Unsupported> {
279 let mut out = String::with_capacity(template.len());
280 let mut rest = template;
281 while let Some(i) = rest.find("{$") {
282 out.push_str(&rest[..i]);
283 let after = &rest[i + 2..];
284 let len = after
285 .bytes()
286 .take_while(|b| b.is_ascii_alphanumeric() || *b == b'_')
287 .count();
288 if after[len..].starts_with('}') {
289 let var = &after[..len];
290 match vars.get(var) {
291 Some(v) => out.push_str(v),
292 None => return Err(Unsupported::UnknownTemplateVar(var.to_owned())),
293 }
294 rest = &after[len + 1..];
295 } else {
296 out.push_str("{$");
297 rest = after;
298 }
299 }
300 out.push_str(rest);
301 Ok(out)
302}
303
304pub fn placement(
308 table: &Table,
309 root_extra: Option<&Value>,
310 name: &str,
311 package_type: &str,
312 package_extra: Option<&Value>,
313) -> Result<Placement, Unsupported> {
314 let disabled = disabled_keys(root_extra);
315 let Some(fw) = find_framework(table, &disabled, package_type) else {
316 return Ok(Placement::Vendor);
317 };
318 if !supports(fw, package_type) {
319 return Ok(Placement::Vendor);
320 }
321 if fw.custom {
322 return Err(Unsupported::CustomFramework {
323 key: fw.key.clone(),
324 class: fw.class.clone(),
325 });
326 }
327
328 let (vendor, short_name) = match name.split_once('/') {
330 Some((v, n)) => (v, n.split('/').next().unwrap_or(n)),
331 None => ("", name),
332 };
333 let mut var_name = short_name.to_owned();
334 if let Some(v) = package_extra.and_then(|e| e.get("installer-name")) {
335 match v {
336 Value::String(s) if s.is_empty() || s == "0" => {} Value::String(s) => {
338 if s.contains('/') || s.contains('{') || s.split('/').any(|c| c == "..") {
341 return Err(Unsupported::BadInstallerName(format!("`{s}`")));
342 }
343 var_name = s.clone();
344 }
345 Value::Null | Value::Bool(false) => {}
346 other => {
347 return Err(Unsupported::BadInstallerName(format!(
348 "not a string: {other}"
349 )))
350 }
351 }
352 }
353 let vars: BTreeMap<&str, &str> = BTreeMap::from([
354 ("name", var_name.as_str()),
355 ("vendor", vendor),
356 ("type", package_type),
357 ]);
358
359 if let Some(paths) = root_extra.and_then(|e| e.get("installer-paths")) {
360 let empty = match paths {
362 Value::Null | Value::Bool(false) => true,
363 Value::String(s) => s.is_empty() || s == "0",
364 Value::Array(a) => a.is_empty(),
365 Value::Object(o) => o.is_empty(),
366 Value::Number(n) => n.as_f64() == Some(0.0),
367 Value::Bool(true) => false,
368 };
369 if !empty {
370 if let Some(template) = custom_install_path(paths, name, package_type, vendor)? {
371 return Ok(Placement::Custom(template_path(template, &vars)?));
372 }
373 }
374 }
375
376 let location_key = &package_type[fw.key.len() + 1..];
377 let Some(template) = fw.locations.get(location_key) else {
378 return Err(Unsupported::UnknownLocation {
379 package_type: package_type.to_owned(),
380 });
381 };
382 Ok(Placement::Custom(template_path(template, &vars)?))
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use serde_json::json;
389
390 fn t() -> &'static Table {
391 table_for("v2.3.0").expect("table 2.3.0")
392 }
393
394 #[test]
395 fn tables_load_and_versions_are_known() {
396 for v in ported_versions() {
397 let tbl = table_for(v).expect(v);
398 assert_eq!(tbl.tag, format!("v{v}"));
399 assert!(tbl.framework("wordpress").is_some());
400 }
401 assert!(table_for("v1.12.0").is_none());
402 assert!(table_for("2.4.0").is_none());
403 assert!(table_for("dev-main").is_none());
404 }
405
406 #[test]
407 fn framework_prefix_uses_krsort_order() {
408 let d = DisabledKeys::None;
409 assert_eq!(
410 find_framework(t(), &d, "fuelphp-package").map(|f| f.key.as_str()),
411 Some("fuelphp")
412 );
413 assert_eq!(
414 find_framework(t(), &d, "fuel-package").map(|f| f.key.as_str()),
415 Some("fuel")
416 );
417 assert_eq!(
418 find_framework(t(), &d, "redaxo5-addon").map(|f| f.key.as_str()),
419 Some("redaxo5")
420 );
421 assert_eq!(
422 find_framework(t(), &d, "concretecms-package").map(|f| f.key.as_str()),
423 Some("concretecms")
424 );
425 assert!(find_framework(t(), &d, "library").is_none());
426 }
427
428 #[test]
429 fn wordpress_default_and_custom_paths() {
430 let p = placement(
431 t(),
432 None,
433 "wpackagist-plugin/akismet",
434 "wordpress-plugin",
435 None,
436 );
437 assert_eq!(
438 p,
439 Ok(Placement::Custom("wp-content/plugins/akismet/".into()))
440 );
441
442 let root = json!({"installer-paths": {
443 "web/app/mu-plugins/{$name}/": ["type:wordpress-muplugin"],
444 "web/app/plugins/{$name}/": ["type:wordpress-plugin"],
445 "web/app/themes/{$vendor}-{$name}/": ["type:wordpress-theme"],
446 "custom/{$name}": ["wpackagist-plugin/hello-dolly"]
447 }});
448 assert_eq!(
449 placement(
450 t(),
451 Some(&root),
452 "wpackagist-plugin/akismet",
453 "wordpress-plugin",
454 None
455 ),
456 Ok(Placement::Custom("web/app/plugins/akismet/".into()))
457 );
458 assert_eq!(
460 placement(
461 t(),
462 Some(&root),
463 "wpackagist-plugin/hello-dolly",
464 "wordpress-plugin",
465 None
466 ),
467 Ok(Placement::Custom("web/app/plugins/hello-dolly/".into()))
468 );
469 assert_eq!(
470 placement(
471 t(),
472 Some(&root),
473 "wpackagist-theme/twentytwentyfour",
474 "wordpress-theme",
475 None
476 ),
477 Ok(Placement::Custom(
478 "web/app/themes/wpackagist-theme-twentytwentyfour/".into()
479 ))
480 );
481 assert_eq!(
483 placement(t(), Some(&root), "a/b", "library", None),
484 Ok(Placement::Vendor)
485 );
486 assert_eq!(
487 placement(t(), Some(&root), "a/b", "wordpress-core", None),
488 Ok(Placement::Vendor)
489 );
490 assert_eq!(
493 placement(t(), None, "a/b", "wordpress-plugin-x", None),
494 Err(Unsupported::UnknownLocation {
495 package_type: "wordpress-plugin-x".into()
496 })
497 );
498 }
499
500 #[test]
501 fn installer_name_disable_and_custom_frameworks() {
502 let extra = json!({"installer-name": "renamed"});
503 assert_eq!(
504 placement(t(), None, "a/b", "drupal-module", Some(&extra)),
505 Ok(Placement::Custom("modules/renamed/".into()))
506 );
507 let empty = json!({"installer-name": ""});
508 assert_eq!(
509 placement(t(), None, "a/b", "drupal-module", Some(&empty)),
510 Ok(Placement::Custom("modules/b/".into()))
511 );
512 let root_all = json!({"installer-disable": true});
513 assert_eq!(
514 placement(t(), Some(&root_all), "a/b", "drupal-module", None),
515 Ok(Placement::Vendor)
516 );
517 let root_some = json!({"installer-disable": ["drupal"]});
518 assert_eq!(
519 placement(t(), Some(&root_some), "a/b", "drupal-module", None),
520 Ok(Placement::Vendor)
521 );
522 assert_eq!(
523 placement(t(), Some(&root_some), "a/b", "wordpress-plugin", None),
524 Ok(Placement::Custom("wp-content/plugins/b/".into()))
525 );
526 assert!(matches!(
527 placement(t(), None, "a/b", "cakephp-plugin", None),
528 Err(Unsupported::CustomFramework { .. })
529 ));
530 assert_eq!(
531 placement(
532 t(),
533 Some(&json!({"installer-paths": {"x/{$nope}": ["a/b"]}})),
534 "a/b",
535 "drupal-module",
536 None
537 ),
538 Err(Unsupported::UnknownTemplateVar("nope".into()))
539 );
540 }
541}