crepuscularity-cli 0.7.17

crepus CLI — scaffolding and builds for Crepuscularity (UNSTABLE; in active development).
use std::collections::BTreeSet;
use std::path::PathBuf;

use console::style;
use crepuscularity_core::context::{TemplateContext, TemplateValue};
use crepuscularity_lvgl::{
    render_component_file_to_lvgl_xml, render_template_to_lvgl_xml_with_options, LvglOptions,
    LvglRoot,
};
use crepuscularity_native::{
    render_component_file_to_ir, render_template_to_ir, to_json, to_json_pretty,
};

use crate::crepus_toml::{pick_targets, ResolvedTarget};
use crate::ui;

pub(crate) fn has_manifest_targets(manifest: Option<PathBuf>) -> bool {
    matches!(
        crate::crepus_toml::load_manifest_targets(manifest),
        Ok(Some(targets)) if !targets.is_empty()
    )
}

pub(crate) fn run(args: &[String]) {
    let parsed = parse_args(args);
    let targets = crate::crepus_toml::load_manifest_targets(parsed.manifest)
        .unwrap_or_else(|e| ui::error(&e))
        .unwrap_or_else(|| ui::error("no crepus.toml found"));
    if targets.is_empty() {
        ui::error("crepus.toml has no [[targets]] entries");
    }
    let picked = if let Some(selector) = parsed.selector.as_deref() {
        pick_targets_by_selector(&targets, selector).unwrap_or_else(|e| ui::error(&e))
    } else {
        pick_targets(&targets, parsed.target_id.as_deref()).unwrap_or_else(|e| ui::error(&e))
    };
    for target in picked {
        build_target(&target);
    }
}

struct BuildArgs {
    target_id: Option<String>,
    selector: Option<String>,
    manifest: Option<PathBuf>,
}

fn parse_args(args: &[String]) -> BuildArgs {
    let mut target_id = None;
    let mut selector = None;
    let mut manifest = None;
    let mut all = false;
    let mut i = 0;
    while i < args.len() {
        match args[i].as_str() {
            "--target" | "-t" => {
                i += 1;
                target_id = args.get(i).cloned();
                if target_id.is_none() {
                    ui::error("--target expects an id");
                }
            }
            "--manifest" => {
                i += 1;
                manifest = args.get(i).map(PathBuf::from);
                if manifest.is_none() {
                    ui::error("--manifest expects a path");
                }
            }
            "--all" => all = true,
            "--release" => {}
            other if other.starts_with('-') => ui::error(&format!("unknown option: {other}")),
            other => {
                if selector.replace(other.to_string()).is_some() {
                    ui::error("crepus build accepts only one target selector");
                }
            }
        }
        i += 1;
    }
    if target_id.is_some() && selector.is_some() {
        ui::error("use either --target ID or a positional selector, not both");
    }
    if all && (target_id.is_some() || selector.is_some()) {
        ui::error("use either --all or a target selector, not both");
    }
    BuildArgs {
        target_id,
        selector,
        manifest,
    }
}

fn pick_targets_by_selector(
    targets: &[ResolvedTarget],
    selector: &str,
) -> Result<Vec<ResolvedTarget>, String> {
    if let Ok(picked) = pick_targets(targets, Some(selector)) {
        return Ok(picked);
    }
    let normalized = normalize_selector(selector);
    let picked: Vec<ResolvedTarget> = targets
        .iter()
        .filter(|target| normalize_selector(&target.target_type) == normalized)
        .cloned()
        .collect();
    if !picked.is_empty() {
        return Ok(picked);
    }
    let ids: Vec<&str> = targets.iter().map(|t| t.id.as_str()).collect();
    let types: BTreeSet<&str> = targets.iter().map(|t| t.target_type.as_str()).collect();
    Err(format!(
        "no target id or type {selector:?} (ids: {ids:?}; types: {types:?})"
    ))
}

fn normalize_selector(selector: &str) -> &str {
    match selector {
        "extension" | "browser-extension" | "web-extension" => "webext",
        "ir" => "native",
        other => other,
    }
}

fn build_target(target: &ResolvedTarget) {
    match target.target_type.as_str() {
        "web" => build_web(target),
        "webext" => {
            if let Some(manifest) = &target.webext {
                crate::webext::build_app_target(&target.dir, manifest);
            } else {
                crate::webext::build_app_path(&target.dir);
            }
        }
        "lvgl" => build_lvgl(target),
        "native" | "ir" => build_native_ir(target),
        "embedded" => build_embedded(target),
        other => ui::error(&format!(
            "target {:?} uses unsupported type {:?}",
            target.id, other
        )),
    }
}

fn build_web(target: &ResolvedTarget) {
    crate::web::build_site_wasm(&crate::web::WebBuildArgs {
        site_dir: Some(target.dir.clone()),
        out_dir: target.out.clone(),
        entry: target.entry.clone(),
        target_id: None,
        manifest: None,
        meta: Some(target.web.clone()),
    });
}

fn build_lvgl(target: &ResolvedTarget) {
    let template_path = resolve_template_path(target);
    let template = std::fs::read_to_string(&template_path)
        .unwrap_or_else(|e| ui::error(&format!("read {}: {e}", template_path.display())));
    let ctx = target_context(target, template_path.parent().map(PathBuf::from));
    let name = target.name.clone().unwrap_or_else(|| target.id.clone());
    let root = match target.root.as_deref().unwrap_or("component") {
        "screen" => LvglRoot::Screen,
        "component" => LvglRoot::Component,
        other => ui::error(&format!(
            "lvgl root must be component or screen, got {other:?}"
        )),
    };
    let xml = if let Some(component) = &target.component {
        render_component_file_to_lvgl_xml(&template, component, &ctx)
    } else {
        render_template_to_lvgl_xml_with_options(&template, &ctx, &LvglOptions { name, root })
    }
    .unwrap_or_else(|e| ui::error(&format!("render lvgl target {:?}: {e}", target.id)));
    let out = target
        .out
        .clone()
        .unwrap_or_else(|| target.dir.join("dist").join(format!("{}.xml", target.id)));
    write_output(&out, xml.as_bytes());
    eprintln!(
        "{} {} {}",
        ui::ok(),
        style(&target.id).cyan().bold(),
        style(out.display().to_string()).dim()
    );
}

fn build_native_ir(target: &ResolvedTarget) {
    let template_path = resolve_template_path(target);
    let template = std::fs::read_to_string(&template_path)
        .unwrap_or_else(|e| ui::error(&format!("read {}: {e}", template_path.display())));
    let ctx = target_context(target, template_path.parent().map(PathBuf::from));
    let ir = if let Some(component) = &target.component {
        render_component_file_to_ir(&template, component, &ctx)
    } else {
        render_template_to_ir(&template, &ctx)
    }
    .unwrap_or_else(|e| ui::error(&format!("render native target {:?}: {e}", target.id)));
    let json = if target.root.as_deref() == Some("pretty") {
        to_json_pretty(&ir)
    } else {
        to_json(&ir)
    }
    .unwrap_or_else(|e| ui::error(&format!("serialize native target {:?}: {e}", target.id)));
    let out = target
        .out
        .clone()
        .unwrap_or_else(|| target.dir.join("dist").join(format!("{}.json", target.id)));
    write_output(&out, json.as_bytes());
    eprintln!(
        "{} {} {}",
        ui::ok(),
        style(&target.id).cyan().bold(),
        style(out.display().to_string()).dim()
    );
}

fn build_embedded(target: &ResolvedTarget) {
    let width = target
        .width
        .unwrap_or_else(|| ui::error("embedded target needs width"));
    let height = target
        .height
        .unwrap_or_else(|| ui::error("embedded target needs height"));
    let template_path = resolve_template_path(target);
    let template = std::fs::read_to_string(&template_path)
        .unwrap_or_else(|e| ui::error(&format!("read {}: {e}", template_path.display())));
    let ctx = target_context(target, template_path.parent().map(PathBuf::from));
    let mut ui_view = crepuscularity_embedded::Ui::new(width, height, &template);
    if let Some(component) = &target.component {
        ui_view.set_component(component.clone());
    }
    for (key, value) in ctx.vars {
        ui_view.set(key, value);
    }
    let screen = ui_view.screen();
    let mut ppm =
        crepuscularity_embedded::Rgb888Buffer::new(screen, crepuscularity_embedded::DEFAULT_BG);
    ui_view
        .render_into(&mut ppm)
        .unwrap_or_else(|e| ui::error(&format!("render embedded target {:?}: {e}", target.id)));
    let out = target
        .out
        .clone()
        .unwrap_or_else(|| target.dir.join("dist").join(format!("{}.ppm", target.id)));
    if let Some(parent) = out.parent() {
        std::fs::create_dir_all(parent)
            .unwrap_or_else(|e| ui::error(&format!("create {}: {e}", parent.display())));
    }
    crepuscularity_embedded::write_ppm(&out, &ppm)
        .unwrap_or_else(|e| ui::error(&format!("write {}: {e}", out.display())));
    eprintln!(
        "{} {} {}",
        ui::ok(),
        style(&target.id).cyan().bold(),
        style(out.display().to_string()).dim()
    );
}

fn resolve_template_path(target: &ResolvedTarget) -> PathBuf {
    target
        .template
        .clone()
        .or_else(|| target.entry.as_ref().map(|entry| target.dir.join(entry)))
        .unwrap_or_else(|| target.dir.join("ui.crepus"))
}

fn target_context(target: &ResolvedTarget, base_dir: Option<PathBuf>) -> TemplateContext {
    let mut ctx = TemplateContext::new();
    ctx.base_dir = base_dir;
    if let Some(path) = &target.ctx {
        load_ctx_file(path, &mut ctx);
    }
    for (key, value) in &target.vars {
        ctx.set(key, toml_to_template_value(value));
    }
    ctx
}

fn load_ctx_file(path: &PathBuf, ctx: &mut TemplateContext) {
    let raw = std::fs::read_to_string(path)
        .unwrap_or_else(|e| ui::error(&format!("read {}: {e}", path.display())));
    let table = raw
        .parse::<toml::Table>()
        .unwrap_or_else(|e| ui::error(&format!("parse {}: {e}", path.display())));
    for (key, value) in table {
        ctx.set(key, toml_to_template_value(&value));
    }
}

fn toml_to_template_value(value: &toml::Value) -> TemplateValue {
    match value {
        toml::Value::String(s) => TemplateValue::Str(s.clone()),
        toml::Value::Integer(n) => TemplateValue::Int(*n),
        toml::Value::Float(n) => TemplateValue::Float(*n),
        toml::Value::Boolean(b) => TemplateValue::Bool(*b),
        toml::Value::Array(items) => TemplateValue::List(
            items
                .iter()
                .map(|item| {
                    let mut row = TemplateContext::new();
                    row.set("value", toml_to_template_value(item));
                    row
                })
                .collect(),
        ),
        toml::Value::Table(table) => {
            let mut ctx = TemplateContext::new();
            for (key, value) in table {
                ctx.set(key, toml_to_template_value(value));
            }
            TemplateValue::Scope(ctx)
        }
        toml::Value::Datetime(dt) => TemplateValue::Str(dt.to_string()),
    }
}

fn write_output(path: &PathBuf, bytes: &[u8]) {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .unwrap_or_else(|e| ui::error(&format!("create {}: {e}", parent.display())));
    }
    std::fs::write(path, bytes)
        .unwrap_or_else(|e| ui::error(&format!("write {}: {e}", path.display())));
}