glacier-web-cli 0.5.0

Command-line tool for creating and running Glacier applications.
//! `glacier generate access org`: orgs whose members share rows, as
//! Phoenix's `phx.gen.auth --scope` and Jumpstart's accounts do. It
//! writes `Org` and `Membership` with their migrations, `OrgAccess`
//! (extending the `UserAccess` that `generate authentication` wrote), the
//! `InOrg` middleware, an orgs controller and its tests, and its routes
//! through an `in_org` pipeline. The org is the one chosen and kept in the
//! session (`--urls session`, the default for an app with pages), or named
//! in the path, `/orgs/:org_id/…` (`--urls nested`, the default for a JSON
//! API, which has no session). It's the app's code afterwards: nothing is
//! hidden in the framework.

use std::fs;
use std::path::Path;

use crate::scaffold::{check_app, refuse_existing, write};

macro_rules! template {
    ($name:literal) => {
        include_str!(concat!("../templates/access/", $name))
    };
}

/// `glacier generate access <kind>`; `org` is the one there is.
/// How an app's URLs name the org.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Urls {
    Session,
    Nested,
    Prefix,
}

impl Urls {
    /// The invitation and member templates for these URLs: where the
    /// org's page is, and the paths that name the org.
    fn render(self, source: &str) -> String {
        let (org_page, joined, enter) = match self {
            Urls::Session => (
                "org_path()",
                "session.insert(\"org_id\", org_id)?\n        redirect org_path(), notice: \"Welcome!\"",
                "post \"/orgs/#{org.id}/switch\"\n  \"/org\".to_s()",
            ),
            // The org's id is at the front of the paths built in its
            // requests; the invitation's link is anyone's.
            Urls::Prefix => (
                "org_path()",
                "redirect \"/#{org_id}/org\", notice: \"Welcome!\"",
                "\"/#{org.id}/org\"",
            ),
            Urls::Nested => (
                "org_path(org_access.org.id)",
                "redirect org_path(org_id), notice: \"Welcome!\"",
                "\"/orgs/#{org.id}\"",
            ),
        };
        // `membership_path(member.id)`, or `membership_path(org.id,
        // member.id)` when the org is in the path.
        let nested = self == Urls::Nested;
        let path = |name: &str, org: &str, rest: &str| {
            let args: Vec<&str> = [if nested { org } else { "" }, rest]
                .into_iter()
                .filter(|a| !a.is_empty())
                .collect();
            format!("{name}({})", args.join(", "))
        };
        source
            .replace("{{org_page}}", org_page)
            .replace("{{joined}}", joined)
            .replace("{{enter_org}}", enter)
            .replace(
                "{{invitations_path}}",
                &path("invitations_path", "org_id", ""),
            )
            .replace(
                "{{new_invitation_path}}",
                &path("new_invitation_path", "org.id", ""),
            )
            .replace(
                "{{membership_path}}",
                &path("membership_path", "org.id", "member.id"),
            )
            .replace(
                "{{invitation_withdrawal_path}}",
                &path("invitation_withdrawal_path", "org.id", "invitation.id"),
            )
    }
}

pub fn access(kind: &str, urls: Option<&str>) -> Result<(), String> {
    if kind != "org" {
        return Err(format!(
            "`glacier generate access {kind}`: the kind there is is `org`"
        ));
    }
    check_app()?;
    if !crate::scaffold::discovers() || !Path::new(crate::scaffold::ROUTES_GLACIER).exists() {
        return Err(
            "`glacier generate access` needs an app made with `glacier::app!()` and src/routes.glacier"
                .into(),
        );
    }
    if !Path::new("src/access/user_access.glacier").exists() {
        return Err(
            "run `glacier generate authentication` first: an org's access extends the user's"
                .into(),
        );
    }
    refuse_existing(&[
        "src/models/org.glacier",
        "src/models/membership.glacier",
        "src/access/org_access.glacier",
        "src/middleware/in_org.glacier",
        "src/controllers/orgs_controller.glacier",
    ])?;
    let api = crate::scaffold::api_app();
    let urls = match (urls, api) {
        (None, false) | (Some("session"), false) => Urls::Session,
        (Some("session"), true) => {
            return Err("a JSON API has no session to keep the org in: use `--urls nested`".into());
        }
        (None, true) | (Some("nested"), _) => Urls::Nested,
        (Some("prefix"), false) => Urls::Prefix,
        (Some("prefix"), true) => {
            return Err(
                "`--urls prefix` is for an app with pages: a JSON API uses `--urls nested`".into(),
            );
        }
        (Some(other), _) => {
            return Err(format!(
                "`--urls {other}`: expected `session`, `nested` or `prefix`"
            ));
        }
    };

    crate::generate::migration_with("create_orgs", template!("create_orgs.glacier"))?;
    crate::generate::migration_with(
        "create_memberships",
        template!("create_memberships.glacier"),
    )?;
    write("src/models/org.glacier", template!("org.glacier"))?;
    write(
        "src/models/membership.glacier",
        template!("membership.glacier"),
    )?;
    write(
        "src/access/org_access.glacier",
        template!("org_access.glacier"),
    )?;
    write(
        "src/middleware/in_org.glacier",
        match urls {
            Urls::Session => template!("in_org_session.glacier"),
            Urls::Prefix => template!("in_org_prefix.glacier"),
            Urls::Nested => template!("in_org.glacier"),
        },
    )?;
    if api {
        write(
            "src/controllers/orgs_controller.glacier",
            template!("orgs_controller_api.glacier"),
        )?;
        write(
            "tests/controllers/orgs_controller.test.glacier",
            template!("orgs_controller_api.test.glacier"),
        )?;
    } else if urls == Urls::Prefix {
        write(
            "src/controllers/orgs_controller.glacier",
            template!("orgs_controller_prefix.glacier"),
        )?;
        write(
            "src/views/orgs/index.html",
            template!("orgs_index_prefix.html"),
        )?;
        write("src/views/orgs/new.html", template!("orgs_new.html"))?;
        write(
            "tests/controllers/orgs_controller.test.glacier",
            template!("orgs_controller_prefix.test.glacier"),
        )?;
        invitations(urls)?;
        // Before routing: the org's id comes off the path.
        crate::scaffold::add_app_setting(
            "middleware glacier::middleware::IdPrefix",
            "URLs that start with an org's id, /42/…: the id comes off before routing.",
        )?;
    } else if urls == Urls::Session {
        write(
            "src/controllers/orgs_controller.glacier",
            template!("orgs_controller_session.glacier"),
        )?;
        write("src/views/orgs/index.html", template!("orgs_index.html"))?;
        write("src/views/orgs/new.html", template!("orgs_new.html"))?;
        write(
            "tests/controllers/orgs_controller.test.glacier",
            template!("orgs_controller_session.test.glacier"),
        )?;
        invitations(urls)?;
    } else {
        write(
            "src/controllers/orgs_controller.glacier",
            template!("orgs_controller.glacier"),
        )?;
        write("src/views/orgs/new.html", template!("orgs_new.html"))?;
        write(
            "tests/controllers/orgs_controller.test.glacier",
            template!("orgs_controller.test.glacier"),
        )?;
        invitations(urls)?;
    }
    write(
        "tests/factories/org.glacier",
        template!("org_factory.glacier"),
    )?;
    write(
        "tests/factories/membership.glacier",
        template!("membership_factory.glacier"),
    )?;
    add_routes(api, urls)?;

    let scope = if urls != Urls::Nested {
        "scope through `in_org` (the org's pages)"
    } else {
        "`/orgs/:org_id` scope, through `in_org`,"
    };
    println!(
        "\nNext: glacier db migrate. An org's own routes go in the {scope} where\n\
         `access` is the org's: give a model `owned_by :org` and read it with\n\
         `Post.for(access)`, or `glacier generate scaffold Post title:string --access org`."
    );
    Ok(())
}

/// Owners inviting people by email, and removing members; `OrgPolicy`
/// decides by role. For an app with pages, whichever its URLs.
fn invitations(urls: Urls) -> Result<(), String> {
    crate::generate::migration_with(
        "create_invitations",
        template!("create_invitations.glacier"),
    )?;
    write(
        "src/models/invitation.glacier",
        template!("invitation.glacier"),
    )?;
    write(
        "src/policies/org_policy.glacier",
        template!("org_policy.glacier"),
    )?;
    write(
        "src/mailers/invitations_mailer.glacier",
        template!("invitations_mailer.glacier"),
    )?;
    write(
        "src/views/invitations_mailer/invite.txt",
        template!("invitations_mailer_invite.txt"),
    )?;
    write(
        "src/views/invitations_mailer/invite.html",
        template!("invitations_mailer_invite.html"),
    )?;
    write(
        "src/controllers/invitations_controller.glacier",
        &urls.render(template!("invitations_controller.glacier")),
    )?;
    write(
        "src/controllers/memberships_controller.glacier",
        &urls.render(template!("memberships_controller.glacier")),
    )?;
    write(
        "src/views/invitations/new.html",
        &urls.render(template!("invitations_new.html")),
    )?;
    write(
        "src/views/invitations/show.html",
        template!("invitations_show.html"),
    )?;
    write(
        "tests/controllers/invitations_controller.test.glacier",
        &urls.render(template!("invitations_controller.test.glacier")),
    )?;
    write(
        "src/views/orgs/show.html",
        &urls.render(template!("orgs_show_members.html")),
    )
}

/// The `in_org` pipeline, the routes that make an org, and its scope.
fn add_routes(api: bool, urls: Urls) -> Result<(), String> {
    let path = crate::scaffold::ROUTES_GLACIER;
    let routes = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
    let mut updated = routes.clone();
    let alias = "  alias Middleware.InOrg\n";
    if !updated.contains(alias) {
        let anchor = "  alias Middleware.RequireAuthentication\n";
        updated = match updated.find(anchor) {
            Some(at) => format!(
                "{}{alias}{}",
                &updated[..at + anchor.len()],
                &updated[at + anchor.len()..]
            ),
            None => match updated.find("routes do\n") {
                Some(at) => {
                    let after = at + "routes do\n".len();
                    format!("{}{alias}{}", &updated[..after], &updated[after..])
                }
                None => format!("{alias}{updated}"),
            },
        };
    }
    let pipeline = "  # Only an org's members, in its routes; see src/middleware/in_org.glacier.\n  \
                    pipeline in_org = Pipeline.new().with(InOrg)\n";
    let protect = if api { "api" } else { "browser" };
    let making = if api {
        "    post \"/orgs\", OrgsController.create, as: :orgs\n".to_owned()
    } else {
        "    get \"/orgs/new\", OrgsController.new, as: :new_org\n    \
         post \"/orgs\", OrgsController.create, as: :orgs\n"
            .to_owned()
    };
    let switch = if urls == Urls::Session {
        "    post \"/orgs/:id/switch\", OrgsController.switch, as: :switch_org\n"
    } else {
        ""
    };
    // Inviting and removing members, in an app with pages: under the
    // org's page, `/org/…` or `/orgs/:org_id/…`.
    let at = if urls == Urls::Nested { "" } else { "/org" };
    let (members, invitation_links) = if api {
        (String::new(), "")
    } else {
        (
            format!(
                "    get \"{at}/invitations/new\", InvitationsController.new, as: :new_invitation\n    \
                 post \"{at}/invitations\", InvitationsController.create, as: :invitations\n    \
                 delete \"{at}/invitations/:id\", InvitationsController.destroy, as: :invitation_withdrawal\n    \
                 delete \"{at}/members/:id\", MembershipsController.destroy, as: :membership\n"
            ),
            "\n  # An invitation's link, for whoever is signed in (signing in comes back).\n  \
             scope \"/\", through: [:browser, :authenticated] do\n    \
             get \"/invitations/:token\", InvitationsController.show, as: :invitation\n    \
             post \"/invitations/:token\", InvitationsController.accept\n  end\n",
        )
    };
    let scope = if urls != Urls::Nested {
        format!(
            "\n  scope \"/\", through: [:{protect}, :authenticated] do\n    \
             get \"/orgs\", OrgsController.index, as: :orgs\n    \
             get \"/orgs/new\", OrgsController.new, as: :new_org\n    \
             post \"/orgs\", OrgsController.create\n{switch}  end\n\n  \
             # The org's pages: `access` in them is its OrgAccess.\n  \
             scope \"/\", through: [:{protect}, :authenticated, :in_org] do\n    \
             get \"/org\", OrgsController.show, as: :org\n{members}  end\n{invitation_links}"
        )
    } else {
        format!(
            "\n  scope \"/\", through: [:{protect}, :authenticated] do\n{making}  end\n\n  \
             # An org's own pages: `access` in them is its OrgAccess.\n  \
             scope \"/orgs/:org_id\", through: [:{protect}, :authenticated, :in_org] do\n    \
             get \"/\", OrgsController.show, as: :org\n{members}  end\n{invitation_links}"
        )
    };
    let pipelines_at = updated
        .rfind("  pipeline ")
        .and_then(|at| updated[at..].find('\n').map(|end| at + end + 1));
    let close = updated.rfind("\nend").map(|at| at + 1);
    match (pipelines_at, close) {
        (Some(pipelines_at), Some(close)) if pipelines_at < close => {
            updated.insert_str(close, &scope);
            updated.insert_str(pipelines_at, pipeline);
        }
        _ => println!("\nAdd these to the routes in {path}:\n\n{pipeline}{scope}"),
    }
    if updated != routes {
        fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
        println!("      update  {path}");
    }
    Ok(())
}