Skip to main content

rustbasic_cli/
packages.rs

1/* ---------------------------------------------------------
2 * 📦 LABEL: PACKAGE MANAGER (src/packages.rs)
3 * Menangani install, list, dan uninstall package di project
4 * RustBasic menggunakan manifest .rustbasic_packages.json
5 * --------------------------------------------------------- */
6
7use colored::*;
8use serde::{Deserialize, Serialize};
9use std::process::Command;
10
11const MANIFEST_FILE: &str = ".rustbasic_packages.json";
12
13// Registry package yang didukung beserta metadata-nya
14struct PackageInfo {
15    /// Versi default yang akan digunakan saat install via CLI
16    version: &'static str,
17    /// Deskripsi singkat package
18    description: &'static str,
19    /// Command yang dijalankan setelah install (opsional)
20    setup_command: Option<&'static str>,
21    /// Command yang dijalankan sebelum uninstall (opsional)
22    remove_command: Option<&'static str>,
23}
24
25fn known_packages(name: &str) -> Option<PackageInfo> {
26    match name {
27        "rustbasic-breeze" => Some(PackageInfo {
28            version: "0.0",
29            description: "Authentication scaffolding (login, register, reset password)",
30            setup_command: Some("breeze:install"),
31            remove_command: Some("breeze:remove"),
32        }),
33        _ => None,
34    }
35}
36
37// ─── Manifest Structs ─────────────────────────────────────────────────────────
38
39#[derive(Debug, Serialize, Deserialize, Clone)]
40pub struct InstalledPackage {
41    pub name: String,
42    pub version: String,
43    pub installed_at: String,
44    pub source: String, // "install" | "manual"
45    pub description: String,
46}
47
48#[derive(Debug, Serialize, Deserialize, Default)]
49pub struct PackageManifest {
50    pub packages: Vec<InstalledPackage>,
51}
52
53// ─── Manifest I/O ─────────────────────────────────────────────────────────────
54
55pub fn read_manifest() -> PackageManifest {
56    if let Ok(content) = std::fs::read_to_string(MANIFEST_FILE) {
57        serde_json::from_str(&content).unwrap_or_default()
58    } else {
59        PackageManifest::default()
60    }
61}
62
63fn write_manifest(manifest: &PackageManifest) {
64    if let Ok(json) = serde_json::to_string_pretty(manifest) {
65        std::fs::write(MANIFEST_FILE, json).ok();
66    }
67}
68
69// ─── Cargo.toml Helpers ───────────────────────────────────────────────────────
70
71fn cargo_toml_path() -> &'static str {
72    "Cargo.toml"
73}
74
75fn read_cargo_toml() -> Option<String> {
76    std::fs::read_to_string(cargo_toml_path()).ok()
77}
78
79fn write_cargo_toml(content: &str) {
80    std::fs::write(cargo_toml_path(), content).ok();
81}
82
83/// Cek apakah package sudah ada di Cargo.toml
84fn cargo_has_package(name: &str) -> bool {
85    read_cargo_toml()
86        .map(|c| c.contains(name))
87        .unwrap_or(false)
88}
89
90/// Tambahkan dependency ke Cargo.toml
91fn cargo_add_package(name: &str, version: &str) -> bool {
92    let Some(mut content) = read_cargo_toml() else {
93        println!("{}", "❌ Tidak dapat membaca Cargo.toml".red().bold());
94        return false;
95    };
96
97    if content.contains(name) {
98        return true; // sudah ada
99    }
100
101    let dep_line = format!("{} = {{ path = \"../{}\", version = \"{}\" }}\n", name, name, version);
102
103    // Sisipkan setelah [dependencies]
104    if let Some(pos) = content.find("[dependencies]") {
105        let insert_at = pos + "[dependencies]".len();
106        // Cari akhir baris [dependencies]
107        let after = &content[insert_at..];
108        let newline_pos = after.find('\n').map(|p| insert_at + p + 1).unwrap_or(insert_at);
109        content.insert_str(newline_pos, &dep_line);
110        write_cargo_toml(&content);
111        true
112    } else {
113        println!("{}", "❌ Tidak dapat menemukan section [dependencies] di Cargo.toml".red().bold());
114        false
115    }
116}
117
118/// Hapus dependency dari Cargo.toml
119fn cargo_remove_package(name: &str) {
120    if let Some(content) = read_cargo_toml() {
121        let filtered: String = content
122            .lines()
123            .filter(|line| !line.contains(name))
124            .collect::<Vec<_>>()
125            .join("\n");
126        // Pertahankan trailing newline
127        let result = if content.ends_with('\n') {
128            format!("{}\n", filtered)
129        } else {
130            filtered
131        };
132        write_cargo_toml(&result);
133    }
134}
135
136/// Scan Cargo.toml untuk package rustbasic-* yang belum ada di manifest
137pub fn sync_manual_packages(manifest: &mut PackageManifest) {
138    let Some(content) = read_cargo_toml() else { return };
139    let known_in_manifest: Vec<String> = manifest.packages.iter().map(|p| p.name.clone()).collect();
140
141    for line in content.lines() {
142        let trimmed = line.trim();
143        if trimmed.starts_with('#') { continue; }
144        if let Some(idx) = trimmed.find("rustbasic-") {
145            let rest = &trimmed[idx..];
146            // Ekstrak nama package: ambil sampai karakter non-alfanumerik/dash
147            let name_end = rest.find(|c: char| !c.is_alphanumeric() && c != '-')
148                .unwrap_or(rest.len());
149            let pkg_name = &rest[..name_end];
150
151            // Skip core dan cli
152            if pkg_name == "rustbasic-core" || pkg_name == "rustbasic-cli" {
153                continue;
154            }
155
156            if !known_in_manifest.contains(&pkg_name.to_string()) {
157                // Ekstrak versi jika ada
158                let version = extract_version_from_line(trimmed).unwrap_or_else(|| "?".to_string());
159                let desc = known_packages(pkg_name)
160                    .map(|p| p.description.to_string())
161                    .unwrap_or_else(|| "Package eksternal".to_string());
162
163                manifest.packages.push(InstalledPackage {
164                    name: pkg_name.to_string(),
165                    version,
166                    installed_at: "—".to_string(),
167                    source: "manual".to_string(),
168                    description: desc,
169                });
170            }
171        }
172    }
173}
174
175fn extract_version_from_line(line: &str) -> Option<String> {
176    // Coba cari 'version = "x.x"' atau '"x.x"' pattern
177    if let Some(start) = line.find("version = \"") {
178        let rest = &line[start + 11..];
179        if let Some(end) = rest.find('"') {
180            return Some(rest[..end].to_string());
181        }
182    }
183    // Bentuk ringkas: package = "x.x"
184    if let Some(eq) = line.find(" = \"") {
185        let rest = &line[eq + 4..];
186        if let Some(end) = rest.find('"') {
187            return Some(rest[..end].to_string());
188        }
189    }
190    None
191}
192
193// ─── Cargo Build ──────────────────────────────────────────────────────────────
194
195fn run_cargo_build() -> bool {
196    println!("   {} Mengunduh dan mengkompilasi dependensi...", "📦".bold());
197    let status = Command::new("cargo")
198        .args(["build", "-q"])
199        .status();
200    match status {
201        Ok(s) if s.success() => true,
202        Ok(s) => {
203            println!("{} cargo build gagal dengan exit code: {}", "❌".red().bold(), s);
204            false
205        }
206        Err(e) => {
207            println!("{} Gagal menjalankan cargo build: {}", "❌".red().bold(), e);
208            false
209        }
210    }
211}
212
213// ─── Setup / Remove via Temp Binary ───────────────────────────────────────────
214
215fn run_setup_command(package_name: &str, command: &str) {
216    let bin_dir = "src/bin";
217    std::fs::create_dir_all(bin_dir).ok();
218    let script_path = format!("{}/temp_pkg_setup.rs", bin_dir);
219
220    let script = match command {
221        "breeze:install" => {
222            r#"use rustbasic_core::dotenvy::dotenv;
223#[tokio::main]
224async fn main() {
225    dotenv().ok();
226    rustbasic_breeze::make_auth().await;
227}
228"#.to_string()
229        }
230        "breeze:remove" => {
231            r#"use rustbasic_core::dotenvy::dotenv;
232#[tokio::main]
233async fn main() {
234    dotenv().ok();
235    rustbasic_breeze::remove_auth().await;
236}
237"#.to_string()
238        }
239        _ => return,
240    };
241
242    std::fs::write(&script_path, &script).ok();
243
244    println!("   {} Menjalankan setup {}...", "⚙️".bold(), package_name.cyan().bold());
245    let status = Command::new("cargo")
246        .args(["run", "-q", "--bin", "temp_pkg_setup"])
247        .status();
248
249    // Cleanup script
250    std::fs::remove_file(&script_path).ok();
251    // Hapus folder bin jika kosong
252    if let Ok(entries) = std::fs::read_dir(bin_dir)
253        && entries.count() == 0 {
254        std::fs::remove_dir(bin_dir).ok();
255    }
256
257    match status {
258        Ok(s) if s.success() => {}
259        Ok(s) => println!("{} Setup command gagal (exit {})", "⚠️".yellow().bold(), s),
260        Err(e) => println!("{} Gagal menjalankan setup: {}", "⚠️".yellow().bold(), e),
261    }
262}
263
264// ─── Public API ───────────────────────────────────────────────────────────────
265
266/// Install sebuah package ke project
267pub fn install_package(name: &str) {
268    println!("\n{} {}", "📦 Installing:".magenta().bold(), name.cyan().bold());
269
270    // 1. Cek apakah package sudah terinstall
271    let mut manifest = read_manifest();
272    if manifest.packages.iter().any(|p| p.name == name) {
273        println!("{} Package '{}' sudah terinstall.", "⚠️".yellow().bold(), name.yellow());
274        println!("   Gunakan '{}' untuk melihat daftar package.", "rustbasic list packages".cyan());
275        return;
276    }
277
278    // 2. Tentukan versi
279    let (version, description, setup_cmd) = if let Some(info) = known_packages(name) {
280        (info.version.to_string(), info.description.to_string(), info.setup_command.map(|s| s.to_string()))
281    } else {
282        println!("{} Package '{}' tidak dikenali dalam registry RustBasic.", "⚠️".yellow().bold(), name.yellow());
283        println!("   Gunakan versi spesifik dengan menambahkan ke Cargo.toml secara manual.");
284        return;
285    };
286
287    // 3. Tambahkan ke Cargo.toml
288    println!("   {} Menambahkan ke Cargo.toml...", "📝".bold());
289    if !cargo_add_package(name, &version) {
290        return;
291    }
292
293    // 4. cargo build
294    if !run_cargo_build() {
295        // Rollback
296        cargo_remove_package(name);
297        return;
298    }
299
300    // 5. Jalankan setup function
301    if let Some(cmd) = &setup_cmd {
302        run_setup_command(name, cmd);
303    }
304
305    // 6. Catat di manifest
306    let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
307    manifest.packages.push(InstalledPackage {
308        name: name.to_string(),
309        version,
310        installed_at: now,
311        source: "install".to_string(),
312        description,
313    });
314    write_manifest(&manifest);
315
316    println!("\n{} Package '{}' berhasil diinstall!", "✅".green().bold(), name.cyan().bold());
317    println!("   Gunakan '{}' untuk melihat daftar package.", "rustbasic list packages".cyan());
318}
319
320/// Tampilkan daftar package yang terinstall
321pub fn list_packages() {
322    let mut manifest = read_manifest();
323    sync_manual_packages(&mut manifest);
324
325    println!("\n{}", "📦 RustBasic Package Manager".magenta().bold());
326    println!("{}", "═══════════════════════════════════════════════════════════════════════════".magenta());
327
328    if manifest.packages.is_empty() {
329        println!("{}", "   Belum ada package tambahan yang terinstall.".dimmed());
330        println!("   Gunakan '{}' untuk menginstall package.", "rustbasic install <nama-package>".cyan());
331    } else {
332        // Header tabel
333        println!(
334            "  {:<28} {:<10} {:<10} {:<22} {}",
335            "PACKAGE".bold(),
336            "VERSION".bold(),
337            "SOURCE".bold(),
338            "INSTALLED AT".bold(),
339            "DESCRIPTION".bold()
340        );
341        println!("{}", "  ─────────────────────────────────────────────────────────────────────".dimmed());
342
343        for pkg in &manifest.packages {
344            let source_display = match pkg.source.as_str() {
345                "manual" => "manual".yellow().to_string(),
346                _ => "install".green().to_string(),
347            };
348            let installed_at = if pkg.installed_at == "—" {
349                "—".dimmed().to_string()
350            } else {
351                pkg.installed_at.clone().dimmed().to_string()
352            };
353            println!(
354                "  {:<28} {:<10} {:<18} {:<22} {}",
355                pkg.name.cyan(),
356                pkg.version,
357                source_display,
358                installed_at,
359                pkg.description.dimmed()
360            );
361        }
362    }
363
364    println!("{}", "═══════════════════════════════════════════════════════════════════════════".magenta());
365    println!(
366        "  {} Total: {} package\n",
367        "📊".bold(),
368        manifest.packages.len().to_string().cyan().bold()
369    );
370}
371
372/// Uninstall sebuah package dari project
373pub fn uninstall_package(name: &str) {
374    println!("\n{} {}", "🗑️  Uninstalling:".red().bold(), name.cyan().bold());
375
376    let mut manifest = read_manifest();
377    let pkg_idx = manifest.packages.iter().position(|p| p.name == name);
378
379    // Cek jika tidak ada di manifest tapi ada di Cargo.toml
380    let in_cargo = cargo_has_package(name);
381    if pkg_idx.is_none() && !in_cargo {
382        println!("{} Package '{}' tidak ditemukan.", "❌".red().bold(), name.yellow());
383        return;
384    }
385
386    // 1. Jalankan remove function (cleanup file scaffolding)
387    if let Some(info) = known_packages(name)
388        && let Some(remove_cmd) = info.remove_command {
389        // Pastikan package ada di Cargo.toml sebelum menjalankan remove
390        if in_cargo {
391            run_setup_command(name, remove_cmd);
392        }
393    }
394
395    // 2. Hapus dari Cargo.toml
396    println!("   {} Menghapus dari Cargo.toml...", "📝".bold());
397    cargo_remove_package(name);
398
399    // 3. cargo build untuk update lock file
400    println!("   {} Memperbarui dependencies...", "📦".bold());
401    let _ = Command::new("cargo").args(["build", "-q"]).status();
402
403    // 4. Hapus dari manifest
404    if let Some(idx) = pkg_idx {
405        manifest.packages.remove(idx);
406        write_manifest(&manifest);
407    }
408
409    println!("\n{} Package '{}' berhasil diuninstall!", "✅".green().bold(), name.cyan().bold());
410}