Skip to main content

sofie_cli/new_command/
area.rs

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