1use std::path::{Component, Path, PathBuf};
4
5use crate::{
6 Aggregate, App, AreaContribution, AreaDefinition, Crate, Method, Module, Route, TypeName,
7};
8
9pub const WEB_AREA: &str = "web";
10const WEB_AREA_TYPE: &str = "sofie::http::Web";
11
12pub const IDENTITY_PACKAGE: &str = "sofie-identity";
13pub const TENANCY_PACKAGE: &str = "sofie-tenancy";
14pub const CONTENT_PACKAGE: &str = "sofie-content";
15pub const BILLING_PACKAGE: &str = "sofie-billing";
16pub const IDENTITY_AREA: &str = WEB_AREA;
17
18pub(crate) const GEN_MODULE: &str = "gen";
19
20pub const SOURCE_LANGUAGE: &str = "en";
23
24pub const QUERY_MODULE: &str = "queries";
25const ACTION_MODULE: &str = "actions";
26
27const VISITOR: &str = "Visitor";
28
29const MEMBER_PRINCIPAL: &str = "Account";
30
31pub const TASK_RUNNERS: [&str; 2] = ["run_with_tasks", "run_with_flags_and_tasks"];
33
34pub const DEFAULT_BEHAVIORS: [&str; 2] = ["Versioned", "Timestamps"];
35
36pub struct BehaviorColumn {
37 pub name: &'static str,
38 pub sql_type: &'static str,
39 pub nullable: bool,
40 pub backfill: Option<&'static str>,
42}
43
44const fn column(name: &'static str, sql_type: &'static str) -> BehaviorColumn {
45 BehaviorColumn {
46 name,
47 sql_type,
48 nullable: false,
49 backfill: None,
50 }
51}
52
53const fn optional(name: &'static str, sql_type: &'static str) -> BehaviorColumn {
54 BehaviorColumn {
55 nullable: true,
56 ..column(name, sql_type)
57 }
58}
59
60const fn filled(
61 name: &'static str,
62 sql_type: &'static str,
63 backfill: &'static str,
64) -> BehaviorColumn {
65 BehaviorColumn {
66 backfill: Some(backfill),
67 ..column(name, sql_type)
68 }
69}
70
71#[must_use]
73pub fn behavior_columns(behavior: &str) -> &'static [BehaviorColumn] {
74 match behavior {
75 "Versioned" => const { &[column("version", "bigint")] },
76 "Timestamps" => {
77 const {
78 &[
79 filled("created_at", "timestamptz", "now()"),
80 filled("updated_at", "timestamptz", "now()"),
81 ]
82 }
83 }
84 "SoftDelete" => const { &[optional("deleted_at", "timestamptz")] },
85 "PublishLock" => const { &[optional("published_at", "timestamptz")] },
86 "Audited" => {
87 const {
88 &[
89 optional("created_by", "uuid"),
90 optional("updated_by", "uuid"),
91 ]
92 }
93 }
94 "Active" => const { &[filled("is_active", "boolean", "true")] },
95 "Default" => const { &[filled("is_default", "boolean", "false")] },
96 "Slugged" => const { &[column("slug", "text")] },
97 "UniqueCode" => const { &[column("code", "text")] },
98 "Searched" => const { &[column("search_text", "text")] },
99 _ => &[],
100 }
101}
102
103pub struct StoredColumn {
104 pub suffix: &'static str,
106 pub sql_type: &'static str,
107}
108
109const fn stored(suffix: &'static str, sql_type: &'static str) -> StoredColumn {
110 StoredColumn { suffix, sql_type }
111}
112
113#[must_use]
116pub fn stored_columns(ty: &TypeName) -> Option<&'static [StoredColumn]> {
117 if let ("Secret", [inner]) = (ty.name.as_str(), ty.arguments.as_slice()) {
118 return stored_columns(inner);
119 }
120 if ty.record().is_some() || ty.name == "Uuid" {
121 return Some(const { &[stored("", "uuid")] });
122 }
123 Some(match ty.name.as_str() {
124 "bool" => const { &[stored("", "boolean")] },
125 "i32" => const { &[stored("", "integer")] },
126 "i64" | "Percentage" => const { &[stored("", "bigint")] },
127 "f64" => const { &[stored("", "double precision")] },
128 "String" | "Email" | "Url" | "Slug" | "Locale" | "Currency" | "TimeZone" | "TimeOfDay"
129 | "Weekday" | "PersonName" | "Password" => const { &[stored("", "text")] },
130 "Instant" | "Timestamp" => const { &[stored("", "timestamptz")] },
131 "Date" => const { &[stored("", "date")] },
132 "Money" => const { &[stored("", "bigint"), stored("_currency", "text")] },
133 "Measurement" => const { &[stored("", "double precision"), stored("_unit", "text")] },
134 "LocalDateTime" => const { &[stored("", "timestamp"), stored("_zone", "text")] },
135 "Coordinate" => {
136 const {
137 &[
138 stored("_latitude", "double precision"),
139 stored("_longitude", "double precision"),
140 ]
141 }
142 }
143 "PhoneNumber" => const { &[stored("_code", "bigint"), stored("_number", "text")] },
144 _ => return None,
145 })
146}
147
148#[must_use]
150pub fn stored_name(variant: &str) -> String {
151 snake_case(variant).to_uppercase()
152}
153
154#[must_use]
156pub fn snake_case(name: &str) -> String {
157 let characters: Vec<char> = name.chars().collect();
158 let mut snake = String::with_capacity(name.len() + 4);
159 for (index, &character) in characters.iter().enumerate() {
160 if index > 0 && starts_word(&characters, index) {
161 snake.push('_');
162 }
163 snake.extend(character.to_lowercase());
164 }
165 snake
166}
167
168#[must_use]
170pub fn pascal_case(snake: &str) -> String {
171 snake
172 .split('_')
173 .map(|word| {
174 let mut letters = word.chars();
175 letters.next().map_or_else(String::new, |first| {
176 first.to_ascii_uppercase().to_string() + letters.as_str()
177 })
178 })
179 .collect()
180}
181
182fn starts_word(characters: &[char], index: usize) -> bool {
183 let current = characters[index];
184 if !current.is_uppercase() {
185 return false;
186 }
187 let previous = characters[index - 1];
188 let next_is_lowercase = characters
189 .get(index + 1)
190 .is_some_and(|next| next.is_lowercase());
191 previous.is_lowercase()
192 || previous.is_ascii_digit()
193 || (previous.is_uppercase() && next_is_lowercase)
194}
195
196pub(crate) fn route_name(module: &Module, area: &str, route: &Route) -> String {
197 format!(
198 "{}.{area}.{}",
199 module.krate.crate_name,
200 route.handler.last().map_or("", |handler| unraw(handler))
201 )
202}
203
204#[must_use]
206pub fn unraw(segment: &str) -> &str {
207 segment.strip_prefix("r#").unwrap_or(segment)
208}
209
210#[must_use]
212pub fn handler_module(method: Method) -> &'static str {
213 match method {
214 Method::Get => QUERY_MODULE,
215 Method::Post | Method::Put | Method::Patch | Method::Delete => ACTION_MODULE,
216 }
217}
218
219#[must_use]
220pub fn contract_package(module_package: &str) -> String {
221 format!("{module_package}-contract")
222}
223
224#[must_use]
225pub fn crate_name(package: &str) -> String {
226 package.replace('-', "_")
227}
228
229#[must_use]
230pub fn screen_key(module: &str, area: &str, name: &str) -> String {
231 format!("{module}/{area}/{name}")
232}
233
234#[must_use]
235pub fn job_name(module: &Module, function: &[String]) -> String {
236 let function: Vec<&str> = function.iter().map(|segment| unraw(segment)).collect();
237 format!("{}.{}", module.krate.crate_name, function.join("."))
238}
239
240#[must_use]
241pub fn task_name(module: &Module, function: &[String]) -> String {
242 let name = function.last().map_or("", |name| unraw(name));
243 format!("{}.{name}", module.krate.crate_name)
244}
245
246pub(crate) fn is_plain_name(name: &str) -> bool {
248 !name.is_empty()
249 && name
250 .chars()
251 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
252}
253
254#[must_use]
255pub fn file_stem(file: &Path) -> String {
256 file.file_stem()
257 .map(|stem| stem.to_string_lossy().into_owned())
258 .unwrap_or_default()
259}
260
261impl AreaContribution {
262 #[must_use]
263 pub fn screen_file(&self, name: &str) -> PathBuf {
264 self.routes_file
265 .with_file_name("screens")
266 .join(format!("{name}.html"))
267 }
268}
269
270impl Crate {
271 #[must_use]
272 pub fn depends_on(&self, package: &str) -> bool {
273 self.dependencies
274 .iter()
275 .any(|dependency| dependency.package == package)
276 }
277
278 #[must_use]
279 pub fn gen_file(&self) -> PathBuf {
280 self.path.join("src").join(GEN_MODULE).join("mod.rs")
281 }
282
283 #[must_use]
284 pub fn wiring_file(&self) -> PathBuf {
285 self.path.join("src").join("wiring.rs")
286 }
287}
288
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
292pub enum WiringSide {
293 Module,
294 App,
295}
296
297impl Module {
298 #[must_use]
300 pub fn job_side(&self, function: &[String]) -> WiringSide {
301 match self.code.features.gate_on(function) {
302 Some(_) => WiringSide::App,
303 None => WiringSide::Module,
304 }
305 }
306}
307
308impl AreaDefinition {
309 #[must_use]
310 pub fn needs_generated_member_area(&self) -> bool {
311 !self.implements_member_area
312 && (self.is_permitted()
313 || self
314 .principal
315 .last()
316 .is_some_and(|name| name == MEMBER_PRINCIPAL))
317 }
318}
319
320impl KnownArea<'_> {
321 #[must_use]
322 pub fn is_public(&self) -> bool {
323 self.definition.is_none_or(|definition| {
324 definition
325 .principal
326 .last()
327 .is_some_and(|name| name == VISITOR)
328 })
329 }
330
331 #[must_use]
332 pub fn prefix(&self) -> Option<&str> {
333 match self.definition {
334 None => Some(""),
335 Some(definition) => definition.prefix.as_deref(),
336 }
337 }
338}
339
340#[derive(Clone, Debug, PartialEq, Eq)]
341pub struct KnownArea<'app> {
342 pub name: &'app str,
343 pub type_path: String,
344 pub contract: Option<&'app str>,
345 pub definition: Option<&'app AreaDefinition>,
346}
347
348impl App {
349 #[must_use]
350 pub fn areas(&self) -> Vec<KnownArea<'_>> {
351 let web = KnownArea {
352 name: WEB_AREA,
353 type_path: WEB_AREA_TYPE.to_owned(),
354 contract: None,
355 definition: None,
356 };
357 let local = self.modules.iter().map(|module| {
358 let contract = module.contract.as_ref().map(|krate| krate.package.as_str());
359 (contract, &module.defined_areas)
360 });
361 let remote = self.split.remote.iter().map(|module| {
362 (
363 Some(module.contract.package.as_str()),
364 &module.defined_areas,
365 )
366 });
367 let defined = local.chain(remote).flat_map(|(contract, definitions)| {
368 definitions.iter().map(move |definition| KnownArea {
369 name: &definition.name,
370 type_path: definition.type_path.join("::"),
371 contract,
372 definition: Some(definition),
373 })
374 });
375 std::iter::once(web).chain(defined).collect()
376 }
377
378 #[must_use]
379 pub fn area(&self, name: &str) -> Option<KnownArea<'_>> {
380 self.areas().into_iter().find(|area| area.name == name)
381 }
382
383 #[must_use]
384 pub fn member_home(&self, area: &AreaDefinition) -> Option<String> {
385 let prefix = area.prefix.as_deref()?;
386 self.modules_by_name()
387 .into_iter()
388 .flat_map(|module| &module.areas)
389 .filter(|contribution| contribution.area == area.name)
390 .flat_map(|contribution| &contribution.routes)
391 .find(|route| route.screen.is_some() && !route.path.contains('{'))
392 .map(|route| format!("{prefix}{}", route.path))
393 }
394
395 #[must_use]
396 pub fn depends_on(&self, package: &str) -> bool {
397 self.app.depends_on(package)
398 }
399
400 #[must_use]
401 pub fn wiring_file(&self) -> PathBuf {
402 self.app.wiring_file()
403 }
404
405 #[must_use]
407 pub fn embedded_path(&self, file: &Path) -> Option<String> {
408 relative_path(&self.app.path, file)
409 }
410
411 #[must_use]
412 pub fn route_sites(&self) -> Vec<RouteSite<'_>> {
413 let mut sites = Vec::new();
414 for module in self.modules_by_name() {
415 let mut areas: Vec<&AreaContribution> = module.areas.iter().collect();
416 areas.sort_by(|left, right| left.area.cmp(&right.area));
417 for area in areas {
418 sites.extend(area.routes.iter().map(|route| RouteSite {
419 module,
420 area,
421 route,
422 }));
423 }
424 }
425 sites
426 }
427
428 fn modules_by_name(&self) -> Vec<&Module> {
430 let mut modules: Vec<&Module> = self.modules.iter().collect();
431 modules.sort_by(|left, right| left.krate.crate_name.cmp(&right.krate.crate_name));
432 modules
433 }
434}
435
436#[derive(Clone, Copy, Debug)]
437pub struct RouteSite<'app> {
438 pub module: &'app Module,
439 pub area: &'app AreaContribution,
440 pub route: &'app Route,
441}
442
443impl RouteSite<'_> {
444 #[must_use]
445 pub fn name(&self) -> String {
446 route_name(self.module, &self.area.area, self.route)
447 }
448
449 #[must_use]
450 pub fn full_path(&self, app: &App) -> Option<String> {
451 let area = app.area(&self.area.area)?;
452 Some(format!("{}{}", area.prefix()?, self.route.path))
453 }
454}
455
456#[must_use]
458pub fn key_constant(key: &str) -> String {
459 key.chars()
460 .map(|character| match character {
461 '.' | '-' => '_',
462 other => other.to_ascii_uppercase(),
463 })
464 .collect()
465}
466
467#[must_use]
468pub fn path_parameters(path: &str) -> Vec<&str> {
469 written_parameters(path)
470 .map(|name| name.trim_start_matches('*'))
471 .collect()
472}
473
474fn written_parameters(path: &str) -> impl Iterator<Item = &str> {
476 path.split('/')
477 .filter_map(|segment| segment.strip_prefix('{')?.strip_suffix('}'))
478}
479
480#[must_use]
482pub fn bound_aggregate<'module>(
483 module: &'module Module,
484 parameter: &str,
485) -> Option<&'module Aggregate> {
486 module
487 .code
488 .aggregates
489 .iter()
490 .find(|aggregate| snake_case(&aggregate.name) == parameter)
491}
492
493#[derive(Clone, Debug)]
495pub enum PathParameter<'app> {
496 Aggregate(&'app Aggregate),
498 Reference {
500 module: &'app Module,
501 aggregate: &'app Aggregate,
502 },
503 Value(String),
505 Rest,
507}
508
509#[must_use]
511pub fn route_parameters<'app, 'path>(
512 app: &'app App,
513 module: &'app Module,
514 path: &'path str,
515) -> Vec<(&'path str, Option<PathParameter<'app>>)> {
516 written_parameters(path)
517 .map(|name| match name.strip_prefix('*') {
518 Some(rest) => (rest, Some(PathParameter::Rest)),
519 None => (name, path_parameter(app, module, name)),
520 })
521 .collect()
522}
523
524fn path_parameter<'app>(
525 app: &'app App,
526 module: &'app Module,
527 parameter: &str,
528) -> Option<PathParameter<'app>> {
529 if let Some(aggregate) = bound_aggregate(module, parameter) {
530 return Some(PathParameter::Aggregate(aggregate));
531 }
532 let field = module
533 .code
534 .aggregates
535 .iter()
536 .flat_map(|aggregate| &aggregate.fields)
537 .find(|field| field.name == parameter)?;
538 let Some(record) = field.ty.record() else {
539 return module
540 .code
541 .contract_type(&field.ty)
542 .map(PathParameter::Value);
543 };
544 let referenced = app.modules.iter().find_map(|module| {
545 let aggregate = module
546 .code
547 .aggregates
548 .iter()
549 .find(|aggregate| aggregate.name == record)?;
550 Some(PathParameter::Reference { module, aggregate })
551 });
552 Some(referenced.unwrap_or_else(|| PathParameter::Value("sofie::db::Uuid".to_owned())))
553}
554
555#[must_use]
557pub fn relative_path(from: &Path, to: &Path) -> Option<String> {
558 let from: Vec<Component> = from.components().collect();
559 let to: Vec<Component> = to.components().collect();
560 let common = from
561 .iter()
562 .zip(&to)
563 .take_while(|(left, right)| left == right)
564 .count();
565 let mut parts = Vec::new();
566 for component in &from[common..] {
567 if !matches!(component, Component::Normal(_)) {
568 return None;
569 }
570 parts.push("..");
571 }
572 for component in &to[common..] {
573 parts.push(component.as_os_str().to_str()?);
574 }
575 Some(parts.join("/"))
576}
577
578#[cfg(test)]
579mod tests {
580 use std::path::Path;
581
582 use super::{is_plain_name, relative_path};
583
584 #[test]
585 fn relative_paths_climb_out_of_the_app_crate() {
586 let relative = relative_path(
587 Path::new("./app/src"),
588 Path::new("./modules/order/migrations/0001_a.sql"),
589 );
590
591 assert_eq!(
592 relative.as_deref(),
593 Some("../../modules/order/migrations/0001_a.sql")
594 );
595 }
596
597 #[test]
598 fn a_plain_name_holds_letters_digits_underscores_and_dashes() {
599 assert!(is_plain_name("orders_table-2"));
600 for name in ["", "../x", "#orders", "a b", "a.b"] {
601 assert!(!is_plain_name(name), "{name}");
602 }
603 }
604}