glacier-cli 0.6.3

CLI do glacier-ui: cria um projeto pronto (templates .gvb, .gss e scripts Luau) e instala as extensões de VS Code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
//! Materializa um preset no disco: cria o diretório do projeto e escreve nele
//! os arquivos embutidos, com os marcadores substituídos.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};

use crate::embedded;

/// Um preset do questionário: o conjunto de arquivos sob `templates/<id>/`.
pub struct Preset {
    pub id: &'static str,
    pub titulo: &'static str,
    pub descricao: &'static str,
    /// O que o preset demonstra, mostrado no resumo antes de executar.
    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.gvb 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.gvb — 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",
        ],
    },
    Preset {
        id: "wasm32",
        titulo: "Web (WebAssembly)",
        descricao: "O mesmo app no desktop e no navegador: componente em Rust, views/ embutida no .wasm e `glacier serve wasm` para compilar e servir.",
        destaques: &[
            "src/main.rs — embed_assets! só no alvo wasm32; no desktop, disco + hot-reload",
            "src/contador.rs — impl Component (no navegador não há <script> Luau)",
            "web/index.html — a página que carrega o app.js gerado pelo wasm-bindgen",
            "Dockerfile multi-stage: a imagem de produção leva só a página, o .js e o .wasm",
            "glacier serve wasm / glacier serve desktop",
        ],
    },
    Preset {
        id: "android",
        titulo: "Android (experimental)",
        descricao: "O app mínimo rodando no desktop e no celular, com Makefile e fazer.bat que preparam a máquina (Java, SDK/NDK, cargo-apk) e geram o APK.",
        destaques: &[
            "Makefile (Linux) e fazer.bat (Windows): setup, doctor, build, release, install, launch, logcat, emulador",
            "src/lib.rs — run() + glacier_ui::android_main!(run): a inicialização do Android fica no motor; views/ embutida no .so",
            "[patch] para o fork antoniofernandodj/iced_winit — o iced_winit 0.14 com o suporte a Android que o winit exige",
            "ANDROID_TODO.md — o que do glacier ainda não funciona lá (open_window, rfd, IME, storage…)",
        ],
    },
    Preset {
        id: "catalogo",
        titulo: "Catálogo de widgets",
        descricao: "Uma sidebar de categorias; cada tela mostra dezenas de widgets do motor com um exemplo mínimo e vivo, feito para copiar.",
        destaques: &[
            "views/categorias/ — uma tela por seção da tabela de widgets (§2 do PLANO)",
            "views/app.gvb — sidebar + roteador por {view}, sem escada de <se>",
            "views/scripts/app.luau — init() semeia toda chave que os demos leem",
        ],
    },
    Preset {
        id: "dashboard",
        titulo: "Painel de dados",
        descricao: "KPIs e gráficos que andam sozinhos: linechart de série múltipla, barchart, piechart, gauge e sparkline, alimentados por every(1000).",
        destaques: &[
            "views/app.gvb — cards de KPI + <linechart series>/<barchart>/<gauge>",
            "views/scripts/app.luau — every(1000, …) empurra as séries sem os widgets saberem do relógio",
        ],
    },
    Preset {
        id: "formulario",
        titulo: "Formulário validado",
        descricao: "App de cadastro: textinput/maskedinput/spinbox/dateedit/select/checkbox num <form> com regras declaradas (rules=\"…\"); o motor valida ao enviar.",
        destaques: &[
            "<form on_submit/on_validation_error> com rules=\"required|minlen:3|digits:11|…\" nos campos",
            "pseudo-estado :invalid no .gss; <button type=\"submit\">/\"reset\"",
            "validar.luau reduzido a salvar()/apontar()/limpar() — sem revalidar() na mão",
        ],
    },
    Preset {
        id: "crud",
        titulo: "Lista editável (model/view)",
        descricao: "Uma <tableview> ligada a um array do contexto, com Novo/Editar/Excluir via prompt{} e confirm{} da camada Luau. Dados em memória.",
        destaques: &[
            "views/scripts/state.luau — o depósito tipado; handlers/itens.luau — a vitrine",
            "prompt{}/confirm{} suspensivos; ordenação e seleção no <tableview>",
        ],
    },
];

pub fn preset(id: &str) -> Option<&'static Preset> {
    PRESETS.iter().find(|p| p.id == id)
}

/// Extensões cujo conteúdo passa pela substituição de marcadores. O resto
/// (ícones, fontes) é copiado byte a byte — um `replace` num PNG o corromperia.
const TEXTUAIS: &[&str] = &[
    "gvb", "gva", "gv", "gss", "luau", "rs", "toml", "json", "md", "luaurc", "txt", "bat", "sh",
    "html", "conf",
];

/// Arquivos textuais que não têm extensão nenhuma. Sem esta lista o `Makefile`
/// sairia com os `{{nome_projeto}}` literais no lugar do nome do app — e o
/// `Dockerfile` do preset web, com um `docker build` que não acha o `.wasm`.
const TEXTUAIS_SEM_EXTENSAO: &[&str] = &["Makefile", "Dockerfile", "gitignore"];

/// Escreve o preset em `destino`. O diretório não pode existir: sobrescrever um
/// projeto já começado seria a única operação irreversível desta CLI.
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 nome_arquivo = alvo.file_name().and_then(|n| n.to_str()).unwrap_or("");
        let ehtexto = alvo
            .extension()
            .and_then(|e| e.to_str())
            .is_some_and(|e| TEXTUAIS.contains(&e))
            || TEXTUAIS_SEM_EXTENSAO.contains(&nome_arquivo)
            || rel.ends_with(".gitignore")
            || rel.ends_with(".dockerignore")
            || 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)?;
        }
        executavel_se_script(&alvo)?;
        escritos.push(PathBuf::from(rel));
    }
    escritos.sort();
    Ok(escritos)
}

/// Dá o bit de execução a um `.sh` recém-escrito.
///
/// `fs::write` cria com a permissão do `umask` — tipicamente `644`. Um
/// `instalar.sh` sem o `+x` é um instalador que o usuário precisa descobrir que
/// tem de chamar com `sh instalar.sh`, e o `.tar.gz` gerado pelo Makefile
/// propagaria a permissão errada para quem baixasse o pacote.
///
/// No Windows não há o conceito, e a função não faz nada.
#[cfg_attr(not(unix), allow(unused_variables))]
fn executavel_se_script(alvo: &Path) -> io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if alvo.extension().and_then(|e| e.to_str()) == Some("sh") {
            fs::set_permissions(alvo, fs::Permissions::from_mode(0o755))?;
        }
    }
    Ok(())
}

/// Desfaz os dois disfarces que um arquivo veste dentro de `templates/`, ambos
/// para não ser confundido com um arquivo de verdade do repositório do glacier:
///
/// - `gitignore` → `.gitignore`: com o ponto, ele passaria a valer para o
///   próprio repositório, escondendo os arquivos do preset de quem editasse os
///   templates.
/// - `Cargo.toml.template` → `Cargo.toml`: `cargo package` PULA todo
///   subdiretório que tenha um `Cargo.toml` (ele o lê como outro pacote), então
///   com o nome real os presets ficariam de fora do `.crate` publicado — e a
///   CLI instalada do crates.io não teria projeto nenhum para criar. O sufixo
///   também impede que um `cd` para dentro de `templates/` esbarre num pacote
///   que não é membro deste workspace.
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(),
    }
}

/// Marcadores em chave dupla — `{{nome}}` — porque a chave simples já é a
/// sintaxe de data binding dos `.gvb` (`{contador}`), e um marcador em chave
/// simples seria indistinguível de um binding de verdade.
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)
}

/// Nome do projeto → identificador Rust (`meu-app` → `meu_app`), usado onde o
/// hífen não é aceito: `application_id`, nome de módulo, chave de storage.
pub fn nome_crate(nome: &str) -> String {
    nome.chars()
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
        .collect()
}

/// Nome do projeto → título de janela (`meu-app` → `Meu App`).
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(" ")
}

/// Valida o nome antes de criar qualquer coisa: o `name` de um `Cargo.toml`
/// aceita letras, dígitos, `-` e `_`, e não pode começar com dígito.
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.gvb"), "views/app.gvb");
    }

    /// Todo preset anunciado no questionário precisa existir de fato — o build
    /// embute `templates/<id>/`, e um `id` sem diretório só falharia na hora de
    /// criar o projeto, depois do usuário responder tudo.
    #[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
            );
        }
    }

    /// O preset web entrega a imagem junto: sem um dos três arquivos, o
    /// `docker build` do projeto criado para na primeira linha que os usa.
    #[test]
    fn preset_web_leva_a_imagem_docker() {
        let arquivos = embedded::arquivos_do_preset("wasm32");
        for esperado in ["Dockerfile", ".dockerignore", "docker/nginx.conf"] {
            assert!(
                arquivos.iter().any(|(c, _)| renomear(c) == esperado),
                "preset 'wasm32' sem '{esperado}'"
            );
        }
    }

    /// E o `Dockerfile` precisa sair com os marcadores substituídos. Ele não
    /// tem extensão: fora de `TEXTUAIS_SEM_EXTENSAO` ele seria copiado byte a
    /// byte, com o `{{nome_projeto}}` literal no caminho do `.wasm` — um
    /// `docker build` que só falha na máquina de quem criou o projeto.
    #[test]
    fn o_dockerfile_criado_nao_tem_marcador_sobrando() {
        let destino = std::env::temp_dir().join(format!(
            "glacier-scaffold-docker-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        let _ = fs::remove_dir_all(&destino);

        criar(&destino, "wasm32", "meu-app", "0.108").expect("criar o preset web");

        let dockerfile = fs::read_to_string(destino.join("Dockerfile")).expect("ler o Dockerfile");
        assert!(
            !dockerfile.contains("{{"),
            "marcador não substituído no Dockerfile:\n{dockerfile}"
        );
        assert!(
            dockerfile.contains("release/meu-app.wasm"),
            "o Dockerfile não aponta para o .wasm do projeto:\n{dockerfile}"
        );
        assert!(destino.join("docker/nginx.conf").is_file());
        assert!(destino.join(".dockerignore").is_file());

        let _ = fs::remove_dir_all(&destino);
    }

    /// O preset Android só serve se o `[patch]` do `iced_winit` e as duas casas do
    /// build (Makefile e fazer.bat) forem junto: sem o patch o APK abre e cai
    /// no `EventLoop`; sem o `fazer.bat` o Windows fica sem caminho.
    #[test]
    fn preset_android_leva_patch_makefile_e_bat_sem_marcador_sobrando() {
        let destino = std::env::temp_dir().join(format!(
            "glacier-scaffold-android-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        let _ = fs::remove_dir_all(&destino);

        criar(&destino, "android", "meu-app", "0.112").expect("criar o preset android");

        for esperado in [
            "Makefile",
            "fazer.bat",
            "ANDROID_TODO.md",
            "src/lib.rs",
            "src/main.rs",
            "views/app.gvb",
        ] {
            assert!(destino.join(esperado).is_file(), "faltou '{esperado}'");
        }

        for texto in [
            "Makefile",
            "fazer.bat",
            "Cargo.toml",
            "src/lib.rs",
            "src/main.rs",
            "views/app.gvb",
        ] {
            let conteudo = fs::read_to_string(destino.join(texto)).expect("ler");
            assert!(
                !conteudo.contains("{{"),
                "marcador não substituído em {texto}"
            );
        }

        let cargo = fs::read_to_string(destino.join("Cargo.toml")).expect("ler Cargo.toml");
        assert!(
            cargo.contains("name = \"meu_app\""),
            "lib sem o nome de crate"
        );
        assert!(cargo.contains("package = \"com.example.meu_app\""));
        assert!(
            cargo.contains("features = [\"android\"]"),
            "falta a feature android do motor"
        );
        assert!(
            cargo.contains(
                "iced_winit = { git = \"https://github.com/antoniofernandodj/iced_winit\""
            ),
            "o [patch] do iced_winit de Android sumiu"
        );

        // O .gitignore do preset sobrepõe o comum e esconde a chave de teste.
        let ignore = fs::read_to_string(destino.join(".gitignore")).expect("ler .gitignore");
        assert!(ignore.contains("release.keystore"));

        let _ = fs::remove_dir_all(&destino);
    }

    /// E todo preset precisa ser um projeto Cargo de verdade.
    #[test]
    fn todo_preset_tem_cargo_toml_e_main() {
        for p in PRESETS {
            let arquivos = embedded::arquivos_do_preset(p.id);
            // Pelo nome de DEPOIS do `renomear`: é o que o projeto criado tem.
            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);
        }
    }
}