crepuscularity-cli 0.9.14

crepus CLI — scaffolding and builds for Crepuscularity (UNSTABLE; in active development).
//! `crepus tui` — Terminal User Interface apps with Ratatui.
//!
//! Scaffold and build TUI applications that render `.crepus` templates in the terminal
//! using Ratatui for layout and Crossterm for input handling.

use std::fs;
use std::path::Path;
#[cfg(feature = "tui")]
use std::path::PathBuf;
use std::process::Command;

use console::style;

use crate::build_options::BuildOptions;
use crate::cli::TuiCommands;
use crate::ui;

/// Caret-style version requirement injected into scaffolded TUI apps for
/// `crepuscularity-tui`. Keep this aligned with the published `crepuscularity-tui`
/// minor; pre-1.0 caret on `0.x.y` resolves to `>=0.x.y, <0.(x+1).0`.
const SCAFFOLD_TUI_VERSION: &str = "0.4";

pub fn execute(cmd: TuiCommands) {
    match cmd {
        TuiCommands::New { name } => scaffold_tui_app(&name),
        TuiCommands::Build { build, extra } => {
            build_tui_app(build.into_options_or_exit(), &extra);
        }
        TuiCommands::Run { build, extra } => {
            run_tui_app(build.into_options_or_exit(), &extra);
        }
        TuiCommands::Preview { file: path } => {
            #[cfg(feature = "tui")]
            preview_tui_template(&path);
            #[cfg(not(feature = "tui"))]
            ui::error("TUI preview not compiled in. Rebuild crepus with --features tui.");
        }
    }
}

fn scaffold_tui_app(name: &str) {
    let dir = Path::new(name);

    // Create directory structure
    fs::create_dir_all(dir).unwrap_or_else(|e| {
        ui::error(&format!("failed to create directory: {}", e));
    });

    let cargo_toml = format!(
        r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2021"

[dependencies]
# Bump these as needed; the scaffold is pinned to the version of `crepus` you
# scaffolded with.
crepuscularity-tui = "{tui_version}"
ratatui = {{ version = "0.29", default-features = false, features = ["crossterm"] }}
crossterm = "0.28"
anyhow = "1.0"
"#,
        name = name,
        tui_version = SCAFFOLD_TUI_VERSION,
    );

    let main_rs = r###"//! Generated by `crepus tui new`. Edit `app.crepus` and run `crepus tui run`
//! (or `cargo run`). The template hot-reloads on save.

use std::time::Duration;

use crepuscularity_tui::{HotTemplate, ReloadOutcome};
use crossterm::event::{Event, KeyCode};
use ratatui::prelude::*;

fn main() -> anyhow::Result<()> {
    let mut hot = HotTemplate::watch("app.crepus").map_err(|e| anyhow::anyhow!(e))?;
    hot.template_mut()
        .set("title", "My TUI App")
        .set("message", "Hello from Crepuscularity!")
        .set("quit_hint", "Press 'q' to quit");

    let mut terminal = ratatui::init();
    let result = run(&mut terminal, &mut hot);
    ratatui::restore();
    result
}

fn run(terminal: &mut Terminal<impl Backend>, hot: &mut HotTemplate) -> anyhow::Result<()> {
    loop {
        terminal.draw(|frame| {
            let _ = hot.poll_and_draw_full(frame);
        })?;

        if crossterm::event::poll(Duration::from_millis(100))? {
            if let Event::Key(key) = crossterm::event::read()? {
                if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
                    return Ok(());
                }
            }
        }

        // Avoid an unused-variant warning if a future ReloadOutcome::Error is added.
        let _ = ReloadOutcome::Unchanged;
    }
}
"###;
    fs::write(dir.join("Cargo.toml"), cargo_toml).unwrap_or_else(|e| {
        ui::error(&format!("failed to write Cargo.toml: {}", e));
    });

    // Create src directory
    fs::create_dir_all(dir.join("src")).unwrap_or_else(|e| {
        ui::error(&format!("failed to create src directory: {}", e));
    });

    // Write src/main.rs
    fs::write(dir.join("src/main.rs"), main_rs).unwrap_or_else(|e| {
        ui::error(&format!("failed to write src/main.rs: {}", e));
    });

    // Create a basic .crepus template file
    let template_content = r#"div bg-black text-white flex flex-col gap-4
  div text-2xl font-bold text-green-400
    "Welcome to {title}"
  div text-lg
    "{message}"
  div text-sm text-gray-500
    "{quit_hint}"
"#;

    fs::write(dir.join("app.crepus"), template_content).unwrap_or_else(|e| {
        ui::error(&format!("failed to write app.crepus: {}", e));
    });

    ui::success(&format!(
        "Created new TUI app '{}' in directory '{}'",
        name,
        dir.display()
    ));
    eprintln!("Run 'cd {} && cargo run' to start the app", name);
}

fn build_tui_app(options: BuildOptions, extra: &[String]) {
    ensure_cargo_project_for_tui();
    let mut cmd = Command::new("cargo");
    cmd.arg("build");
    if options.release() {
        cmd.arg("--release");
    }
    cmd.args(extra);
    delegate_to_cargo(cmd, "build");
}

fn run_tui_app(options: BuildOptions, extra: &[String]) {
    ensure_cargo_project_for_tui();
    let mut cmd = Command::new("cargo");
    cmd.arg("run");
    if options.release() {
        cmd.arg("--release");
    }
    if !extra.is_empty() {
        cmd.arg("--").args(extra);
    }
    delegate_to_cargo(cmd, "run");
}

fn ensure_cargo_project_for_tui() {
    if !Path::new("Cargo.toml").exists() {
        ui::error(
            "no Cargo.toml in the current directory. Run `crepus tui build|run` from the \
             root of a TUI app scaffolded with `crepus tui new <name>`.",
        );
    }
}

fn delegate_to_cargo(mut cmd: Command, verb: &str) {
    match cmd.status() {
        Ok(status) if status.success() => {}
        Ok(status) => {
            std::process::exit(status.code().unwrap_or(1));
        }
        Err(e) => {
            ui::error(&format!(
                "failed to invoke `cargo {verb}`: {e}. Is the Rust toolchain installed and on PATH?"
            ));
        }
    }
}

/// Live-preview a `.crepus` template in the current terminal.
///
/// Sets up crossterm raw mode via `ratatui::init`, watches the file for
/// changes via [`crepuscularity_tui::HotTemplate`], and re-renders on each
/// save. `q` or `Esc` exits cleanly; `r` forces a reload. A `context.toml`
/// next to the template is loaded as initial variables.
#[cfg(feature = "tui")]
fn preview_tui_template(path: &Path) {
    use crepuscularity_tui::{HotTemplate, ReloadOutcome};
    use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
    use std::time::{Duration, Instant};

    if !path.exists() {
        ui::error(&format!("file not found: {}", path.display()));
    }

    let mut hot = match HotTemplate::watch(path) {
        Ok(h) => h,
        Err(e) => ui::error(&format!("could not load template: {e}")),
    };

    if let Some(dir) = path.parent() {
        let ctx_path = dir.join("context.toml");
        if ctx_path.exists() {
            load_tui_context_toml(&ctx_path, hot.template_mut().context_mut());
        }
    }

    eprintln!(
        "{} previewing {} (q/Esc to quit, r to force reload)",
        style("crepus tui").dim(),
        style(path.display().to_string()).cyan().bold()
    );

    let mut terminal = ratatui::init();
    let mut last_reload: Option<(Instant, ReloadOutcome)> = None;

    let result: Result<(), String> = loop {
        let outcome_holder = std::cell::Cell::new(ReloadOutcome::Unchanged);
        if let Err(e) = terminal.draw(|frame| match hot.poll_and_draw_full(frame) {
            Ok(o) => outcome_holder.set(o),
            Err(_) => outcome_holder.set(ReloadOutcome::Unchanged),
        }) {
            break Err(format!("terminal draw: {e}"));
        }
        match outcome_holder.into_inner() {
            ReloadOutcome::Unchanged => {}
            other => last_reload = Some((Instant::now(), other)),
        }

        if matches!(event::poll(Duration::from_millis(100)), Ok(true)) {
            match event::read() {
                Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match key.code {
                    KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
                    KeyCode::Char('r') => {
                        if let Err(e) = hot.template_mut().reload() {
                            last_reload = Some((Instant::now(), ReloadOutcome::Error(e)));
                        } else {
                            last_reload = Some((Instant::now(), ReloadOutcome::Reloaded));
                        }
                    }
                    _ => {}
                },
                Ok(_) => {}
                Err(e) => break Err(format!("event read: {e}")),
            }
        }

        if let Some((t, _)) = &last_reload {
            if t.elapsed() > Duration::from_secs(2) {
                last_reload = None;
            }
        }
    };
    ratatui::restore();

    if let Some((_, ReloadOutcome::Error(msg))) = last_reload {
        eprintln!("{} {}", style("").yellow(), msg);
    }
    if let Err(e) = result {
        ui::error(&e);
    }
}

/// Minimal TOML loader (key=value, strings/bools/ints/floats) shared with
/// `crepus preview`.  Lives here to avoid the `desktop` cfg-gate so non-GPUI
/// builds still get TUI preview support.
#[cfg(feature = "tui")]
fn load_tui_context_toml(path: &Path, ctx: &mut crepuscularity_tui::TemplateContext) {
    use crepuscularity_tui::TemplateValue;
    let Ok(content) = std::fs::read_to_string(path) else {
        return;
    };
    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some(eq) = line.find('=') {
            let key = line[..eq].trim();
            let val = line[eq + 1..].trim();
            if val == "true" {
                ctx.set(key, TemplateValue::Bool(true));
            } else if val == "false" {
                ctx.set(key, TemplateValue::Bool(false));
            } else if let Ok(n) = val.parse::<i64>() {
                ctx.set(key, TemplateValue::Int(n));
            } else if let Ok(f) = val.parse::<f64>() {
                ctx.set(key, TemplateValue::Float(f));
            } else {
                ctx.set(key, strip_one_outer_quote_pair(val).to_string());
            }
        }
    }
}

/// Strip exactly one matching pair of `"`/`'` quotes from the outside of `s`.
///
/// Unlike `str::trim_matches('"').trim_matches('\'')` this preserves repeated
/// quotes inside the value, e.g. `"\"\"hi\"\""` → `\"hi\"` rather than `hi`.
#[cfg(feature = "tui")]
fn strip_one_outer_quote_pair(s: &str) -> &str {
    let bytes = s.as_bytes();
    if bytes.len() >= 2 {
        let first = bytes[0];
        let last = bytes[bytes.len() - 1];
        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
            // Slice on byte indices that we just verified are `"` / `'` — both
            // ASCII, so we are always on a UTF-8 char boundary.
            return &s[1..s.len() - 1];
        }
    }
    s
}

#[cfg(all(test, feature = "tui"))]
mod tests {
    use super::strip_one_outer_quote_pair;

    #[test]
    fn strips_single_outer_double_quotes() {
        assert_eq!(strip_one_outer_quote_pair("\"hello\""), "hello");
    }

    #[test]
    fn strips_single_outer_single_quotes() {
        assert_eq!(strip_one_outer_quote_pair("'hi'"), "hi");
    }

    #[test]
    fn preserves_inner_quotes_when_repeated() {
        assert_eq!(
            strip_one_outer_quote_pair("\"\"keep\"\""),
            "\"keep\"",
            "only the outermost pair should be stripped"
        );
    }

    #[test]
    fn preserves_mismatched_quotes() {
        assert_eq!(strip_one_outer_quote_pair("\"hi'"), "\"hi'");
        assert_eq!(strip_one_outer_quote_pair("'hi\""), "'hi\"");
    }

    #[test]
    fn preserves_unquoted_value() {
        assert_eq!(strip_one_outer_quote_pair("plain"), "plain");
    }

    #[test]
    fn handles_short_inputs() {
        assert_eq!(strip_one_outer_quote_pair(""), "");
        assert_eq!(strip_one_outer_quote_pair("\""), "\"");
        assert_eq!(strip_one_outer_quote_pair("\"\""), "");
    }
}