Skip to main content

sofie_cli/new_command/
area.rs

1use sofie_model::naming::pascal_case;
2use sofie_model::{App, Crate};
3
4use super::edits::module_file;
5use super::route::{NewRoute, file_names, new_route};
6use super::{Edits, checked_name, checked_type_name, find_module};
7use crate::gen_command::GenError;
8
9/// The variants of `sofie::http::Access`, each fixed by the principal the area is written with.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum AreaAccess {
12    Public,
13    Owned,
14    Permitted,
15}
16
17impl AreaAccess {
18    pub const ALL: [Self; 3] = [Self::Public, Self::Owned, Self::Permitted];
19
20    #[must_use]
21    pub fn parse(name: &str) -> Option<Self> {
22        Self::ALL.into_iter().find(|access| access.name() == name)
23    }
24
25    /// Its name on the command line, and the crate and type of the area's principal.
26    fn parts(self) -> (&'static str, &'static str, &'static str) {
27        match self {
28            Self::Public => ("public", "sofie::http", "Visitor"),
29            Self::Owned => ("owned", "sofie_identity", "Account"),
30            Self::Permitted => ("permitted", "sofie_identity", "Permitted"),
31        }
32    }
33
34    #[must_use]
35    pub fn name(self) -> &'static str {
36        self.parts().0
37    }
38
39    fn signs_in(self) -> bool {
40        self != Self::Public
41    }
42}
43
44/// The area in the module's contract, and its first page, which a member area lands on.
45pub(super) fn new_area(
46    edits: &mut Edits,
47    app: &App,
48    module: &str,
49    name: &str,
50    access: AreaAccess,
51) -> Result<(), GenError> {
52    let name = checked_name(name)?;
53    let type_name = pascal_case(name);
54    checked_type_name(&type_name)?;
55    if app.area(name).is_some() {
56        return Err(GenError::Usage(format!(
57            "the app has an area `{name}` already; choose another name"
58        )));
59    }
60    let defining = find_module(app, module)?;
61    let contract = defining.contract.as_ref().ok_or_else(|| {
62        GenError::Usage(format!(
63            "module `{module}` has no contract to define the area in; add `contract/` as \
64             `sofie new module` writes it"
65        ))
66    })?;
67    refuse_taken_prefix(app, name)?;
68    refuse_taken_type_name(app, contract, name, &type_name)?;
69    write_definition(edits, contract, name, &type_name, access)?;
70    depend_on_principal(edits, app, contract, access)?;
71    let reloaded = sofie_model::load(&app.root).map_err(GenError::Model)?;
72    new_route(
73        edits,
74        &reloaded,
75        &NewRoute {
76            module: module.to_owned(),
77            area: name.to_owned(),
78            method: "get".to_owned(),
79            path: HOME.to_owned(),
80            handler: format!("{name}_home"),
81            screen: Some("home".to_owned()),
82            fields: None,
83            read_model: None,
84            title: None,
85            back: None,
86        },
87    )
88}
89
90/// A member area lands on its first parameterless screen; `/` would serve the prefix with a
91/// trailing slash only.
92const HOME: &str = "/home";
93
94fn prefix(name: &str) -> String {
95    format!("/{}", name.replace('_', "-"))
96}
97
98fn refuse_taken_prefix(app: &App, name: &str) -> Result<(), GenError> {
99    let prefix = prefix(name);
100    let areas = app.areas();
101    let Some(taken) = areas
102        .iter()
103        .find(|area| area.prefix() == Some(prefix.as_str()))
104    else {
105        return Ok(());
106    };
107    Err(GenError::Usage(format!(
108        "area `{}` serves under the prefix `{prefix}` already, which area `{name}` would take; \
109         choose another name",
110        taken.name
111    )))
112}
113
114/// Every principal is refused, not only this area's: a later area of another access imports
115/// its principal into the same file.
116fn refuse_taken_type_name(
117    app: &App,
118    contract: &Crate,
119    name: &str,
120    type_name: &str,
121) -> Result<(), GenError> {
122    let file = module_file(contract, "area");
123    let in_file = if file.is_file() {
124        file_names(&file)?
125    } else {
126        Vec::new()
127    };
128    let principals = AreaAccess::ALL.map(|access| access.parts().2);
129    if !in_file
130        .iter()
131        .map(String::as_str)
132        .chain(principals)
133        .any(|taken| taken == type_name)
134    {
135        return Ok(());
136    }
137    Err(GenError::Usage(format!(
138        "`{type_name}`, the type area `{name}` would define, is a name {} declares or imports \
139         already; choose another name",
140        file.strip_prefix(&app.root).unwrap_or(&file).display()
141    )))
142}
143
144fn write_definition(
145    edits: &mut Edits,
146    contract: &Crate,
147    name: &str,
148    type_name: &str,
149    access: AreaAccess,
150) -> Result<(), GenError> {
151    let (_, principal_crate, principal) = access.parts();
152    let prefix = prefix(name);
153    let sign_in = if access.signs_in() {
154        format!("{prefix}/sign-in")
155    } else {
156        "/".to_owned()
157    };
158    let definition = format!(
159        "pub struct {type_name};\n\nimpl Area for {type_name} {{\n    \
160         const NAME: &'static str = \"{name}\";\n    const PREFIX: &'static str = \"{prefix}\";\n    \
161         const SIGN_IN: &'static str = \"{sign_in}\";\n    type Principal = {principal};\n}}\n"
162    );
163    let file = module_file(contract, "area");
164    let imports = [
165        ("use sofie::http", "Area"),
166        (&format!("use {principal_crate}"), principal),
167    ];
168    edits.append(&file, &imports, &definition)?;
169    edits.declare(contract, &["area"])
170}
171
172/// `sofie gen` then adds sofie-identity's module, sign-in routes and `MemberArea` for an
173/// area members sign in to.
174fn depend_on_principal(
175    edits: &mut Edits,
176    app: &App,
177    contract: &Crate,
178    access: AreaAccess,
179) -> Result<(), GenError> {
180    edits.depend_on_framework(contract)?;
181    match access {
182        AreaAccess::Public => return Ok(()),
183        AreaAccess::Owned => edits.depend_on_workspace(contract, IDENTITY)?,
184        AreaAccess::Permitted => edits.enable_feature(contract, IDENTITY, "permissions")?,
185    }
186    edits.depend_on_workspace(&app.app, IDENTITY)?;
187    edits.depend(&app.app, contract)
188}
189
190const IDENTITY: &str = "sofie-identity";
191
192#[cfg(test)]
193mod tests {
194    use super::AreaAccess;
195
196    #[test]
197    fn each_name_reads_back_as_its_access() {
198        for access in AreaAccess::ALL {
199            assert_eq!(AreaAccess::parse(access.name()), Some(access));
200        }
201        assert_eq!(AreaAccess::parse("private"), None);
202    }
203}