rproj 0.13.1

Guided bootstrap-to-game-dev CLI for Roblox: takes a fresh Windows PC to a working Roblox/Luau setup, then scaffolds projects on it
//! Plain fallback shown when bare `rproj` is not attached to a terminal.

/// Command rows. Kept as data so the column stays aligned by construction -
/// hand-padded columns in a string literal drift the moment one row
/// changes, and nothing fails when they do.
const COMMANDS: &[(&str, &str, &[&str])] = &[
    (
        "๐Ÿš€",
        "rproj new <name>",
        &[
            "Scaffold a project. Sets your machine up first if it",
            "hasn't been, then asks only about this project's packages.",
            "--like <setup> reuses a saved selection, --save-setup saves one",
        ],
    ),
    (
        "๐Ÿ”ง",
        "rproj setup",
        &[
            "Install or change the machine-wide tools: system apps, CLI",
            "tools, Studio plugins, editor extensions",
        ],
    ),
    (
        // Every icon here is a plain double-width emoji with no variation
        // selector. A selector-carrying one (โš™๏ธ, ๐Ÿ› ๏ธ) is two `char`s wide but
        // one or two *columns* depending on the console, so no padding
        // arithmetic can keep the column straight for it.
        "๐Ÿ”ฉ",
        "rproj configure [key]",
        &[
            "Walk through a tool's settings one at a time - StyLua,",
            "Selene, luau-lsp - or edit the tree future projects inherit",
        ],
    ),
    (
        "๐Ÿ”„",
        "rproj upgrade",
        &[
            "Re-apply rproj's generated config to a project you already made,",
            "so it picks up fixes shipped since it was scaffolded",
        ],
    ),
    (
        "๐Ÿ‘€",
        "rproj watch",
        &["Resume the dev loop: install what's missing, watch the sourcemap"],
    ),
    (
        "๐Ÿงช",
        "rproj test [args]",
        &["Restore dependencies and run the project's selected test runner"],
    ),
    (
        "๐Ÿ“‹",
        "rproj copy",
        &["Copy every file under src/ to the clipboard, with path headers"],
    ),
    (
        "๐Ÿ“–",
        "rproj info [key]",
        &["Look up what a tool or package does, and how to use it"],
    ),
];

pub fn run() {
    let version = env!("CARGO_PKG_VERSION");
    let (rocket, book, sparkle) = ("๐ŸŽฎ  ", "๐Ÿ“š  ", "โœจ  ");

    println!("\n{rocket}rproj {version} - guided bootstrap-to-game-dev CLI for Roblox\n");
    println!("    Takes a fresh PC all the way to a working Roblox dev setup, then");
    println!("    scaffolds projects on top of it. Explains every tool and package");
    println!("    as it goes, so you end up knowing why your setup looks like it does.\n");

    println!("{book}Commands\n");
    let width = COMMANDS
        .iter()
        .map(|(_, name, _)| name.len())
        .max()
        .unwrap_or(0);
    // Terminal *columns* the icon occupies, not `char`s: every icon above is
    // one char and two columns wide, plus two spaces of gutter.
    let icon_columns = 4;
    for (icon, name, lines) in COMMANDS {
        for (i, line) in lines.iter().enumerate() {
            let lead = match i {
                0 => format!("{icon}  "),
                // Continuation rows leave the icon and name columns blank,
                // so a multi-line description reads as one paragraph rather
                // than as several commands.
                _ => " ".repeat(icon_columns),
            };
            let name = if i == 0 { *name } else { "" };
            println!("  {lead}{name:<width$}  {line}");
        }
        println!();
    }

    println!("{sparkle}New here?  rproj new my-first-game  sets your machine up and");
    println!("    scaffolds your first project in one go.\n");
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The icon column is padded by a fixed number of terminal columns, on
    /// the assumption that every icon is one `char` and two columns wide.
    /// A variation-selector emoji (โš™๏ธ, ๐Ÿ› ๏ธ) is two `char`s and an
    /// unpredictable number of columns, and adding one silently bends the
    /// description column out of line on every continuation row.
    #[test]
    fn every_icon_is_a_single_char() {
        for (icon, name, _) in COMMANDS {
            assert_eq!(
                icon.chars().count(),
                1,
                "{name}'s icon {icon} is not a single char"
            );
        }
    }

    /// Every command clap accepts should be findable here - the welcome
    /// screen is the only place someone who typed `rproj` learns they exist.
    #[test]
    fn every_command_is_listed() {
        for command in [
            "new",
            "setup",
            "configure",
            "upgrade",
            "watch",
            "test",
            "copy",
            "info",
        ] {
            assert!(
                COMMANDS
                    .iter()
                    .any(|(_, name, _)| name.starts_with(&format!("rproj {command}"))),
                "`{command}` is missing from the welcome screen"
            );
        }
    }

    /// An empty description row would print a name with nothing after it.
    #[test]
    fn every_command_explains_itself() {
        for (_, name, lines) in COMMANDS {
            assert!(!lines.is_empty(), "{name} has no description");
            assert!(
                lines.iter().all(|l| !l.trim().is_empty()),
                "{name} has a blank line"
            );
        }
    }
}