rproj 0.20.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
pub(super) mod model;
pub(super) mod render;
#[cfg(test)]
mod tests;

use anyhow::{Context, Result, bail};
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};

use super::new;
use crate::{
    config::{GlobalConfig, Setups, project_file},
    graph::ProjectGraph,
    tui,
};
use model::{Draft, Effect};

pub(super) use model::validate_name;

pub struct Prepared {
    draft: Draft,
    root: std::path::PathBuf,
    template: Option<serde_json::Value>,
    config: GlobalConfig,
}

pub struct ProjectEdit {
    pub source: String,
    pub original: ProjectGraph,
    pub updated: ProjectGraph,
}

impl Prepared {
    pub fn project_dir(&self) -> std::path::PathBuf {
        self.root.join(&self.draft.name)
    }
}

pub fn prepare(terminal: &mut tui::TerminalSession, name: &str) -> Result<Option<Prepared>> {
    terminal.draw(|frame| {
        frame.render_widget(
            ratatui::widgets::Paragraph::new(
                "Preparing New Project...\nValidating the project template.",
            ),
            frame.area(),
        )
    })?;
    model::validate_name(name).map_err(anyhow::Error::msg)?;
    let config = GlobalConfig::load()?;
    if !config.machine_configured() {
        bail!("Run Machine Setup from the hub, or `rproj setup`, before creating a project here.");
    }
    let template =
        std::thread::scope(|scope| -> Result<Option<Option<serde_json::Value>>> {
            let (sender, receiver) = std::sync::mpsc::channel();
            let config = &config;
            scope.spawn(move || {
                let _ = sender.send(new::prepare_project(config, name));
            });
            let mut cancelled = false;
            loop {
                if let Some(Event::Key(key)) =
                    terminal.poll_event(std::time::Duration::from_millis(50))?
                    && key.kind != KeyEventKind::Release
                    && (key.code == KeyCode::Esc
                        || (key.modifiers.contains(KeyModifiers::CONTROL)
                            && key.code == KeyCode::Char('c')))
                {
                    cancelled = true;
                    crate::interrupt::request();
                }
                terminal.draw(|frame| {
                    frame.render_widget(
                ratatui::widgets::Paragraph::new(if cancelled {
                    "Cancelling preparation... Waiting for the active validation process."
                } else {
                    "Preparing New Project...\nValidating the project template.\nEsc cancels."
                }).wrap(ratatui::widgets::Wrap { trim: false }), frame.area())
                })?;
                match receiver.try_recv() {
                    Ok(_) if cancelled => return Ok(None),
                    Ok(result) => return result.map(|(_, template)| Some(template)),
                    Err(std::sync::mpsc::TryRecvError::Empty) => {}
                    Err(std::sync::mpsc::TryRecvError::Disconnected) => {
                        bail!("Project preparation stopped unexpectedly")
                    }
                }
            }
        })?;
    let Some(template) = template else {
        crate::diagnostics::event("creation.cancel", "preparation cancelled; nothing created");
        return Ok(None);
    };
    let root = config.projects_root()?;
    let mut draft = Draft::new(
        name,
        Setups::list(),
        config.selected_system_apps.clone(),
        config.selected_vscode_extensions.clone(),
    );
    let create = {
        loop {
            let destination = root.join(&draft.name).display().to_string();
            let effect = next_effect(terminal, &mut draft, &destination)?;
            match effect {
                Effect::None => {}
                Effect::Save => anyhow::bail!("Setup save is unavailable in project creation"),
                Effect::Cancel => break false,
                Effect::LoadSetup(name) => match new::read_setup(&name) {
                    Ok((graph, warnings)) => draft.loaded(&name, graph, warnings),
                    Err(error) => {
                        crate::diagnostics::event(
                            "creation.validation",
                            error
                                .to_string()
                                .lines()
                                .next()
                                .unwrap_or("Saved setup could not be loaded"),
                        );
                        draft.status = format!("{error:#}");
                    }
                },
                Effect::Create => {
                    if root.join(&draft.name).exists() {
                        crate::diagnostics::event(
                            "creation.validation",
                            "destination already exists",
                        );
                        draft.status =
                            "Destination already exists. Change the project name.".into();
                    } else if let Some(name) = draft
                        .save_setup
                        .as_deref()
                        .filter(|name| Setups::load(name).map_or(true, |setup| setup.is_some()))
                    {
                        crate::diagnostics::event(
                            "creation.validation",
                            "setup destination exists or is unreadable",
                        );
                        draft.status = format!(
                            "Setup `{name}` now exists or cannot be read. Choose another setup name."
                        );
                    } else {
                        break true;
                    }
                }
            }
        }
    };
    if !create {
        crate::diagnostics::event("creation.cancel", "nothing created");
        return Ok(None);
    }
    crate::diagnostics::event(
        "creation.confirmed",
        format!(
            "workflow={:?}; capabilities={:?}; packages={:?}; dropped={:?}",
            draft.graph.package_workflow,
            draft.graph.capabilities,
            draft.graph.packages,
            draft.graph.dropped
        ),
    );
    Ok(Some(Prepared {
        draft,
        root,
        template,
        config,
    }))
}

pub fn edit_project_in(
    terminal: &mut tui::TerminalSession,
    project_dir: &std::path::Path,
) -> Result<Option<ProjectEdit>> {
    let record_path = project_file::path_in(project_dir);
    if !record_path.is_file() {
        anyhow::bail!(
            "no rproj.toml in {}; project composition editing needs a recorded project",
            project_dir.display()
        );
    }
    let source = std::fs::read_to_string(&record_path)
        .with_context(|| format!("failed to read {}", record_path.display()))?;
    let graph = project_file::load_from(project_dir)?.ok_or_else(|| {
        anyhow::anyhow!(
            "no rproj.toml in {}; project composition editing needs a recorded project",
            project_dir.display()
        )
    })?;
    let config = GlobalConfig::load().ok();
    let name = project_dir
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("project");
    let original = graph.clone();
    let mut draft = Draft::edit_project(
        name,
        graph,
        config
            .as_ref()
            .map(|config| config.selected_system_apps.clone())
            .unwrap_or_default(),
        config
            .as_ref()
            .map(|config| config.selected_vscode_extensions.clone())
            .unwrap_or_default(),
    );
    loop {
        let effect = next_effect(terminal, &mut draft, &project_dir.display().to_string())?;
        match effect {
            Effect::None => {}
            Effect::Save => {
                return Ok(Some(ProjectEdit {
                    source,
                    original,
                    updated: draft.graph,
                }));
            }
            Effect::Cancel => return Ok(None),
            Effect::Create | Effect::LoadSetup(_) => {
                draft.status = "That action is unavailable while editing a project.".into()
            }
        }
    }
}

fn next_effect(
    terminal: &mut tui::TerminalSession,
    draft: &mut Draft,
    destination: &str,
) -> Result<Effect> {
    let mut small = false;
    terminal.draw(|frame| {
        small = tui::is_too_small(frame.area());
        render::draw(frame, draft, destination);
    })?;
    Ok(match terminal.read_event()? {
        Event::Key(key) if key.kind != KeyEventKind::Release => {
            if small
                && !matches!(key.code, KeyCode::Esc | KeyCode::Char('?'))
                && !(key.modifiers.contains(KeyModifiers::CONTROL)
                    && key.code == KeyCode::Char('c'))
            {
                Effect::None
            } else {
                draft.key(key)
            }
        }
        Event::Paste(text) if !small => {
            draft.paste(&text);
            Effect::None
        }
        Event::Mouse(mouse) if !small => {
            match mouse.kind {
                crossterm::event::MouseEventKind::ScrollUp => {
                    draft.scroll = draft.scroll.saturating_sub(3)
                }
                crossterm::event::MouseEventKind::ScrollDown => {
                    draft.scroll = draft.scroll.saturating_add(3)
                }
                _ => {}
            }
            Effect::None
        }
        _ => Effect::None,
    })
}

impl Prepared {
    pub fn execute(self) -> Result<()> {
        let Self {
            draft,
            root,
            template,
            mut config,
        } = self;
        crate::interrupt::check()?;
        let planned = draft.graph.plan(&draft.apps, &draft.extensions);
        new::execute_confirmed(
            &draft.name,
            &root.join(&draft.name),
            &draft.graph,
            &planned,
            template.as_ref(),
            &mut config,
            draft.save_setup.as_deref().map(new::SetupSave::New),
        )
    }
}