glacier-web-cli 0.1.1

Command-line tool for creating and running Glacier applications.
//! `glacier generate authentication`: sign up, sign in and sign out, as
//! code in the app, like Rails' `generate authentication` and Phoenix's
//! `phx.gen.auth`. Nothing is hidden in the framework beyond password
//! hashing and tokens (`glacier::auth`), so the app can change any of it.
//!
//! It writes `User` and `Session` models and their migrations, an
//! `Authentication` module (who's signed in, and a `RequireAuthentication`
//! pipeline), sessions and registrations controllers, and tests. With
//! `--api`, the controllers speak JSON and clients send a bearer token;
//! otherwise they render sign-in and sign-up pages and keep the token in
//! the session cookie.

use std::fs;

use crate::scaffold::{
    add_to_controllers, add_to_models, add_to_tests, check_app, refuse_existing, write,
};

const CREATE_USERS: &str = include_str!("../templates/auth/create_users.rs");
const CREATE_SESSIONS: &str = include_str!("../templates/auth/create_sessions.rs");
const USER: &str = include_str!("../templates/auth/user.glacier");
const SESSION: &str = include_str!("../templates/auth/session.glacier");
const USER_FACTORY: &str = include_str!("../templates/auth/user_factory.glacier");
const USER_TEST: &str = include_str!("../templates/auth/user.test.glacier");

struct Flavour {
    authentication: &'static str,
    sessions: &'static str,
    registrations: &'static str,
    sessions_test: &'static str,
    registrations_test: &'static str,
}

const BROWSER: Flavour = Flavour {
    authentication: include_str!("../templates/auth/authentication.rs"),
    sessions: include_str!("../templates/auth/sessions_controller.glacier"),
    registrations: include_str!("../templates/auth/registrations_controller.glacier"),
    sessions_test: include_str!("../templates/auth/sessions_controller.test.glacier"),
    registrations_test: include_str!("../templates/auth/registrations_controller.test.glacier"),
};

const API: Flavour = Flavour {
    authentication: include_str!("../templates/auth/authentication_api.rs"),
    sessions: include_str!("../templates/auth/sessions_controller_api.glacier"),
    registrations: include_str!("../templates/auth/registrations_controller_api.glacier"),
    sessions_test: include_str!("../templates/auth/sessions_controller_api.test.glacier"),
    registrations_test: include_str!("../templates/auth/registrations_controller_api.test.glacier"),
};

const SESSIONS_NEW: &str = include_str!("../templates/auth/sessions_new.html");
const REGISTRATIONS_NEW: &str = include_str!("../templates/auth/registrations_new.html");

pub fn authentication(api: bool) -> Result<(), String> {
    check_app()?;
    refuse_existing(&[
        "src/models/user.glacier",
        "src/models/user.rs",
        "src/models/session.glacier",
        "src/models/session.rs",
        "src/authentication.rs",
        "src/controllers/sessions_controller.glacier",
        "src/controllers/registrations_controller.glacier",
    ])?;
    let api = api || crate::scaffold::api_app();
    let flavour = if api { &API } else { &BROWSER };

    crate::generate::migration_with("create_users", CREATE_USERS)?;
    crate::generate::migration_with("create_sessions", CREATE_SESSIONS)?;
    write("src/models/user.glacier", USER)?;
    write("src/models/session.glacier", SESSION)?;
    add_to_models("session")?;
    add_to_models("user")?;

    write("src/authentication.rs", flavour.authentication)?;
    write(
        "src/controllers/sessions_controller.glacier",
        flavour.sessions,
    )?;
    write(
        "src/controllers/registrations_controller.glacier",
        flavour.registrations,
    )?;
    add_to_controllers("registrations")?;
    add_to_controllers("sessions")?;
    if !api {
        write("src/views/sessions/new.html", SESSIONS_NEW)?;
        write("src/views/registrations/new.html", REGISTRATIONS_NEW)?;
    }

    write("tests/factories/user.glacier", USER_FACTORY)?;
    write("tests/models/user.test.glacier", USER_TEST)?;
    write(
        "tests/controllers/sessions_controller.test.glacier",
        flavour.sessions_test,
    )?;
    write(
        "tests/controllers/registrations_controller.test.glacier",
        flavour.registrations_test,
    )?;
    add_to_tests(&[
        "controllers/registrations_controller",
        "controllers/sessions_controller",
        "factories/user",
        "models/user",
    ])?;

    register_module()?;
    add_routes(api)?;

    let protect = if api { "api" } else { "browser" };
    println!(
        "\nNext: glacier db migrate. Routes for signed-in users go in a scope\n\
         through the `authenticated` pipeline:\n\n    \
         scope \"/\" through {protect}, authenticated {{\n        \
         get \"/account\" => Accounts::show, as account;\n    }}\n\n\
         and `current_user(&ctx)` (alias Authentication.current_user) is who's signed in."
    );
    Ok(())
}

/// `pub mod authentication;` and the middleware, in src/lib.rs.
fn register_module() -> Result<(), String> {
    let path = "src/lib.rs";
    let lib = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
    let mut updated = lib.clone();
    if !updated.contains("pub mod authentication;") {
        updated = match updated.find("pub mod controllers;") {
            Some(at) => format!(
                "{}pub mod authentication;\n{}",
                &updated[..at],
                &updated[at..]
            ),
            None => format!("pub mod authentication;\n{updated}"),
        };
    }
    let middleware = "        // Who's signed in, for every request.\n        \
                      .middleware(authentication::Authentication)\n";
    if !updated.contains("authentication::Authentication") {
        let anchor = "        .middleware(Logger)\n";
        match updated.find(anchor) {
            Some(at) => updated.insert_str(at + anchor.len(), middleware),
            None => println!(
                "\nRegister the middleware on your App in {path}:\n\n    \
                 .middleware(authentication::Authentication)"
            ),
        }
    }
    if updated != lib {
        fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
        println!("      update  {path}");
    }
    Ok(())
}

/// The `authenticated` pipeline and the sign-in routes, in src/routes.rs.
fn add_routes(api: bool) -> Result<(), String> {
    let path = "src/routes.rs";
    let routes = fs::read_to_string(path).map_err(|e| format!("reading {path}: {e}"))?;
    let mut updated = routes.clone();

    let import = "use crate::authentication::RequireAuthentication;\n";
    if !updated.contains(import) {
        let anchor = "use crate::controllers::*;\n";
        updated = match updated.find(anchor) {
            Some(at) => format!("{}{import}{}", &updated[..at], &updated[at..]),
            None => format!("{import}{updated}"),
        };
    }

    let mut pipelines = String::new();
    if api && !updated.contains("pipeline api ") {
        pipelines.push_str("    // JSON APIs: errors as JSON, no CSRF (clients send tokens).\n");
        pipelines.push_str("    pipeline api = Pipeline::api();\n");
    }
    pipelines.push_str("    // Only for signed-in users; see src/authentication.rs.\n");
    pipelines
        .push_str("    pipeline authenticated = Pipeline::new().with(RequireAuthentication);\n");
    let scope = if api {
        "\n    scope \"/\" through api {\n        \
         post \"/session\" => Sessions::create, as session;\n        \
         delete \"/session\" => Sessions::destroy;\n        \
         post \"/registration\" => Registrations::create, as registration;\n    }\n"
    } else {
        "\n    scope \"/\" through browser {\n        \
         get \"/session/new\" => Sessions::new, as new_session;\n        \
         post \"/session\" => Sessions::create, as session;\n        \
         delete \"/session\" => Sessions::destroy;\n        \
         get \"/registration/new\" => Registrations::new, as new_registration;\n        \
         post \"/registration\" => Registrations::create, as registration;\n    }\n"
    };

    // Pipelines after the last one declared; the scope at the table's end.
    let pipelines_at = updated
        .rfind("    pipeline ")
        .and_then(|at| updated[at..].find('\n').map(|end| at + end + 1));
    let close = updated.rfind("\n}").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, &pipelines);
        }
        _ => {
            println!("\nAdd these to the routes! table in {path}:\n\n{pipelines}{scope}");
        }
    }
    if updated != routes {
        fs::write(path, updated).map_err(|e| format!("writing {path}: {e}"))?;
        println!("      update  {path}");
    }
    Ok(())
}