use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::embedded;
pub struct Preset {
pub id: &'static str,
pub titulo: &'static str,
pub descricao: &'static str,
pub destaques: &'static [&'static str],
}
pub static PRESETS: &[Preset] = &[
Preset {
id: "completo",
titulo: "App completo",
descricao: "Janela sem decoração com titlebar própria, tema + .gss, componentes, navegação, fetch e toasts.",
destaques: &[
"views/app.gv com <screen>, <resources> e titlebar custom",
"views/components/ — componentes com <props>",
"views/scripts/handlers/ — comportamento em Luau, um módulo por domínio",
"views/styles/ — theme.json (var()) + app.gss com pseudo-estados e @media",
],
},
Preset {
id: "minimo",
titulo: "Mínimo",
descricao: "Uma tela, um .gss e um <script> Luau. O menor projeto que ainda mostra a ideia.",
destaques: &[
"views/contador.gv — uma <screen> com <script> Luau embutido",
"views/styles/app.gss — classes em vez de atributos inline",
],
},
Preset {
id: "janelas",
titulo: "Multi-janela + bandeja",
descricao: "GlacierDaemon com open_window, broadcast entre janelas, ícone de bandeja e instância única.",
destaques: &[
"open_window/broadcast/close_window entre janelas isoladas",
"feature `tray`: o app sobrevive ao fechar a última janela",
"single_instance + remember_window_geometry + storage_dir",
],
},
Preset {
id: "rust",
titulo: "Componente em Rust",
descricao: "O trait Component com estado tipado em Rust, em vez de comportamento em Luau.",
destaques: &[
"src/contador.rs — impl Component (name/template/init/update)",
"estado tipado em Rust, template só com markup",
],
},
];
pub fn preset(id: &str) -> Option<&'static Preset> {
PRESETS.iter().find(|p| p.id == id)
}
const TEXTUAIS: &[&str] = &[
"gv", "gss", "luau", "rs", "toml", "json", "md", "luaurc", "txt",
];
pub fn criar(
destino: &Path,
preset: &str,
nome: &str,
versao_motor: &str,
) -> io::Result<Vec<PathBuf>> {
if destino.exists() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} já existe", destino.display()),
));
}
let arquivos = embedded::arquivos_do_preset(preset);
if arquivos.is_empty() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("preset '{preset}' não tem arquivos embutidos"),
));
}
let mut escritos = Vec::new();
for (rel, bytes) in arquivos {
let rel = renomear(rel);
let alvo = destino.join(&rel);
if let Some(pai) = alvo.parent() {
fs::create_dir_all(pai)?;
}
let ehtexto = alvo
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| TEXTUAIS.contains(&e))
|| rel.ends_with(".gitignore")
|| rel.ends_with(".luaurc");
if ehtexto && let Ok(texto) = std::str::from_utf8(bytes) {
fs::write(&alvo, substituir(texto, nome, versao_motor))?;
} else {
fs::write(&alvo, bytes)?;
}
escritos.push(PathBuf::from(rel));
}
escritos.sort();
Ok(escritos)
}
fn renomear(rel: &str) -> String {
let rel = rel.strip_suffix(".template").unwrap_or(rel);
match rel.rsplit_once('/') {
Some((dir, "gitignore")) => format!("{dir}/.gitignore"),
None if rel == "gitignore" => ".gitignore".to_string(),
_ => rel.to_string(),
}
}
fn substituir(texto: &str, nome: &str, versao_motor: &str) -> String {
texto
.replace("{{nome_projeto}}", nome)
.replace("{{nome_crate}}", &nome_crate(nome))
.replace("{{titulo}}", &titulo(nome))
.replace("{{versao_motor}}", versao_motor)
}
pub fn nome_crate(nome: &str) -> String {
nome.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn titulo(nome: &str) -> String {
nome.split(['-', '_', ' '])
.filter(|p| !p.is_empty())
.map(|p| {
let mut c = p.chars();
match c.next() {
Some(primeira) => primeira.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn validar_nome(nome: &str) -> Result<(), String> {
if nome.is_empty() {
return Err("o nome não pode ser vazio".into());
}
if nome.starts_with(|c: char| c.is_ascii_digit()) {
return Err("o nome não pode começar com dígito (é o nome do crate)".into());
}
if let Some(c) = nome
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_')
{
return Err(format!("caractere inválido para um nome de crate: '{c}'"));
}
Ok(())
}
#[cfg(test)]
mod testes {
use super::*;
#[test]
fn titulo_capitaliza_e_troca_separadores() {
assert_eq!(titulo("meu-app"), "Meu App");
assert_eq!(titulo("painel_de_controle"), "Painel De Controle");
}
#[test]
fn nome_crate_troca_hifen_por_underscore() {
assert_eq!(nome_crate("meu-app"), "meu_app");
}
#[test]
fn nome_invalido_e_recusado() {
assert!(validar_nome("2fast").is_err());
assert!(validar_nome("meu app").is_err());
assert!(validar_nome("meu-app_2").is_ok());
}
#[test]
fn marcador_em_chave_dupla_nao_toca_binding_do_gv() {
let gv = r#"<text content="{contador}" /> <!-- {{nome_projeto}} -->"#;
let saida = substituir(gv, "meu-app", "0.61");
assert!(saida.contains("{contador}"));
assert!(saida.contains("meu-app"));
}
#[test]
fn os_disfarces_do_template_sao_desfeitos_ao_criar() {
assert_eq!(renomear("gitignore"), ".gitignore");
assert_eq!(renomear("sub/gitignore"), "sub/.gitignore");
assert_eq!(renomear("Cargo.toml.template"), "Cargo.toml");
assert_eq!(renomear("views/app.gv"), "views/app.gv");
}
#[test]
fn todo_preset_tem_arquivos_embutidos() {
for p in PRESETS {
assert!(
!embedded::arquivos_do_preset(p.id).is_empty(),
"preset '{}' não tem templates/{}/",
p.id,
p.id
);
}
}
#[test]
fn todo_preset_tem_cargo_toml_e_main() {
for p in PRESETS {
let arquivos = embedded::arquivos_do_preset(p.id);
let tem = |alvo: &str| arquivos.iter().any(|(c, _)| renomear(c) == alvo);
assert!(tem("Cargo.toml"), "preset '{}' sem Cargo.toml", p.id);
assert!(tem("src/main.rs"), "preset '{}' sem src/main.rs", p.id);
}
}
}