Skip to main content

fastskill_core/core/
packaging.rs

1//! Packaging skills into ZIP artifacts with metadata
2
3use 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
13/// Package a skill directory into a ZIP file
14pub 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
22/// Package a skill directory into a ZIP file with explicit skill ID.
23///
24/// The `evals/` subtree is excluded from the archive (local eval suites are not published).
25pub 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    // Validate skill directory
32    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    // Check for SKILL.md
47    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    // Validate skill against AI Skill standard
55    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    // Try to read metadata from skill-project.toml if it exists (T042, T043)
83    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                // Extract skill ID and version from metadata
88                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                // Dependencies are available in project.dependencies if needed (T043, T044)
99                (id, ver)
100            }
101            Err(_) => {
102                // If parsing fails, fall back to directory name and passed version
103                (None, None)
104            }
105        }
106    } else {
107        (None, None)
108    };
109
110    // Get skill ID - priority: override > skill-project.toml > directory name
111    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    // Get version - priority: skill-project.toml > passed version parameter
126    let package_version = package_version.unwrap_or_else(|| version.to_string());
127
128    // Create output directory if it doesn't exist
129    fs::create_dir_all(output_dir).map_err(ServiceError::Io)?;
130
131    // Create ZIP file path (skill_id should not contain slashes)
132    let zip_filename = format!("{}-{}.zip", skill_id, package_version);
133    let zip_path = output_dir.join(&zip_filename);
134
135    // Create ZIP file
136    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    // Walk through skill directory and add files
144    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        // Skip .git files
158        if relative_path_str.contains(".git/") {
159            continue;
160        }
161
162        // Skip evals/ directory from published artifacts
163        if relative_path_str.starts_with("evals/") || relative_path_str == "evals" {
164            continue;
165        }
166
167        // Read file content
168        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        // Add file to ZIP
174        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    // Get git commit if available
180    let git_commit = get_git_commit().ok();
181
182    // Create build metadata (use skill_id and package_version from skill-project.toml if available)
183    let build_metadata = create_build_metadata(&skill_id, &package_version, git_commit.as_deref());
184
185    // Add BUILD_INFO.json to ZIP
186    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    // Finish ZIP first (without checksum) to calculate checksum
195    zip.finish()
196        .map_err(|e| ServiceError::Custom(format!("Failed to finalize ZIP: {}", e)))?;
197
198    // Calculate checksum of the ZIP file (before adding checksum file)
199    // This checksum represents the ZIP contents without the checksum file itself
200    let checksum = calculate_checksum(&zip_path)?;
201
202    // Now recreate the ZIP with checksum included
203    // Remove the old ZIP
204    fs::remove_file(&zip_path).map_err(ServiceError::Io)?;
205
206    // Create new ZIP with checksum
207    let file = fs::File::create(&zip_path).map_err(ServiceError::Io)?;
208
209    let mut zip = ZipWriter::new(file);
210
211    // Re-add all files from skill directory
212    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        // Skip evals/ directory from published artifacts
230        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    // Add BUILD_INFO.json (with checksum included in metadata)
245    // Note: checksum is stored separately in CHECKSUM.sha256 file
246
247    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    // Add CHECKSUM.sha256 (checksum of ZIP before this file was added)
256    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
268/// Create build metadata for a skill
269pub 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
287/// Calculate SHA256 checksum of a file
288pub 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
308/// Get git commit hash
309fn 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
327/// Get git branch name
328fn 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
346/// Get Rust version
347fn get_rust_version() -> String {
348    // Try to get from environment or use a default
349    std::env::var("RUSTC_VERSION").unwrap_or_else(|_| "unknown".to_string())
350}
351
352/// Build metadata structure
353#[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}