creation du CLI update project

EBO <eric.bouhana@softalys.com> committé le 2026-03-20 16:06

6b44246c5f163691924bd7908d468dfc73125f52

1 parent(s)

9 fichiers modifiés +923 -57
M Cargo.lock
+1 -0
@@ -3559,6 +3559,7 @@ dependencies = [
3559 3559 name = "rustwarden-cli"
3560 3560 version = "0.1.0"
3561 3561 dependencies = [
3562 + "chrono",
3562 3563 "clap",
3563 3564 "console",
3564 3565 "dialoguer",
M README.md
+20 -1
@@ -158,7 +158,26 @@ RustwardenApp::builder()
158 158 .build().await?
159 159 .run().await
160 160 ```
161 -
161 +### Procédure manuelle actuelle
162 +
163 + 1. Remplacer les crates du framework dans le projet consommateur :
164 + # Depuis le dossier du projet consommateur
165 + rm -rf crates/rustwarden-core crates/rustwarden-ui
166 + cp -r /chemin/vers/Rustwarden/crates/rustwarden-core crates/
167 + cp -r /chemin/vers/Rustwarden/crates/rustwarden-ui crates/
168 + 2. Copier les fichiers partagés (si modifiés) :
169 + cp -r /chemin/vers/Rustwarden/locales ./
170 + cp -r /chemin/vers/Rustwarden/static ./
171 + 3. Vérifier les breaking changes :
172 + - Les exports du prelude (signatures de RustwardenApp, AuthUser, Claims, etc.)
173 + - Les migrations : s'assurer que core_migrations() dans src/migrations.rs est à jour
174 + - Le contrat des templates (LayoutContext variables)
175 + - Les dépendances dans Cargo.toml (les versions récentes ont changé : thiserror 2.0, askama 0.15, jsonwebtoken 10, rand 0.9, etc.)
176 + 4. Compiler et corriger :
177 + cargo check
178 + cargo clippy -- -D warnings
179 +
180 +
162 181 ### Sequencement interne du builder
163 182
164 183 1. Connexion a la base PostgreSQL
M crates/rustwarden-ui/src/handlers/login_handler.rs
+2 -0
@@ -68,6 +68,7 @@ struct ResendVerificationTemplate {
68 68 app_domain: String,
69 69 app_description: String,
70 70 page_title: String,
71 + #[allow(dead_code)] // utilise par base.html (template parent)
71 72 csrf_token: String,
72 73 title: String,
73 74 subtitle: String,
@@ -90,6 +91,7 @@ struct ResetPasswordTemplate {
90 91 app_domain: String,
91 92 app_description: String,
92 93 page_title: String,
94 + #[allow(dead_code)] // utilise par base.html (template parent)
93 95 csrf_token: String,
94 96 reset_password_title: String,
95 97 subtitle: String,
M tools/rustwarden-cli/Cargo.toml
+1 -0
@@ -13,6 +13,7 @@ clap = { version = "4", features = ["derive"] }
13 13 dialoguer = { version = "0.11", features = ["fuzzy-select"] }
14 14 console = "0.15"
15 15 rand = "0.9"
16 +chrono = "0.4"
16 17
17 18 [lints.clippy]
18 19 empty_line_after_doc_comments = "allow"
M tools/rustwarden-cli/README.md
+94 -9
@@ -1,6 +1,6 @@
1 1 # rustwarden-cli
2 2
3 -Wizard interactif en ligne de commande pour creer de nouveaux projets applicatifs bases sur le framework Rustwarden.
3 +Wizard interactif en ligne de commande pour creer et maintenir des projets applicatifs bases sur le framework Rustwarden.
4 4
5 5 ## Installation
6 6
@@ -12,18 +12,30 @@ cargo build -p rustwarden-cli
12 12
13 13 Le binaire est genere dans `target/debug/rustwarden-cli`.
14 14
15 -## Utilisation
15 +Pour un binaire optimise reutilisable :
16 16
17 17 ```bash
18 -cargo run -p rustwarden-cli -- start-project
18 +cargo build -p rustwarden-cli --release
19 +# => target/release/rustwarden-cli
19 20 ```
20 21
21 -Ou directement :
22 +## Utilisation
22 23
23 24 ```bash
24 -./target/debug/rustwarden-cli start-project
25 +# Lancement direct (compile + execute)
26 +cargo run -p rustwarden-cli -- <commande>
27 +
28 +# Ou via le binaire
29 +./target/release/rustwarden-cli <commande>
25 30 ```
26 31
32 +Commandes disponibles :
33 +
34 +| Commande | Description |
35 +|----------|-------------|
36 +| `start-project` | Creer un nouveau projet applicatif Rustwarden |
37 +| `update-project` | Mettre a jour les crates du framework dans un projet existant |
38 +
27 39 ## Commande `start-project`
28 40
29 41 Le wizard pose une serie de questions pour configurer le projet, puis genere un dossier complet pret a l'emploi.
@@ -46,12 +58,17 @@ Le wizard pose une serie de questions pour configurer le projet, puis genere un
46 58 | 12 | Email admin | `admin@localhost` |
47 59 | 13 | Mot de passe admin | *(genere aleatoirement)* |
48 60 | 14 | Configurer SMTP ? (optionnel) | Non |
61 +| 15 | Activer OAuth / SSO ? (optionnel) | Non |
49 62
50 63 Si SMTP est active, le wizard demande egalement :
51 64 - Hote, port, identifiants SMTP
52 65 - Adresse et nom d'expediteur
53 66 - TLS / STARTTLS
54 67
68 +Si OAuth est active :
69 +- URL de base pour les callbacks
70 +- Configuration par provider (Google, GitHub, Discord, Microsoft)
71 +
55 72 ### Structure generee
56 73
57 74 ```
@@ -69,6 +86,7 @@ mon-app/
69 86 │ └── .env # Identifiants DB pour Docker
70 87 ├── .env # Configuration complete de l'application
71 88 ├── .env.example # Reference de toutes les variables d'environnement
89 +├── .rustwarden-version # Version du framework (pour update-project)
72 90 ├── README.md # Guide de demarrage rapide
73 91 └── DEVELOPER.md # Guide complet pour ajouter de la logique metier
74 92 ```
@@ -82,6 +100,7 @@ mon-app/
82 100 | `.env` | Variables d'environnement remplies avec les reponses du wizard |
83 101 | `.env.example` | Reference avec toutes les variables et valeurs d'exemple |
84 102 | `database/.env` | Credentials PostgreSQL pour Docker Compose |
103 +| `.rustwarden-version` | Hash git + date du framework au moment de la generation |
85 104 | `README.md` | Demarrage rapide, routes, commandes |
86 105 | `DEVELOPER.md` | Architecture, ajout de routes/modeles, hooks, i18n, configuration |
87 106
@@ -109,15 +128,78 @@ cargo run
109 128
110 129 L'admin initial est deja configure avec les identifiants fournis pendant le wizard.
111 130
131 +## Commande `update-project`
132 +
133 +Met a jour les crates du framework (`rustwarden-core`, `rustwarden-ui`) et les ressources partagees (`locales`, `static`, `database`) dans un projet existant, sans toucher aux fichiers specifiques du projet.
134 +
135 +```bash
136 +cargo run -p rustwarden-cli -- update-project
137 +```
138 +
139 +### Fonctionnement
140 +
141 +1. **Detection** : demande le chemin du projet et verifie qu'il s'agit bien d'un projet Rustwarden
142 +2. **Versions** : affiche la version actuelle (depuis `.rustwarden-version`) et la nouvelle (git hash du framework)
143 +3. **Detection des templates personnalises** : compare les templates HTML du projet avec ceux du framework pour identifier les modifications
144 +4. **Plan de mise a jour** : affiche ce qui sera mis a jour et ce qui sera preserve
145 +5. **Confirmation** : demande validation avant de proceder
146 +6. **Backup** : cree un dossier `.rustwarden-backup-YYYYMMDD_HHMMSS/` avec une copie complete des fichiers qui vont etre remplaces
147 +7. **Mise a jour** : remplace les crates et ressources par les versions du framework source
148 +8. **Version** : met a jour `.rustwarden-version`
149 +
150 +### Fichiers mis a jour
151 +
152 +| Element | Action |
153 +|---------|--------|
154 +| `crates/rustwarden-core/` | Supprime et recopie depuis le framework |
155 +| `crates/rustwarden-ui/` | Supprime et recopie (si present dans le projet) |
156 +| `locales/` | Remplace |
157 +| `static/` | Remplace (si le projet a une UI) |
158 +| `database/` | Remplace en preservant `database/.env` |
159 +
160 +### Fichiers jamais touches
161 +
162 +| Fichier | Raison |
163 +|---------|--------|
164 +| `Cargo.toml` | Configuration workspace du projet |
165 +| `src/main.rs` | Logique metier du projet |
166 +| `.env` | Credentials et configuration specifiques |
167 +| `.env.example` | Peut avoir ete personnalise |
168 +| `database/.env` | Credentials PostgreSQL du projet |
169 +| `README.md` | Documentation specifique au projet |
170 +| `DEVELOPER.md` | Guide personnalise |
171 +
172 +### Apres la mise a jour
173 +
174 +Le CLI affiche les etapes a suivre :
175 +
176 +```bash
177 +# 1. Verifier la compilation
178 +cargo check
179 +
180 +# 2. Corriger les eventuels breaking changes
181 +cargo clippy -- -D warnings
182 +
183 +# 3. Verifier les migrations
184 +cargo run --bin migrate
185 +
186 +# 4. Lancer les tests
187 +cargo test
188 +```
189 +
190 +Si des templates personnalises ont ete ecrases, ils sont recuperables dans le dossier de backup.
191 +
112 192 ## Architecture du CLI
113 193
114 194 ```
115 195 tools/rustwarden-cli/
116 -├── Cargo.toml # Dependencies : clap, dialoguer, console, rand
196 +├── Cargo.toml # Dependencies : clap, dialoguer, console, rand, chrono
117 197 └── src/
118 - ├── main.rs # Point d'entree, parsing des sous-commandes
119 - ├── start_project.rs # Logique du wizard (questions, copie, generation)
120 - └── templates.rs # Templates des fichiers generes (Cargo.toml, main.rs, .env, etc.)
198 + ├── main.rs # Point d'entree, parsing des sous-commandes
199 + ├── start_project.rs # Wizard de creation (questions, copie, generation)
200 + ├── update_project.rs # Logique de mise a jour (backup, remplacement, detection)
201 + ├── templates.rs # Templates des fichiers generes (Cargo.toml, main.rs, .env, etc.)
202 + └── version.rs # Gestion du fichier .rustwarden-version (lecture, ecriture, calcul)
121 203 ```
122 204
123 205 ### Dependencies
@@ -128,6 +210,7 @@ tools/rustwarden-cli/
128 210 | `dialoguer` | Prompts interactifs (Input, Select, Confirm) |
129 211 | `console` | Styles et couleurs dans le terminal |
130 212 | `rand` | Generation de mots de passe et secrets JWT aleatoires |
213 +| `chrono` | Timestamp pour les noms de dossiers de backup |
131 214
132 215 ## Etendre le CLI
133 216
@@ -138,6 +221,8 @@ Pour ajouter une nouvelle sous-commande, ajoutez une variante dans l'enum `Comma
138 221 enum Commands {
139 222 /// Creer un nouveau projet applicatif Rustwarden
140 223 StartProject,
224 + /// Mettre a jour les crates du framework dans un projet existant
225 + UpdateProject,
141 226 /// Votre nouvelle commande
142 227 MaCommande,
143 228 }
M tools/rustwarden-cli/src/main.rs
+15 -1
@@ -1,10 +1,16 @@
1 1 mod start_project;
2 2 mod templates;
3 +mod update_project;
4 +mod version;
3 5
4 6 use clap::{Parser, Subcommand};
5 7
6 8 #[derive(Parser)]
7 -#[command(name = "rustwarden-cli", version, about = "CLI wizard pour scaffolder des applications Rustwarden")]
9 +#[command(
10 + name = "rustwarden-cli",
11 + version,
12 + about = "CLI wizard pour scaffolder des applications Rustwarden"
13 +)]
8 14 struct Cli {
9 15 #[command(subcommand)]
10 16 command: Commands,
@@ -14,6 +20,8 @@ struct Cli {
14 20 enum Commands {
15 21 /// Creer un nouveau projet applicatif Rustwarden
16 22 StartProject,
23 + /// Mettre a jour les crates du framework dans un projet existant
24 + UpdateProject,
17 25 }
18 26
19 27 fn main() {
@@ -26,5 +34,11 @@ fn main() {
26 34 std::process::exit(1);
27 35 }
28 36 }
37 + Commands::UpdateProject => {
38 + if let Err(e) = update_project::run() {
39 + eprintln!("{}", console::style(format!("Erreur: {e}")).red().bold());
40 + std::process::exit(1);
41 + }
42 + }
29 43 }
30 44 }
M tools/rustwarden-cli/src/start_project.rs
+24 -46
@@ -6,6 +6,7 @@ use dialoguer::{Confirm, Input, Select};
6 6 use rand::Rng;
7 7
8 8 use crate::templates::{self, OAuthProviderConfig, OAuthWizardConfig, ProjectConfig, SmtpConfig};
9 +use crate::version;
9 10
10 11 pub fn run() -> Result<(), Box<dyn std::error::Error>> {
11 12 println!(
@@ -31,28 +32,16 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
31 32 let auto_migrate = ask_auto_migrate()?;
32 33 let port = ask_port()?;
33 34
34 - println!(
35 - "\n{}",
36 - style("── Base de donnees ──").cyan().bold()
37 - );
35 + println!("\n{}", style("── Base de donnees ──").cyan().bold());
38 36 let (db_name, db_user, db_password) = ask_database_config(&name)?;
39 37
40 - println!(
41 - "\n{}",
42 - style("── Administrateur ──").cyan().bold()
43 - );
38 + println!("\n{}", style("── Administrateur ──").cyan().bold());
44 39 let (admin_username, admin_email, admin_password) = ask_admin_config()?;
45 40
46 - println!(
47 - "\n{}",
48 - style("── Email SMTP ──").cyan().bold()
49 - );
41 + println!("\n{}", style("── Email SMTP ──").cyan().bold());
50 42 let smtp = ask_smtp_config(&app_display_name)?;
51 43
52 - println!(
53 - "\n{}",
54 - style("── OAuth / SSO ──").cyan().bold()
55 - );
44 + println!("\n{}", style("── OAuth / SSO ──").cyan().bold());
56 45 let oauth = ask_oauth_config(port)?;
57 46
58 47 let jwt_secret = generate_jwt_secret();
@@ -75,10 +64,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
75 64 oauth,
76 65 };
77 66
78 - println!(
79 - "\n{}",
80 - style("Generation du projet...").cyan().bold()
81 - );
67 + println!("\n{}", style("Generation du projet...").cyan().bold());
82 68
83 69 fs::create_dir_all(project_dir.join("src"))?;
84 70 fs::create_dir_all(project_dir.join("crates"))?;
@@ -92,6 +78,11 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
92 78 generate_files(&project_dir, &cfg)?;
93 79 rebrand_templates(&project_dir, &app_display_name)?;
94 80
81 + // Ecrire le fichier de version du framework
82 + if let Ok(v) = version::compute_version(&rustwarden_root) {
83 + let _ = version::write_version(&project_dir, &v);
84 + }
85 +
95 86 print_summary(&cfg, &project_dir);
96 87
97 88 Ok(())
@@ -210,10 +201,7 @@ fn handle_existing_dir(project_dir: &Path) -> Result<(), Box<dyn std::error::Err
210 201 }
211 202
212 203 fn ask_ui_mode() -> Result<bool, Box<dyn std::error::Error>> {
213 - let options = vec![
214 - "Avec interface web (rustwarden-ui)",
215 - "API seule (headless)",
216 - ];
204 + let options = vec!["Avec interface web (rustwarden-ui)", "API seule (headless)"];
217 205 let selection = Select::new()
218 206 .with_prompt(format!("{}", style("Mode").cyan()))
219 207 .items(&options)
@@ -262,10 +250,7 @@ fn ask_database_config(
262 250
263 251 let default_pw = generate_random_password(16);
264 252 let db_password: String = Input::new()
265 - .with_prompt(format!(
266 - "{}",
267 - style("Mot de passe PostgreSQL").cyan()
268 - ))
253 + .with_prompt(format!("{}", style("Mot de passe PostgreSQL").cyan()))
269 254 .default(default_pw)
270 255 .interact_text()?;
271 256
@@ -292,7 +277,9 @@ fn ask_admin_config() -> Result<(String, String, String), Box<dyn std::error::Er
292 277 Ok((username, email, password))
293 278 }
294 279
295 -fn ask_smtp_config(app_display_name: &str) -> Result<Option<SmtpConfig>, Box<dyn std::error::Error>> {
280 +fn ask_smtp_config(
281 + app_display_name: &str,
282 +) -> Result<Option<SmtpConfig>, Box<dyn std::error::Error>> {
296 283 let configure = Confirm::new()
297 284 .with_prompt(format!(
298 285 "{}",
@@ -424,7 +411,10 @@ fn ask_oauth_config(port: u16) -> Result<Option<OAuthWizardConfig>, Box<dyn std:
424 411 .interact_text()?;
425 412
426 413 let client_secret: String = Input::new()
427 - .with_prompt(format!(" {} Client Secret", style(display_names[i]).cyan()))
414 + .with_prompt(format!(
415 + " {} Client Secret",
416 + style(display_names[i]).cyan()
417 + ))
428 418 .interact_text()?;
429 419
430 420 providers.push(OAuthProviderConfig {
@@ -473,9 +463,7 @@ fn find_rustwarden_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
473 463 let cargo_toml = dir.join("Cargo.toml");
474 464 if cargo_toml.exists() {
475 465 let content = fs::read_to_string(&cargo_toml)?;
476 - if content.contains("[workspace]")
477 - && dir.join("crates/rustwarden-core").exists()
478 - {
466 + if content.contains("[workspace]") && dir.join("crates/rustwarden-core").exists() {
479 467 return Ok(dir);
480 468 }
481 469 }
@@ -647,11 +635,7 @@ fn generate_files(
647 635 if let Some(parent) = full_path.parent() {
648 636 fs::create_dir_all(parent)?;
649 637 }
650 - println!(
651 - " {} {}",
652 - style("Genere").dim(),
653 - style(path).white()
654 - );
638 + println!(" {} {}", style("Genere").dim(), style(path).white());
655 639 fs::write(full_path, content)?;
656 640 }
657 641
@@ -665,10 +649,7 @@ fn generate_files(
665 649 fn print_summary(cfg: &ProjectConfig, project_dir: &Path) {
666 650 let divider = style("─".repeat(55)).dim();
667 651 println!("\n{divider}");
668 - println!(
669 - " {}",
670 - style("Projet cree avec succes !").green().bold()
671 - );
652 + println!(" {}", style("Projet cree avec succes !").green().bold());
672 653 println!("{divider}\n");
673 654
674 655 println!(" {} {}", style("Nom").bold(), cfg.name);
@@ -730,10 +711,7 @@ fn print_summary(cfg: &ProjectConfig, project_dir: &Path) {
730 711 " {}",
731 712 style("cd database && docker compose up -d && cd ..").green()
732 713 );
733 - println!(
734 - " {} Lancer l'application :",
735 - style("3.").cyan().bold()
736 - );
714 + println!(" {} Lancer l'application :", style("3.").cyan().bold());
737 715 println!(" {}", style("cargo run").green());
738 716 println!(
739 717 " {} Ouvrir dans le navigateur :",
A tools/rustwarden-cli/src/update_project.rs
+711 -0
@@ -0,0 +1,711 @@
1 +use std::fs;
2 +use std::path::{Path, PathBuf};
3 +
4 +use console::style;
5 +use dialoguer::{Confirm, Input};
6 +
7 +use crate::version;
8 +
9 +/// Fichiers et dossiers du framework qui sont remplaces lors de la mise a jour.
10 +const FRAMEWORK_CRATES: &[&str] = &["rustwarden-core", "rustwarden-ui"];
11 +/// Ressources partagees mises a jour. Le dossier database/ est exclu car il
12 +/// contient les volumes Docker et le .env avec les credentials du projet.
13 +const FRAMEWORK_RESOURCES: &[&str] = &["locales", "static"];
14 +
15 +/// Fichiers du projet consommateur qu'on ne touche JAMAIS.
16 +const PROTECTED_FILES: &[&str] = &[
17 + "Cargo.toml",
18 + "src/main.rs",
19 + ".env",
20 + ".env.example",
21 + "README.md",
22 + "DEVELOPER.md",
23 +];
24 +
25 +/// Lance la commande update-project.
26 +pub fn run() -> Result<(), Box<dyn std::error::Error>> {
27 + println!(
28 + "\n{}",
29 + style(" Rustwarden - Mise a jour du framework ")
30 + .bold()
31 + .on_cyan()
32 + .black(),
33 + );
34 + println!();
35 +
36 + let rustwarden_root = find_rustwarden_root()?;
37 + let project_dir = ask_project_dir()?;
38 +
39 + validate_consumer_project(&project_dir)?;
40 +
41 + let current_version = version::read_version(&project_dir);
42 + let new_version = version::compute_version(&rustwarden_root)?;
43 +
44 + print_version_info(&current_version, &new_version);
45 +
46 + let has_ui = project_dir.join("crates/rustwarden-ui").exists();
47 + let custom_templates = detect_custom_templates(&project_dir, &rustwarden_root, has_ui);
48 +
49 + print_update_plan(&project_dir, has_ui, &custom_templates);
50 +
51 + if !Confirm::new()
52 + .with_prompt(format!("{}", style("Proceder a la mise a jour ?").cyan()))
53 + .default(false)
54 + .interact()?
55 + {
56 + println!(" {} Operation annulee.", style("✗").red());
57 + return Ok(());
58 + }
59 +
60 + // --- Backup ---
61 + let backup_dir = create_backup(&project_dir, has_ui)?;
62 +
63 + // --- Mise a jour des crates ---
64 + let stats = update_crates(&rustwarden_root, &project_dir, has_ui, &custom_templates)?;
65 +
66 + // --- Mise a jour des ressources ---
67 + update_resources(&rustwarden_root, &project_dir, has_ui)?;
68 +
69 + // --- Reecrire le fichier de version ---
70 + version::write_version(&project_dir, &new_version)?;
71 +
72 + // --- Resume ---
73 + print_summary(&project_dir, &backup_dir, &custom_templates, &stats);
74 +
75 + Ok(())
76 +}
77 +
78 +// ---------------------------------------------------------------------------
79 +// Detection du projet consommateur
80 +// ---------------------------------------------------------------------------
81 +
82 +/// Demande le chemin du projet consommateur.
83 +fn ask_project_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
84 + let dir: String = Input::new()
85 + .with_prompt(format!(
86 + "{}",
87 + style("Chemin du projet consommateur a mettre a jour").cyan()
88 + ))
89 + .default(".".into())
90 + .interact_text()?;
91 +
92 + let path = PathBuf::from(&dir);
93 + let path = if path.is_absolute() {
94 + path
95 + } else {
96 + std::env::current_dir()?.join(path)
97 + };
98 +
99 + Ok(path.canonicalize().unwrap_or(path))
100 +}
101 +
102 +/// Verifie que le dossier est bien un projet consommateur Rustwarden.
103 +fn validate_consumer_project(project_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
104 + if !project_dir.join("crates/rustwarden-core").exists() {
105 + return Err(format!(
106 + "Le dossier {} ne semble pas etre un projet Rustwarden.\n\
107 + (crates/rustwarden-core/ introuvable)",
108 + project_dir.display()
109 + )
110 + .into());
111 + }
112 +
113 + if !project_dir.join("Cargo.toml").exists() {
114 + return Err(format!(
115 + "Pas de Cargo.toml dans {}. Est-ce bien la racine du projet ?",
116 + project_dir.display()
117 + )
118 + .into());
119 + }
120 +
121 + Ok(())
122 +}
123 +
124 +// ---------------------------------------------------------------------------
125 +// Detection des templates personnalises
126 +// ---------------------------------------------------------------------------
127 +
128 +/// Compare les templates du projet avec ceux du framework pour detecter les
129 +/// fichiers modifies par le consommateur.
130 +fn detect_custom_templates(
131 + project_dir: &Path,
132 + rustwarden_root: &Path,
133 + has_ui: bool,
134 +) -> Vec<String> {
135 + let mut custom = Vec::new();
136 +
137 + if !has_ui {
138 + return custom;
139 + }
140 +
141 + let project_templates = project_dir.join("crates/rustwarden-ui/templates");
142 + let framework_templates = rustwarden_root.join("crates/rustwarden-ui/templates");
143 +
144 + if !project_templates.exists() || !framework_templates.exists() {
145 + return custom;
146 + }
147 +
148 + compare_dirs_recursive(
149 + &project_templates,
150 + &framework_templates,
151 + &project_templates,
152 + &mut custom,
153 + );
154 + custom
155 +}
156 +
157 +/// Compare recursivement deux dossiers et enregistre les fichiers differents.
158 +fn compare_dirs_recursive(
159 + project_dir: &Path,
160 + framework_dir: &Path,
161 + base: &Path,
162 + custom: &mut Vec<String>,
163 +) {
164 + let entries = match fs::read_dir(project_dir) {
165 + Ok(e) => e,
166 + Err(_) => return,
167 + };
168 +
169 + for entry in entries.flatten() {
170 + let path = entry.path();
171 + let relative = path.strip_prefix(base).unwrap_or(&path);
172 + let framework_path = framework_dir.join(relative);
173 +
174 + if path.is_dir() {
175 + compare_dirs_recursive(&path, &framework_dir.join(entry.file_name()), base, custom);
176 + } else if path.extension().and_then(|e| e.to_str()) == Some("html") {
177 + if !framework_path.exists() {
178 + // Fichier qui n'existe pas dans le framework = fichier custom
179 + custom.push(relative.to_string_lossy().to_string());
180 + } else {
181 + // Comparer le contenu
182 + let Ok(project_content) = fs::read(&path) else {
183 + continue;
184 + };
185 + let Ok(framework_content) = fs::read(&framework_path) else {
186 + continue;
187 + };
188 + if project_content != framework_content {
189 + custom.push(relative.to_string_lossy().to_string());
190 + }
191 + }
192 + }
193 + }
194 +}
195 +
196 +// ---------------------------------------------------------------------------
197 +// Affichage
198 +// ---------------------------------------------------------------------------
199 +
200 +/// Affiche les informations de version.
201 +fn print_version_info(current: &Option<String>, new: &str) {
202 + println!("\n{}", style("── Versions ──").cyan().bold());
203 +
204 + match current {
205 + Some(v) => println!(" {} {}", style("Actuelle :").dim(), style(v).white()),
206 + None => println!(
207 + " {} {}",
208 + style("Actuelle :").dim(),
209 + style("inconnue (pas de .rustwarden-version)").yellow()
210 + ),
211 + }
212 + println!(" {} {}", style("Nouvelle :").dim(), style(new).green());
213 +}
214 +
215 +/// Affiche le plan de mise a jour.
216 +fn print_update_plan(project_dir: &Path, has_ui: bool, custom_templates: &[String]) {
217 + println!("\n{}", style("── Plan de mise a jour ──").cyan().bold());
218 +
219 + println!(" {} {}", style("Projet :").dim(), project_dir.display());
220 +
221 + println!("\n {} :", style("Sera mis a jour").green().bold());
222 + println!(" - crates/rustwarden-core/");
223 + if has_ui {
224 + println!(" - crates/rustwarden-ui/");
225 + }
226 + for res in FRAMEWORK_RESOURCES {
227 + let p = project_dir.join(res);
228 + if p.exists() || (has_ui || *res != "static") {
229 + println!(" - {res}/");
230 + }
231 + }
232 +
233 + println!(
234 + "\n {} :",
235 + style("Preserve (jamais touche)").yellow().bold()
236 + );
237 + for f in PROTECTED_FILES {
238 + if project_dir.join(f).exists() {
239 + println!(" - {f}");
240 + }
241 + }
242 +
243 + if !custom_templates.is_empty() {
244 + println!(
245 + "\n {} {} template(s) personnalise(s) detecte(s) :",
246 + style("⚠").yellow().bold(),
247 + custom_templates.len()
248 + );
249 + for t in custom_templates {
250 + println!(" - templates/{t}");
251 + }
252 + println!(
253 + " {}",
254 + style(" Ces fichiers seront preserves (non ecrases).").green()
255 + );
256 + }
257 +
258 + println!(
259 + "\n {}",
260 + style("Seuls les fichiers modifies dans le framework seront mis a jour.").dim()
261 + );
262 + println!(
263 + " {}",
264 + style("Un backup complet des crates sera cree avant la mise a jour.").dim()
265 + );
266 +}
267 +
268 +/// Statistiques de synchronisation.
269 +#[derive(Default)]
270 +struct SyncStats {
271 + updated: Vec<String>,
272 + added: Vec<String>,
273 + skipped_custom: Vec<String>,
274 + unchanged: u32,
275 + removed: Vec<String>,
276 +}
277 +
278 +/// Affiche le resume final.
279 +fn print_summary(
280 + project_dir: &Path,
281 + backup_dir: &Path,
282 + custom_templates: &[String],
283 + stats: &SyncStats,
284 +) {
285 + let divider = style("─".repeat(55)).dim();
286 + println!("\n{divider}");
287 + println!(" {}", style("Mise a jour terminee !").green().bold());
288 + println!("{divider}\n");
289 +
290 + println!(" {} {}", style("Backup :").bold(), backup_dir.display());
291 +
292 + if !stats.added.is_empty() {
293 + println!(
294 + "\n {} {} fichier(s) ajoute(s) :",
295 + style("+").green().bold(),
296 + stats.added.len()
297 + );
298 + for f in &stats.added {
299 + println!(" {}", style(f).green());
300 + }
301 + }
302 +
303 + if !stats.updated.is_empty() {
304 + println!(
305 + "\n {} {} fichier(s) mis a jour :",
306 + style("~").cyan().bold(),
307 + stats.updated.len()
308 + );
309 + for f in &stats.updated {
310 + println!(" {}", style(f).cyan());
311 + }
312 + }
313 +
314 + if !stats.removed.is_empty() {
315 + println!(
316 + "\n {} {} fichier(s) supprime(s) (n'existent plus dans le framework) :",
317 + style("-").red().bold(),
318 + stats.removed.len()
319 + );
320 + for f in &stats.removed {
321 + println!(" {}", style(f).red());
322 + }
323 + }
324 +
325 + if !stats.skipped_custom.is_empty() {
326 + println!(
327 + "\n {} {} template(s) personnalise(s) preserve(s) :",
328 + style("⚠").yellow().bold(),
329 + stats.skipped_custom.len()
330 + );
331 + for f in &stats.skipped_custom {
332 + println!(" {}", style(f).yellow());
333 + }
334 + }
335 +
336 + if stats.unchanged > 0 {
337 + println!(
338 + "\n {} {} fichier(s) identiques (non modifies)",
339 + style("=").dim(),
340 + stats.unchanged
341 + );
342 + }
343 +
344 + let _ = (project_dir, custom_templates); // utilises dans le plan
345 +
346 + println!("\n{divider}");
347 + println!(" {}", style("Prochaines etapes :").bold());
348 + println!("{divider}\n");
349 +
350 + println!(" {} Verifier la compilation :", style("1.").cyan().bold());
351 + println!(
352 + " {}",
353 + style(format!("cd {} && cargo check", project_dir.display())).green()
354 + );
355 + println!(
356 + " {} Corriger les eventuels breaking changes :",
357 + style("2.").cyan().bold()
358 + );
359 + println!(" {}", style("cargo clippy -- -D warnings").green());
360 + println!(" {} Verifier les migrations :", style("3.").cyan().bold());
361 + println!(" {}", style("cargo run --bin migrate").green());
362 + println!(" {} Lancer les tests :", style("4.").cyan().bold());
363 + println!(" {}", style("cargo test").green());
364 + println!();
365 +}
366 +
367 +// ---------------------------------------------------------------------------
368 +// Backup
369 +// ---------------------------------------------------------------------------
370 +
371 +/// Cree un backup des crates et ressources du framework dans le projet.
372 +fn create_backup(project_dir: &Path, has_ui: bool) -> Result<PathBuf, Box<dyn std::error::Error>> {
373 + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
374 + let backup_dir = project_dir.join(format!(".rustwarden-backup-{timestamp}"));
375 + fs::create_dir_all(&backup_dir)?;
376 +
377 + println!(
378 + "\n {} {}",
379 + style("Backup").dim(),
380 + style(backup_dir.display().to_string()).white()
381 + );
382 +
383 + for crate_name in FRAMEWORK_CRATES {
384 + let src = project_dir.join("crates").join(crate_name);
385 + if src.exists() {
386 + let dst = backup_dir.join("crates").join(crate_name);
387 + copy_dir_recursive(&src, &dst)?;
388 + println!(" {} crates/{crate_name}/", style("→").dim(),);
389 + }
390 + }
391 +
392 + for res in FRAMEWORK_RESOURCES {
393 + let src = project_dir.join(res);
394 + if src.exists() && (has_ui || *res != "static") {
395 + let dst = backup_dir.join(res);
396 + copy_dir_recursive(&src, &dst)?;
397 + println!(" {} {res}/", style("→").dim(),);
398 + }
399 + }
400 +
401 + Ok(backup_dir)
402 +}
403 +
404 +// ---------------------------------------------------------------------------
405 +// Mise a jour
406 +// ---------------------------------------------------------------------------
407 +
408 +/// Met a jour les crates du framework via sync fichier par fichier.
409 +fn update_crates(
410 + rustwarden_root: &Path,
411 + project_dir: &Path,
412 + has_ui: bool,
413 + custom_templates: &[String],
414 +) -> Result<SyncStats, Box<dyn std::error::Error>> {
415 + println!("\n{}", style("── Mise a jour des crates ──").cyan().bold());
416 +
417 + let mut stats = SyncStats::default();
418 +
419 + // rustwarden-core : toujours mis a jour
420 + sync_crate(
421 + rustwarden_root,
422 + project_dir,
423 + "rustwarden-core",
424 + &[],
425 + &mut stats,
426 + )?;
427 +
428 + // rustwarden-ui : uniquement si le projet l'utilise
429 + if has_ui {
430 + sync_crate(
431 + rustwarden_root,
432 + project_dir,
433 + "rustwarden-ui",
434 + custom_templates,
435 + &mut stats,
436 + )?;
437 + }
438 +
439 + Ok(stats)
440 +}
441 +
442 +/// Synchronise un crate du framework fichier par fichier.
443 +/// - Fichiers identiques : ignores
444 +/// - Fichiers modifies dans le framework : mis a jour
445 +/// - Fichiers nouveaux dans le framework : ajoutes
446 +/// - Fichiers supprimes du framework : supprimes (sauf custom)
447 +/// - Templates personnalises : preserves
448 +fn sync_crate(
449 + rustwarden_root: &Path,
450 + project_dir: &Path,
451 + crate_name: &str,
452 + custom_templates: &[String],
453 + stats: &mut SyncStats,
454 +) -> Result<(), Box<dyn std::error::Error>> {
455 + let src = rustwarden_root.join("crates").join(crate_name);
456 + let dst = project_dir.join("crates").join(crate_name);
457 +
458 + if !src.exists() {
459 + return Err(format!("Crate source introuvable : {}", src.display()).into());
460 + }
461 +
462 + let prefix = format!("crates/{crate_name}");
463 +
464 + // 1. Copier/mettre a jour depuis la source
465 + sync_dir_recursive(&src, &dst, &prefix, crate_name, custom_templates, stats)?;
466 +
467 + // 2. Supprimer les fichiers du projet qui n'existent plus dans le framework
468 + cleanup_removed_files(&src, &dst, &prefix, crate_name, custom_templates, stats)?;
469 +
470 + println!(" {} crates/{crate_name}/", style("✓").green().bold());
471 +
472 + Ok(())
473 +}
474 +
475 +/// Synchronise recursivement un dossier source vers un dossier destination.
476 +fn sync_dir_recursive(
477 + src: &Path,
478 + dst: &Path,
479 + prefix: &str,
480 + crate_name: &str,
481 + custom_templates: &[String],
482 + stats: &mut SyncStats,
483 +) -> Result<(), Box<dyn std::error::Error>> {
484 + fs::create_dir_all(dst)?;
485 +
486 + for entry in fs::read_dir(src)? {
487 + let entry = entry?;
488 + let file_type = entry.file_type()?;
489 + let src_path = entry.path();
490 + let dst_path = dst.join(entry.file_name());
491 + let name = entry.file_name().to_string_lossy().to_string();
492 +
493 + // Exclure target/, .git/, data/
494 + if name == "target" || name == ".git" || name == "data" {
495 + continue;
496 + }
497 +
498 + let relative = format!("{prefix}/{name}");
499 +
500 + if file_type.is_dir() {
501 + sync_dir_recursive(
502 + &src_path,
503 + &dst_path,
504 + &relative,
505 + crate_name,
506 + custom_templates,
507 + stats,
508 + )?;
509 + } else {
510 + // Verifier si c'est un template personnalise a preserver
511 + if is_custom_template(&dst_path, crate_name, custom_templates) {
512 + stats.skipped_custom.push(relative.clone());
513 + continue;
514 + }
515 +
516 + if dst_path.exists() {
517 + // Comparer le contenu
518 + let src_content = fs::read(&src_path)?;
519 + let dst_content = fs::read(&dst_path)?;
520 +
521 + if src_content == dst_content {
522 + stats.unchanged += 1;
523 + } else {
524 + fs::copy(&src_path, &dst_path)?;
525 + stats.updated.push(relative);
526 + }
527 + } else {
528 + // Nouveau fichier
529 + if let Some(parent) = dst_path.parent() {
530 + fs::create_dir_all(parent)?;
531 + }
532 + fs::copy(&src_path, &dst_path)?;
533 + stats.added.push(relative);
534 + }
535 + }
536 + }
537 +
538 + Ok(())
539 +}
540 +
541 +/// Supprime les fichiers du projet qui n'existent plus dans le framework.
542 +fn cleanup_removed_files(
543 + src: &Path,
544 + dst: &Path,
545 + prefix: &str,
546 + crate_name: &str,
547 + custom_templates: &[String],
548 + stats: &mut SyncStats,
549 +) -> Result<(), Box<dyn std::error::Error>> {
550 + if !dst.exists() {
551 + return Ok(());
552 + }
553 +
554 + for entry in fs::read_dir(dst)? {
555 + let entry = entry?;
556 + let file_type = entry.file_type()?;
557 + let src_path = src.join(entry.file_name());
558 + let dst_path = entry.path();
559 + let name = entry.file_name().to_string_lossy().to_string();
560 +
561 + if name == "target" || name == ".git" || name == "data" {
562 + continue;
563 + }
564 +
565 + let relative = format!("{prefix}/{name}");
566 +
567 + if file_type.is_dir() {
568 + if src_path.exists() {
569 + // Le dossier existe dans le framework, verifier son contenu
570 + cleanup_removed_files(
571 + &src_path,
572 + &dst_path,
573 + &relative,
574 + crate_name,
575 + custom_templates,
576 + stats,
577 + )?;
578 + }
579 + // On ne supprime pas les dossiers entiers qui n'existent pas
580 + // dans le framework — ils peuvent contenir des fichiers custom
581 + } else if !src_path.exists() {
582 + // Le fichier n'existe plus dans le framework
583 + if is_custom_template(&dst_path, crate_name, custom_templates) {
584 + // Template custom, on le garde
585 + continue;
586 + }
587 + fs::remove_file(&dst_path)?;
588 + stats.removed.push(relative);
589 + }
590 + }
591 +
592 + Ok(())
593 +}
594 +
595 +/// Verifie si un fichier du projet est un template personnalise.
596 +fn is_custom_template(file_path: &Path, crate_name: &str, custom_templates: &[String]) -> bool {
597 + if crate_name != "rustwarden-ui" || custom_templates.is_empty() {
598 + return false;
599 + }
600 +
601 + // Extraire le chemin relatif depuis templates/
602 + let path_str = file_path.to_string_lossy();
603 + let Some(idx) = path_str.find("templates/") else {
604 + return false;
605 + };
606 + let relative = &path_str[idx + "templates/".len()..];
607 +
608 + custom_templates.iter().any(|t| t == relative)
609 +}
610 +
611 +/// Met a jour les ressources partagees (locales, static).
612 +fn update_resources(
613 + rustwarden_root: &Path,
614 + project_dir: &Path,
615 + has_ui: bool,
616 +) -> Result<(), Box<dyn std::error::Error>> {
617 + println!(
618 + "\n{}",
619 + style("── Mise a jour des ressources ──").cyan().bold()
620 + );
621 +
622 + for res in FRAMEWORK_RESOURCES {
623 + // Ne pas copier static/ si le projet n'a pas d'UI
624 + if *res == "static" && !has_ui {
625 + continue;
626 + }
627 +
628 + let src = rustwarden_root.join(res);
629 + let dst = project_dir.join(res);
630 +
631 + if !src.exists() {
632 + continue;
633 + }
634 +
635 + if dst.exists() {
636 + fs::remove_dir_all(&dst)?;
637 + }
638 + copy_dir_recursive(&src, &dst)?;
639 +
640 + println!(" {} {res}/", style("✓").green().bold(),);
641 + }
642 +
643 + Ok(())
644 +}
645 +
646 +// ---------------------------------------------------------------------------
647 +// Decouverte du workspace Rustwarden
648 +// ---------------------------------------------------------------------------
649 +
650 +/// Cherche la racine du workspace Rustwarden (le framework source).
651 +fn find_rustwarden_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
652 + let start_dirs = [
653 + std::env::current_exe()
654 + .ok()
655 + .and_then(|p| p.parent().map(|p| p.to_path_buf())),
656 + std::env::current_dir().ok(),
657 + ];
658 +
659 + for start in start_dirs.into_iter().flatten() {
660 + let mut dir = start;
661 + loop {
662 + let cargo_toml = dir.join("Cargo.toml");
663 + if cargo_toml.exists() {
664 + let content = fs::read_to_string(&cargo_toml)?;
665 + if content.contains("[workspace]")
666 + && dir.join("crates/rustwarden-core").exists()
667 + && dir.join("tools/rustwarden-cli").exists()
668 + {
669 + return Ok(dir);
670 + }
671 + }
672 + if !dir.pop() {
673 + break;
674 + }
675 + }
676 + }
677 +
678 + Err("Impossible de trouver la racine du framework Rustwarden.\n\
679 + Lancez cette commande depuis le repertoire Rustwarden \
680 + ou son sous-dossier."
681 + .into())
682 +}
683 +
684 +// ---------------------------------------------------------------------------
685 +// Copie recursive
686 +// ---------------------------------------------------------------------------
687 +
688 +/// Copie un dossier recursivement en excluant target/, .git/, data/.
689 +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), Box<dyn std::error::Error>> {
690 + fs::create_dir_all(dst)?;
691 +
692 + for entry in fs::read_dir(src)? {
693 + let entry = entry?;
694 + let file_type = entry.file_type()?;
695 + let src_path = entry.path();
696 + let dst_path = dst.join(entry.file_name());
697 +
698 + let name = entry.file_name().to_string_lossy().to_string();
699 + if name == "target" || name == ".git" || name == "data" {
700 + continue;
701 + }
702 +
703 + if file_type.is_dir() {
704 + copy_dir_recursive(&src_path, &dst_path)?;
705 + } else {
706 + fs::copy(&src_path, &dst_path)?;
707 + }
708 + }
709 +
710 + Ok(())
711 +}
A tools/rustwarden-cli/src/version.rs
+55 -0
@@ -0,0 +1,55 @@
1 +use std::fs;
2 +use std::path::Path;
3 +use std::process::Command;
4 +
5 +/// Nom du fichier de version dans les projets consommateurs.
6 +const VERSION_FILE: &str = ".rustwarden-version";
7 +
8 +/// Lit la version actuelle du framework dans un projet consommateur.
9 +pub fn read_version(project_dir: &Path) -> Option<String> {
10 + let path = project_dir.join(VERSION_FILE);
11 + fs::read_to_string(path).ok().map(|s| s.trim().to_string())
12 +}
13 +
14 +/// Ecrit la version du framework dans un projet consommateur.
15 +pub fn write_version(project_dir: &Path, version: &str) -> Result<(), Box<dyn std::error::Error>> {
16 + let path = project_dir.join(VERSION_FILE);
17 + fs::write(path, format!("{version}\n"))?;
18 + Ok(())
19 +}
20 +
21 +/// Calcule la version actuelle du framework a partir du git hash et de la
22 +/// date du dernier commit, ou a defaut du Cargo.toml.
23 +pub fn compute_version(rustwarden_root: &Path) -> Result<String, Box<dyn std::error::Error>> {
24 + // Essayer git describe ou git log
25 + if let Ok(output) = Command::new("git")
26 + .args(["log", "-1", "--format=%h %ci"])
27 + .current_dir(rustwarden_root)
28 + .output()
29 + {
30 + if output.status.success() {
31 + let info = String::from_utf8_lossy(&output.stdout).trim().to_string();
32 + if !info.is_empty() {
33 + // Format: "d5ab015 2026-03-19 14:30:00 +0100" -> "d5ab015 (2026-03-19)"
34 + let parts: Vec<&str> = info.splitn(3, ' ').collect();
35 + if parts.len() >= 2 {
36 + return Ok(format!("{} ({})", parts[0], parts[1]));
37 + }
38 + return Ok(info);
39 + }
40 + }
41 + }
42 +
43 + // Fallback : version du Cargo.toml de rustwarden-core
44 + let cargo_toml = rustwarden_root.join("crates/rustwarden-core/Cargo.toml");
45 + let content = fs::read_to_string(cargo_toml)?;
46 + for line in content.lines() {
47 + if let Some(version) = line.strip_prefix("version = \"") {
48 + if let Some(version) = version.strip_suffix('"') {
49 + return Ok(format!("v{version}"));
50 + }
51 + }
52 + }
53 +
54 + Ok("inconnue".to_string())
55 +}