Skip to main content

voirs_cli/packaging/
managers.rs

1use crate::error::VoirsCLIError;
2use anyhow::Result;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::{Path, PathBuf};
6use tracing::{debug, info, warn};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct PackageMetadata {
10    pub name: String,
11    pub version: String,
12    pub description: String,
13    pub license: String,
14    pub homepage: String,
15    pub repository: String,
16    pub author: String,
17    pub maintainer: String,
18    pub dependencies: Vec<String>,
19    pub binary_path: PathBuf,
20}
21
22impl Default for PackageMetadata {
23    fn default() -> Self {
24        Self {
25            name: "voirs".to_string(),
26            version: env!("CARGO_PKG_VERSION").to_string(),
27            description: "VoiRS Speech Synthesis CLI Tool".to_string(),
28            license: "MIT".to_string(),
29            homepage: "https://github.com/voirs-org/voirs".to_string(),
30            repository: "https://github.com/voirs-org/voirs".to_string(),
31            author: "VoiRS Team".to_string(),
32            maintainer: "VoiRS Team <voirs@example.com>".to_string(),
33            dependencies: vec![],
34            binary_path: PathBuf::from("target/release/voirs"),
35        }
36    }
37}
38
39pub trait PackageManager {
40    fn generate_package(&self, metadata: &PackageMetadata, output_dir: &Path) -> Result<PathBuf>;
41    fn validate_package(&self, package_path: &Path) -> Result<bool>;
42    fn get_package_name(&self) -> &str;
43    fn get_file_extension(&self) -> &str;
44}
45
46pub struct HomebrewManager {
47    formula_template: String,
48}
49
50impl Default for HomebrewManager {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl HomebrewManager {
57    pub fn new() -> Self {
58        Self {
59            formula_template: include_str!("templates/homebrew.rb").to_string(),
60        }
61    }
62}
63
64impl PackageManager for HomebrewManager {
65    fn generate_package(&self, metadata: &PackageMetadata, output_dir: &Path) -> Result<PathBuf> {
66        info!("Generating Homebrew formula");
67
68        let formula_content = self
69            .formula_template
70            .replace("{{NAME}}", &metadata.name)
71            .replace("{{VERSION}}", &metadata.version)
72            .replace("{{DESCRIPTION}}", &metadata.description)
73            .replace("{{HOMEPAGE}}", &metadata.homepage)
74            .replace("{{REPOSITORY}}", &metadata.repository)
75            .replace("{{LICENSE}}", &metadata.license);
76
77        let formula_path = output_dir.join(format!("{}.rb", metadata.name));
78        fs::write(&formula_path, formula_content)?;
79
80        info!("Homebrew formula generated at: {:?}", formula_path);
81        Ok(formula_path)
82    }
83
84    fn validate_package(&self, package_path: &Path) -> Result<bool> {
85        debug!("Validating Homebrew formula");
86
87        if !package_path.exists() {
88            return Ok(false);
89        }
90
91        let content = fs::read_to_string(package_path)?;
92        Ok(content.contains("class") && content.contains("Formula"))
93    }
94
95    fn get_package_name(&self) -> &str {
96        "homebrew"
97    }
98
99    fn get_file_extension(&self) -> &str {
100        "rb"
101    }
102}
103
104pub struct ChocolateyManager {
105    nuspec_template: String,
106    install_script_template: String,
107}
108
109impl Default for ChocolateyManager {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl ChocolateyManager {
116    pub fn new() -> Self {
117        Self {
118            nuspec_template: include_str!("templates/chocolatey.nuspec").to_string(),
119            install_script_template: include_str!("templates/chocolatey_install.ps1").to_string(),
120        }
121    }
122}
123
124impl PackageManager for ChocolateyManager {
125    fn generate_package(&self, metadata: &PackageMetadata, output_dir: &Path) -> Result<PathBuf> {
126        info!("Generating Chocolatey package");
127
128        let package_dir = output_dir.join(&metadata.name);
129        fs::create_dir_all(&package_dir)?;
130
131        // Generate nuspec file
132        let nuspec_content = self
133            .nuspec_template
134            .replace("{{NAME}}", &metadata.name)
135            .replace("{{VERSION}}", &metadata.version)
136            .replace("{{DESCRIPTION}}", &metadata.description)
137            .replace("{{AUTHOR}}", &metadata.author)
138            .replace("{{LICENSE}}", &metadata.license);
139
140        let nuspec_path = package_dir.join(format!("{}.nuspec", metadata.name));
141        fs::write(&nuspec_path, nuspec_content)?;
142
143        // Generate install script
144        let tools_dir = package_dir.join("tools");
145        fs::create_dir_all(&tools_dir)?;
146
147        let install_script_content = self.install_script_template.replace(
148            "{{BINARY_URL}}",
149            &format!(
150                "{}/releases/download/v{}/voirs-windows.exe",
151                metadata.repository, metadata.version
152            ),
153        );
154
155        let install_script_path = tools_dir.join("chocolateyinstall.ps1");
156        fs::write(&install_script_path, install_script_content)?;
157
158        info!("Chocolatey package generated at: {:?}", package_dir);
159        Ok(package_dir)
160    }
161
162    fn validate_package(&self, package_path: &Path) -> Result<bool> {
163        debug!("Validating Chocolatey package");
164
165        let nuspec_path = package_path.join("*.nuspec");
166        let tools_dir = package_path.join("tools");
167
168        Ok(tools_dir.exists()
169            && fs::read_dir(package_path)?.any(|entry| {
170                entry.ok().is_some_and(|e| {
171                    e.path().extension().and_then(|ext| ext.to_str()) == Some("nuspec")
172                })
173            }))
174    }
175
176    fn get_package_name(&self) -> &str {
177        "chocolatey"
178    }
179
180    fn get_file_extension(&self) -> &str {
181        "nupkg"
182    }
183}
184
185pub struct ScoopManager {
186    manifest_template: String,
187}
188
189impl Default for ScoopManager {
190    fn default() -> Self {
191        Self::new()
192    }
193}
194
195impl ScoopManager {
196    pub fn new() -> Self {
197        Self {
198            manifest_template: include_str!("templates/scoop.json").to_string(),
199        }
200    }
201}
202
203impl PackageManager for ScoopManager {
204    fn generate_package(&self, metadata: &PackageMetadata, output_dir: &Path) -> Result<PathBuf> {
205        info!("Generating Scoop manifest");
206
207        let manifest_content = self
208            .manifest_template
209            .replace("{{NAME}}", &metadata.name)
210            .replace("{{VERSION}}", &metadata.version)
211            .replace("{{DESCRIPTION}}", &metadata.description)
212            .replace("{{HOMEPAGE}}", &metadata.homepage)
213            .replace("{{LICENSE}}", &metadata.license)
214            .replace("{{REPOSITORY}}", &metadata.repository);
215
216        let manifest_path = output_dir.join(format!("{}.json", metadata.name));
217        fs::write(&manifest_path, manifest_content)?;
218
219        info!("Scoop manifest generated at: {:?}", manifest_path);
220        Ok(manifest_path)
221    }
222
223    fn validate_package(&self, package_path: &Path) -> Result<bool> {
224        debug!("Validating Scoop manifest");
225
226        if !package_path.exists() {
227            return Ok(false);
228        }
229
230        let content = fs::read_to_string(package_path)?;
231        let json: serde_json::Value = serde_json::from_str(&content)?;
232
233        Ok(json.get("version").is_some() && json.get("url").is_some())
234    }
235
236    fn get_package_name(&self) -> &str {
237        "scoop"
238    }
239
240    fn get_file_extension(&self) -> &str {
241        "json"
242    }
243}
244
245pub struct DebianManager {
246    control_template: String,
247}
248
249impl Default for DebianManager {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255impl DebianManager {
256    pub fn new() -> Self {
257        Self {
258            control_template: include_str!("templates/debian_control").to_string(),
259        }
260    }
261}
262
263impl PackageManager for DebianManager {
264    fn generate_package(&self, metadata: &PackageMetadata, output_dir: &Path) -> Result<PathBuf> {
265        info!("Generating Debian package");
266
267        let package_dir = output_dir.join(format!("{}-{}", metadata.name, metadata.version));
268        let debian_dir = package_dir.join("DEBIAN");
269        fs::create_dir_all(&debian_dir)?;
270
271        // Generate control file
272        let control_content = self
273            .control_template
274            .replace("{{NAME}}", &metadata.name)
275            .replace("{{VERSION}}", &metadata.version)
276            .replace("{{DESCRIPTION}}", &metadata.description)
277            .replace("{{MAINTAINER}}", &metadata.maintainer)
278            .replace("{{DEPENDENCIES}}", &metadata.dependencies.join(", "));
279
280        let control_path = debian_dir.join("control");
281        fs::write(&control_path, control_content)?;
282
283        // Copy binary
284        let bin_dir = package_dir.join("usr/bin");
285        fs::create_dir_all(&bin_dir)?;
286
287        if metadata.binary_path.exists() {
288            let dest_binary = bin_dir.join(&metadata.name);
289            fs::copy(&metadata.binary_path, &dest_binary)?;
290
291            // Make binary executable
292            #[cfg(unix)]
293            {
294                use std::os::unix::fs::PermissionsExt;
295                let mut perms = fs::metadata(&dest_binary)?.permissions();
296                perms.set_mode(0o755);
297                fs::set_permissions(&dest_binary, perms)?;
298            }
299        }
300
301        info!("Debian package structure generated at: {:?}", package_dir);
302        Ok(package_dir)
303    }
304
305    fn validate_package(&self, package_path: &Path) -> Result<bool> {
306        debug!("Validating Debian package");
307
308        let debian_dir = package_path.join("DEBIAN");
309        let control_file = debian_dir.join("control");
310
311        Ok(debian_dir.exists() && control_file.exists())
312    }
313
314    fn get_package_name(&self) -> &str {
315        "debian"
316    }
317
318    fn get_file_extension(&self) -> &str {
319        "deb"
320    }
321}
322
323pub struct PackageManagerFactory;
324
325impl PackageManagerFactory {
326    pub fn create_manager(manager_type: &str) -> Result<Box<dyn PackageManager>> {
327        match manager_type.to_lowercase().as_str() {
328            "homebrew" => Ok(Box::new(HomebrewManager::new())),
329            "chocolatey" => Ok(Box::new(ChocolateyManager::new())),
330            "scoop" => Ok(Box::new(ScoopManager::new())),
331            "debian" | "apt" => Ok(Box::new(DebianManager::new())),
332            _ => Err(VoirsCLIError::PackagingError(format!(
333                "Unsupported package manager: {}",
334                manager_type
335            ))
336            .into()),
337        }
338    }
339
340    pub fn get_supported_managers() -> Vec<&'static str> {
341        vec!["homebrew", "chocolatey", "scoop", "debian"]
342    }
343}
344
345pub fn generate_all_packages(
346    metadata: &PackageMetadata,
347    output_dir: &Path,
348) -> Result<Vec<PathBuf>> {
349    info!("Generating packages for all supported package managers");
350
351    let mut package_paths = Vec::new();
352
353    for manager_type in PackageManagerFactory::get_supported_managers() {
354        match PackageManagerFactory::create_manager(manager_type) {
355            Ok(manager) => {
356                let manager_output_dir = output_dir.join(manager_type);
357                fs::create_dir_all(&manager_output_dir)?;
358
359                match manager.generate_package(metadata, &manager_output_dir) {
360                    Ok(package_path) => {
361                        package_paths.push(package_path);
362                        info!("Successfully generated {} package", manager_type);
363                    }
364                    Err(e) => {
365                        warn!("Failed to generate {} package: {}", manager_type, e);
366                    }
367                }
368            }
369            Err(e) => {
370                warn!("Failed to create {} manager: {}", manager_type, e);
371            }
372        }
373    }
374
375    info!("Generated {} packages", package_paths.len());
376    Ok(package_paths)
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use tempfile::TempDir;
383
384    #[test]
385    fn test_package_metadata_default() {
386        let metadata = PackageMetadata::default();
387        assert_eq!(metadata.name, "voirs");
388        assert!(!metadata.version.is_empty());
389        assert!(!metadata.description.is_empty());
390    }
391
392    #[test]
393    fn test_package_manager_factory() {
394        let homebrew = PackageManagerFactory::create_manager("homebrew");
395        assert!(homebrew.is_ok());
396
397        let chocolatey = PackageManagerFactory::create_manager("chocolatey");
398        assert!(chocolatey.is_ok());
399
400        let invalid = PackageManagerFactory::create_manager("invalid");
401        assert!(invalid.is_err());
402    }
403
404    #[test]
405    fn test_supported_managers() {
406        let managers = PackageManagerFactory::get_supported_managers();
407        assert!(managers.contains(&"homebrew"));
408        assert!(managers.contains(&"chocolatey"));
409        assert!(managers.contains(&"scoop"));
410        assert!(managers.contains(&"debian"));
411    }
412
413    #[test]
414    fn test_homebrew_manager_properties() {
415        let manager = HomebrewManager::new();
416        assert_eq!(manager.get_package_name(), "homebrew");
417        assert_eq!(manager.get_file_extension(), "rb");
418    }
419
420    #[test]
421    fn test_chocolatey_manager_properties() {
422        let manager = ChocolateyManager::new();
423        assert_eq!(manager.get_package_name(), "chocolatey");
424        assert_eq!(manager.get_file_extension(), "nupkg");
425    }
426}