1use crate::core::service::ServiceError;
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::fs;
8use std::io::{Read, Write};
9use std::path::{Path, PathBuf};
10use zip::write::{FileOptions, ZipWriter};
11use zip::CompressionMethod;
12
13pub fn package_skill(
15 skill_path: &Path,
16 output_dir: &Path,
17 version: &str,
18) -> Result<PathBuf, ServiceError> {
19 package_skill_with_id(skill_path, output_dir, version, None)
20}
21
22pub fn package_skill_with_id(
26 skill_path: &Path,
27 output_dir: &Path,
28 version: &str,
29 skill_id_override: Option<&str>,
30) -> Result<PathBuf, ServiceError> {
31 if !skill_path.exists() {
33 return Err(ServiceError::Custom(format!(
34 "Skill directory does not exist: {}",
35 skill_path.display()
36 )));
37 }
38
39 if !skill_path.is_dir() {
40 return Err(ServiceError::Custom(format!(
41 "Path is not a directory: {}",
42 skill_path.display()
43 )));
44 }
45
46 if !skill_path.join("SKILL.md").exists() {
48 return Err(ServiceError::Custom(format!(
49 "SKILL.md not found in skill directory: {}",
50 skill_path.display()
51 )));
52 }
53
54 use crate::validation::standard_validator::StandardValidator;
56 let validation_result = StandardValidator::validate_skill_directory(skill_path)?;
57 if !validation_result.is_valid {
58 let error_messages: Vec<String> = validation_result.errors.iter().map(|e| {
59 match e {
60 crate::validation::standard_validator::ValidationError::InvalidNameFormat(msg) =>
61 format!("✗ Name format invalid: {}", msg),
62 crate::validation::standard_validator::ValidationError::NameMismatch { expected, actual } =>
63 format!("✗ Name mismatch: Directory '{}' doesn't match skill name '{}'", actual, expected),
64 crate::validation::standard_validator::ValidationError::InvalidDescriptionLength(len) =>
65 format!("✗ Description length invalid: {} characters (must be 1-1024)", len),
66 crate::validation::standard_validator::ValidationError::InvalidCompatibilityLength(len) =>
67 format!("✗ Compatibility field too long: {} characters (max 500)", len),
68 crate::validation::standard_validator::ValidationError::MissingRequiredField(field) =>
69 format!("✗ Missing required field: {}", field),
70 crate::validation::standard_validator::ValidationError::InvalidFileReference(msg) =>
71 format!("✗ Invalid file reference: {}", msg),
72 crate::validation::standard_validator::ValidationError::InvalidDirectoryStructure(msg) =>
73 format!("✗ Invalid directory structure: {}", msg),
74 crate::validation::standard_validator::ValidationError::YamlParseError(msg) =>
75 format!("✗ YAML parsing error: {}", msg),
76 }
77 }).collect();
78
79 return Err(ServiceError::Validation(error_messages.join("\n")));
80 }
81
82 let skill_project_toml_path = skill_path.join("skill-project.toml");
84 let (skill_id, package_version) = if skill_project_toml_path.exists() {
85 match crate::core::manifest::SkillProjectToml::load_from_file(&skill_project_toml_path) {
86 Ok(project) => {
87 let id = project
89 .metadata
90 .as_ref()
91 .and_then(|m| m.id.as_ref())
92 .cloned();
93 let ver = project
94 .metadata
95 .as_ref()
96 .and_then(|m| m.version.as_ref())
97 .cloned();
98 (id, ver)
100 }
101 Err(_) => {
102 (None, None)
104 }
105 }
106 } else {
107 (None, None)
108 };
109
110 let skill_id = if let Some(id) = skill_id_override {
112 id.to_string()
113 } else if let Some(id) = skill_id {
114 id
115 } else {
116 skill_path
117 .file_name()
118 .and_then(|n| n.to_str())
119 .ok_or_else(|| {
120 ServiceError::Custom("Cannot determine skill ID from directory name".to_string())
121 })?
122 .to_string()
123 };
124
125 let package_version = package_version.unwrap_or_else(|| version.to_string());
127
128 fs::create_dir_all(output_dir).map_err(ServiceError::Io)?;
130
131 let zip_filename = format!("{}-{}.zip", skill_id, package_version);
133 let zip_path = output_dir.join(&zip_filename);
134
135 let file = fs::File::create(&zip_path).map_err(ServiceError::Io)?;
137
138 let mut zip = ZipWriter::new(file);
139 let options = FileOptions::default()
140 .compression_method(CompressionMethod::Deflated)
141 .unix_permissions(0o755);
142
143 let entries = walkdir::WalkDir::new(skill_path)
145 .into_iter()
146 .filter_map(|e| e.ok())
147 .filter(|e| e.file_type().is_file());
148
149 for entry in entries {
150 let file_path = entry.path();
151 let relative_path = file_path
152 .strip_prefix(skill_path)
153 .map_err(|e| ServiceError::Custom(format!("Failed to get relative path: {}", e)))?;
154
155 let relative_path_str = relative_path.to_string_lossy();
156
157 if relative_path_str.contains(".git/") {
159 continue;
160 }
161
162 if relative_path_str.starts_with("evals/") || relative_path_str == "evals" {
164 continue;
165 }
166
167 let mut file_content = Vec::new();
169 let mut file = fs::File::open(file_path).map_err(ServiceError::Io)?;
170 file.read_to_end(&mut file_content)
171 .map_err(ServiceError::Io)?;
172
173 zip.start_file(relative_path_str.as_ref(), options)
175 .map_err(|e| ServiceError::Custom(format!("Failed to add file to ZIP: {}", e)))?;
176 zip.write_all(&file_content).map_err(ServiceError::Io)?;
177 }
178
179 let git_commit = get_git_commit().ok();
181
182 let build_metadata = create_build_metadata(&skill_id, &package_version, git_commit.as_deref());
184
185 let build_info_json = serde_json::to_string_pretty(&build_metadata)
187 .map_err(|e| ServiceError::Custom(format!("Failed to serialize build metadata: {}", e)))?;
188
189 zip.start_file("BUILD_INFO.json", options)
190 .map_err(|e| ServiceError::Custom(format!("Failed to add BUILD_INFO.json: {}", e)))?;
191 zip.write_all(build_info_json.as_bytes())
192 .map_err(ServiceError::Io)?;
193
194 zip.finish()
196 .map_err(|e| ServiceError::Custom(format!("Failed to finalize ZIP: {}", e)))?;
197
198 let checksum = calculate_checksum(&zip_path)?;
201
202 fs::remove_file(&zip_path).map_err(ServiceError::Io)?;
205
206 let file = fs::File::create(&zip_path).map_err(ServiceError::Io)?;
208
209 let mut zip = ZipWriter::new(file);
210
211 let entries = walkdir::WalkDir::new(skill_path)
213 .into_iter()
214 .filter_map(|e| e.ok())
215 .filter(|e| e.file_type().is_file());
216
217 for entry in entries {
218 let file_path = entry.path();
219 let relative_path = file_path
220 .strip_prefix(skill_path)
221 .map_err(|e| ServiceError::Custom(format!("Failed to get relative path: {}", e)))?;
222
223 let relative_path_str = relative_path.to_string_lossy();
224
225 if relative_path_str.contains(".git/") {
226 continue;
227 }
228
229 if relative_path_str.starts_with("evals/") || relative_path_str == "evals" {
231 continue;
232 }
233
234 let mut file_content = Vec::new();
235 let mut file = fs::File::open(file_path).map_err(ServiceError::Io)?;
236 file.read_to_end(&mut file_content)
237 .map_err(ServiceError::Io)?;
238
239 zip.start_file(relative_path_str.as_ref(), options)
240 .map_err(|e| ServiceError::Custom(format!("Failed to add file to ZIP: {}", e)))?;
241 zip.write_all(&file_content).map_err(ServiceError::Io)?;
242 }
243
244 let build_info_json = serde_json::to_string_pretty(&build_metadata)
248 .map_err(|e| ServiceError::Custom(format!("Failed to serialize build metadata: {}", e)))?;
249
250 zip.start_file("BUILD_INFO.json", options)
251 .map_err(|e| ServiceError::Custom(format!("Failed to add BUILD_INFO.json: {}", e)))?;
252 zip.write_all(build_info_json.as_bytes())
253 .map_err(ServiceError::Io)?;
254
255 zip.start_file("CHECKSUM.sha256", options)
257 .map_err(|e| ServiceError::Custom(format!("Failed to add CHECKSUM.sha256: {}", e)))?;
258 zip.write_all(checksum.as_bytes())
259 .map_err(ServiceError::Io)?;
260
261 zip.finish().map_err(|e| {
262 ServiceError::Custom(format!("Failed to finalize ZIP with checksum: {}", e))
263 })?;
264
265 Ok(zip_path)
266}
267
268pub fn create_build_metadata(
270 skill_id: &str,
271 version: &str,
272 git_commit: Option<&str>,
273) -> BuildMetadata {
274 BuildMetadata {
275 skill_id: skill_id.to_string(),
276 version: version.to_string(),
277 build_timestamp: Utc::now().to_rfc3339(),
278 git_commit: git_commit.map(|s| s.to_string()),
279 git_branch: get_git_branch().ok(),
280 build_environment: BuildEnvironment {
281 fastskill_version: env!("CARGO_PKG_VERSION").to_string(),
282 rust_version: get_rust_version(),
283 },
284 }
285}
286
287pub fn calculate_checksum(file_path: &Path) -> Result<String, ServiceError> {
289 let mut file = fs::File::open(file_path).map_err(ServiceError::Io)?;
290
291 let mut hasher = Sha256::new();
292 let mut buffer = [0; 8192];
293
294 loop {
295 let bytes_read = file.read(&mut buffer).map_err(ServiceError::Io)?;
296
297 if bytes_read == 0 {
298 break;
299 }
300
301 hasher.update(&buffer[..bytes_read]);
302 }
303
304 let hash = format!("sha256:{:x}", hasher.finalize());
305 Ok(hash)
306}
307
308fn get_git_commit() -> Result<String, ServiceError> {
310 use std::process::Command;
311
312 let output = Command::new("git")
313 .args(["rev-parse", "HEAD"])
314 .output()
315 .map_err(|e| ServiceError::Custom(format!("Failed to execute git: {}", e)))?;
316
317 if !output.status.success() {
318 return Err(ServiceError::Custom(
319 "Failed to get git commit hash".to_string(),
320 ));
321 }
322
323 let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
324 Ok(commit_hash)
325}
326
327fn get_git_branch() -> Result<String, ServiceError> {
329 use std::process::Command;
330
331 let output = Command::new("git")
332 .args(["rev-parse", "--abbrev-ref", "HEAD"])
333 .output()
334 .map_err(|e| ServiceError::Custom(format!("Failed to execute git: {}", e)))?;
335
336 if !output.status.success() {
337 return Err(ServiceError::Custom(
338 "Failed to get git branch name".to_string(),
339 ));
340 }
341
342 let branch_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
343 Ok(branch_name)
344}
345
346fn get_rust_version() -> String {
348 std::env::var("RUSTC_VERSION").unwrap_or_else(|_| "unknown".to_string())
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct BuildMetadata {
355 pub skill_id: String,
356 pub version: String,
357 pub build_timestamp: String,
358 #[serde(skip_serializing_if = "Option::is_none")]
359 pub git_commit: Option<String>,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub git_branch: Option<String>,
362 pub build_environment: BuildEnvironment,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct BuildEnvironment {
367 pub fastskill_version: String,
368 pub rust_version: String,
369}