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 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 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 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 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 #[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}