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