Skip to main content

fastskill_core/core/
manifest.rs

1//! Skills manifest management for declarative skill control
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7/// Main skills manifest structure
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SkillsManifest {
10    pub metadata: ManifestMetadata,
11    #[serde(default)]
12    pub skills: Vec<SkillEntry>,
13}
14
15/// Manifest metadata
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ManifestMetadata {
18    pub version: String,
19}
20
21/// Skill entry in the manifest
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct SkillEntry {
24    pub id: String,
25    pub source: SkillSource,
26    pub version: String,
27    #[serde(default)]
28    pub groups: Vec<String>,
29    #[serde(default)]
30    pub editable: bool,
31}
32
33/// Source specification for skills
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35#[serde(tag = "type")]
36pub enum SkillSource {
37    #[serde(rename = "git")]
38    Git {
39        url: String,
40        #[serde(default)]
41        branch: Option<String>,
42        #[serde(default)]
43        tag: Option<String>,
44        #[serde(default)]
45        subdir: Option<PathBuf>,
46    },
47    #[serde(rename = "source")]
48    Source {
49        name: String,
50        skill: String,
51        #[serde(default)]
52        version: Option<String>,
53    },
54    #[serde(rename = "local")]
55    Local {
56        path: PathBuf,
57        #[serde(default)]
58        editable: bool,
59    },
60    #[serde(rename = "zip-url")]
61    ZipUrl {
62        base_url: String,
63        #[serde(default)]
64        version: Option<String>,
65    },
66}
67
68impl SkillsManifest {
69    /// Load manifest from TOML file
70    pub fn load_from_file(path: &Path) -> Result<Self, ManifestError> {
71        if !path.exists() {
72            return Err(ManifestError::NotFound(path.to_path_buf()));
73        }
74
75        let content = std::fs::read_to_string(path).map_err(ManifestError::Io)?;
76
77        let manifest: SkillsManifest =
78            toml::from_str(&content).map_err(|e| ManifestError::Parse(e.to_string()))?;
79
80        Ok(manifest)
81    }
82
83    /// Save manifest to TOML file
84    pub fn save_to_file(&self, path: &Path) -> Result<(), ManifestError> {
85        let content =
86            toml::to_string_pretty(self).map_err(|e| ManifestError::Serialize(e.to_string()))?;
87
88        std::fs::write(path, content).map_err(ManifestError::Io)?;
89
90        Ok(())
91    }
92
93    /// Get skills filtered by groups (like Poetry groups)
94    pub fn get_skills_for_groups(
95        &self,
96        exclude_groups: Option<&[String]>,
97        only_groups: Option<&[String]>,
98    ) -> Vec<&SkillEntry> {
99        self.skills
100            .iter()
101            .filter(|skill| {
102                // If only_groups specified, skill must be in one of those groups
103                if let Some(only) = only_groups {
104                    if skill.groups.is_empty() && !only.is_empty() {
105                        return false;
106                    }
107                    if !skill.groups.is_empty() {
108                        return skill.groups.iter().any(|g| only.contains(g));
109                    }
110                }
111
112                // If exclude_groups specified, skill must not be in any excluded group
113                if let Some(exclude) = exclude_groups {
114                    return !skill.groups.iter().any(|g| exclude.contains(g));
115                }
116
117                true
118            })
119            .collect()
120    }
121
122    /// Get all skills (no filtering)
123    pub fn get_all_skills(&self) -> Vec<&SkillEntry> {
124        self.skills.iter().collect()
125    }
126
127    /// Add a skill to the manifest
128    pub fn add_skill(&mut self, skill: SkillEntry) {
129        self.skills.push(skill);
130    }
131
132    /// Remove a skill from the manifest
133    pub fn remove_skill(&mut self, skill_id: &str) -> bool {
134        if let Some(pos) = self.skills.iter().position(|s| s.id == skill_id) {
135            self.skills.remove(pos);
136            return true;
137        }
138        false
139    }
140}
141
142// ============================================================================
143// Skill Project TOML structures (skill-project.toml format)
144// ============================================================================
145
146/// Root structure for skill-project.toml file
147/// Contains both project metadata and dependencies
148/// Works in both project-level (skill consumer) and skill-level (skill author) contexts
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SkillProjectToml {
151    /// Optional metadata section (required for skill-level, optional for project-level)
152    #[serde(default)]
153    pub metadata: Option<MetadataSection>,
154    /// Optional dependencies section (required for project-level, optional for skill-level)
155    #[serde(default)]
156    pub dependencies: Option<DependenciesSection>,
157    /// Optional tool configuration (project-level only)
158    #[serde(default)]
159    #[serde(rename = "tool")]
160    pub tool: Option<ToolSection>,
161}
162
163/// Metadata section for skill or project metadata
164/// Contains skill author information for skill-level, project documentation for project-level
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct MetadataSection {
167    /// Required for skill-level, optional for project-level
168    pub id: Option<String>,
169    /// Required for skill-level, optional for project-level
170    pub version: Option<String>,
171    /// Optional: Description
172    #[serde(default)]
173    pub description: Option<String>,
174    /// Optional: Author name
175    #[serde(default)]
176    pub author: Option<String>,
177    /// Optional: Download URL
178    #[serde(default)]
179    pub download_url: Option<String>,
180    /// Optional: Project name (project-level only)
181    #[serde(default)]
182    pub name: Option<String>,
183}
184
185/// Dependencies section containing skill dependencies
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct DependenciesSection {
188    /// Map of skill ID to dependency specification
189    #[serde(flatten)]
190    pub dependencies: HashMap<String, DependencySpec>,
191}
192
193/// Dependency specification - can be a simple version string or inline table with source details
194#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(untagged)]
196pub enum DependencySpec {
197    /// Simple version string: "1.0.0"
198    Version(String),
199    /// Inline table with source details
200    Inline {
201        source: DependencySource,
202        #[serde(flatten)]
203        source_specific: SourceSpecificFields,
204        #[serde(default)]
205        groups: Option<Vec<String>>,
206        #[serde(default)]
207        editable: Option<bool>,
208    },
209}
210
211/// Dependency source type
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub enum DependencySource {
214    #[serde(rename = "git")]
215    Git,
216    #[serde(rename = "local")]
217    Local,
218    #[serde(rename = "zip-url")]
219    ZipUrl,
220    #[serde(rename = "source")]
221    Source,
222}
223
224/// Source-specific fields for dependency specifications
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct SourceSpecificFields {
227    /// For git source
228    #[serde(default)]
229    pub url: Option<String>,
230    #[serde(default)]
231    pub branch: Option<String>,
232    /// For local source
233    #[serde(default)]
234    pub path: Option<String>,
235    /// For source source
236    #[serde(default)]
237    pub name: Option<String>,
238    #[serde(default)]
239    pub skill: Option<String>,
240    /// For zip-url source
241    #[serde(default)]
242    pub zip_url: Option<String>,
243    /// Version (for source source)
244    #[serde(default)]
245    pub version: Option<String>,
246}
247
248/// Tool section containing tool-specific configuration
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ToolSection {
251    #[serde(default)]
252    pub fastskill: Option<FastSkillToolConfig>,
253}
254
255/// FastSkill tool configuration
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct FastSkillToolConfig {
258    /// Optional skills storage directory override
259    #[serde(default)]
260    pub skills_directory: Option<PathBuf>,
261    /// Optional embedding configuration
262    #[serde(default)]
263    pub embedding: Option<EmbeddingConfigToml>,
264    /// Optional repository configuration
265    #[serde(default)]
266    pub repositories: Option<Vec<RepositoryDefinition>>,
267    /// Optional HTTP server configuration
268    #[serde(default)]
269    pub server: Option<HttpServerConfigToml>,
270    /// Maximum dependency depth for recursive install (default: 5)
271    #[serde(default = "default_install_depth")]
272    pub install_depth: u32,
273    /// Skip transitive dependency resolution entirely (default: false)
274    #[serde(default)]
275    pub skip_transitive: bool,
276    /// Optional evaluation configuration
277    #[serde(default)]
278    pub eval: Option<EvalConfigToml>,
279}
280
281/// Evaluation configuration in TOML format ([tool.fastskill.eval])
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct EvalConfigToml {
284    /// Path to prompts CSV file (relative to skill project root)
285    pub prompts: PathBuf,
286    /// Optional path to checks TOML file
287    #[serde(default)]
288    pub checks: Option<PathBuf>,
289    /// Timeout in seconds for each eval case execution
290    #[serde(default = "default_eval_timeout_seconds")]
291    pub timeout_seconds: u64,
292    /// Trials per case (default: 1)
293    #[serde(default = "default_trials_per_case")]
294    pub trials_per_case: u32,
295    /// Optional maximum parallelism for trials within one case (default: CPU cores)
296    #[serde(default)]
297    pub parallel: Option<u32>,
298    /// Pass threshold for trial aggregation (0.0-1.0, default: 1.0)
299    #[serde(default = "default_pass_threshold")]
300    pub pass_threshold: f64,
301    /// When true, `eval run` / `eval validate --agent` fail fast if the agent CLI is not available
302    #[serde(default = "default_fail_on_missing_agent")]
303    pub fail_on_missing_agent: bool,
304}
305
306fn default_eval_timeout_seconds() -> u64 {
307    900
308}
309
310fn default_trials_per_case() -> u32 {
311    1
312}
313
314fn default_pass_threshold() -> f64 {
315    1.0
316}
317
318fn default_fail_on_missing_agent() -> bool {
319    true
320}
321
322fn default_install_depth() -> u32 {
323    5
324}
325
326/// HTTP server configuration in TOML format
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct HttpServerConfigToml {
329    /// List of origins allowed for CORS (required when server is used)
330    #[serde(default)]
331    pub allowed_origins: Vec<String>,
332    /// Optional: allow list of request headers (default: ["Content-Type", "Authorization"])
333    #[serde(default = "default_allowed_headers_toml")]
334    pub allowed_headers: Vec<String>,
335}
336
337fn default_allowed_headers_toml() -> Vec<String> {
338    vec!["Content-Type".to_string(), "Authorization".to_string()]
339}
340
341/// Embedding configuration in TOML format
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct EmbeddingConfigToml {
344    pub openai_base_url: String,
345    pub embedding_model: String,
346    #[serde(default)]
347    pub index_path: Option<PathBuf>,
348}
349
350/// Repository definition with name, type, priority, authentication, and connection details
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct RepositoryDefinition {
353    /// Repository name (unique identifier)
354    pub name: String,
355    /// Repository type
356    pub r#type: RepositoryType,
357    /// Priority (lower number = higher priority)
358    pub priority: u32,
359    /// Connection details (type-specific)
360    #[serde(flatten)]
361    pub connection: RepositoryConnection,
362    /// Authentication configuration
363    #[serde(default)]
364    pub auth: Option<AuthConfig>,
365}
366
367/// Repository type
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub enum RepositoryType {
370    #[serde(rename = "http-registry")]
371    HttpRegistry,
372    #[serde(rename = "git-marketplace")]
373    GitMarketplace,
374    #[serde(rename = "zip-url")]
375    ZipUrl,
376    #[serde(rename = "local")]
377    Local,
378}
379
380/// Repository connection details (type-specific)
381#[derive(Debug, Clone, Serialize, Deserialize)]
382#[serde(untagged)]
383pub enum RepositoryConnection {
384    HttpRegistry {
385        index_url: String,
386    },
387    GitMarketplace {
388        url: String,
389        #[serde(default)]
390        branch: Option<String>,
391    },
392    ZipUrl {
393        zip_url: String,
394    },
395    Local {
396        path: String,
397    },
398}
399
400/// Authentication configuration
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct AuthConfig {
403    pub r#type: AuthType,
404    #[serde(default)]
405    pub env_var: Option<String>,
406}
407
408/// Authentication type
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub enum AuthType {
411    #[serde(rename = "pat")]
412    Pat,
413}
414
415/// Project context enum for context detection
416#[derive(Debug, Clone, PartialEq, Eq)]
417pub enum ProjectContext {
418    /// Project-level context (skill consumer)
419    Project,
420    /// Skill-level context (skill author)
421    Skill,
422    /// Ambiguous context (requires content-based detection)
423    Ambiguous,
424}
425
426/// File resolution result
427#[derive(Debug, Clone)]
428pub struct FileResolutionResult {
429    /// Resolved file path
430    pub path: PathBuf,
431    /// Context detected for the file
432    pub context: ProjectContext,
433    /// Whether file was found or created
434    pub found: bool,
435}
436
437impl SkillProjectToml {
438    /// Load skill-project.toml from file
439    pub fn load_from_file(path: &Path) -> Result<Self, ManifestError> {
440        if !path.exists() {
441            return Err(ManifestError::NotFound(path.to_path_buf()));
442        }
443
444        // Canonicalize path to prevent traversal attacks
445        let safe_path = path.canonicalize().map_err(ManifestError::Io)?;
446
447        let content = std::fs::read_to_string(&safe_path).map_err(ManifestError::Io)?;
448
449        let project: SkillProjectToml = toml::from_str(&content).map_err(|e| {
450            // T066: Enhanced TOML error message with line numbers
451            let error_msg = e.to_string();
452            // Extract line number if available
453            let line_info = if let Some(line_start) = error_msg.find("line ") {
454                let after_line = &error_msg[line_start + 5..];
455                let line_end = after_line
456                    .find(|c: char| !c.is_ascii_digit() && c != ',')
457                    .unwrap_or(after_line.len());
458                if let Ok(line) = after_line[..line_end].parse::<usize>() {
459                    format!("line {}", line)
460                } else {
461                    String::new()
462                }
463            } else {
464                String::new()
465            };
466
467            if !line_info.is_empty() {
468                ManifestError::Parse(format!("TOML syntax error at {}: {}", line_info, error_msg))
469            } else {
470                ManifestError::Parse(format!("TOML syntax error: {}", error_msg))
471            }
472        })?;
473
474        Ok(project)
475    }
476
477    /// Save skill-project.toml to file
478    pub fn save_to_file(&self, path: &Path) -> Result<(), ManifestError> {
479        let content =
480            toml::to_string_pretty(self).map_err(|e| ManifestError::Serialize(e.to_string()))?;
481
482        std::fs::write(path, content).map_err(ManifestError::Io)?;
483
484        Ok(())
485    }
486
487    /// Validate required sections based on context
488    /// T060: Enhanced error messages with context information
489    pub fn validate_for_context(&self, context: ProjectContext) -> Result<(), String> {
490        match context {
491            ProjectContext::Skill => {
492                // Skill-level: metadata with id and version required
493                if let Some(ref metadata) = self.metadata {
494                    if metadata.id.as_ref().is_none_or(|id| id.is_empty()) {
495                        return Err(
496                            "Skill-level skill-project.toml (in directory with SKILL.md) requires [metadata].id field. \
497                            Add 'id = \"your-skill-id\"' to the [metadata] section.".to_string()
498                        );
499                    }
500                    if metadata.version.as_ref().is_none_or(|v| v.is_empty()) {
501                        return Err(
502                            "Skill-level skill-project.toml (in directory with SKILL.md) requires [metadata].version field. \
503                            Add 'version = \"1.0.0\"' to the [metadata] section.".to_string()
504                        );
505                    }
506                } else {
507                    return Err(
508                        "Skill-level skill-project.toml (in directory with SKILL.md) requires [metadata] section with 'id' and 'version' fields. \
509                        This file is used for skill author metadata.".to_string()
510                    );
511                }
512            }
513            ProjectContext::Project => {
514                // Project-level: dependencies required
515                if self.dependencies.is_none() {
516                    return Err(
517                        "Project-level skill-project.toml (at project root) requires [dependencies] section. \
518                        Add '[dependencies]' section to manage skill dependencies. \
519                        Use 'fastskill add <skill-id>' to add skills.".to_string()
520                    );
521                }
522
523                // Project-level: skills_directory in [tool.fastskill] required
524                let has_skills_directory = self
525                    .tool
526                    .as_ref()
527                    .and_then(|t| t.fastskill.as_ref())
528                    .and_then(|f| f.skills_directory.as_ref())
529                    .is_some();
530
531                if !has_skills_directory {
532                    return Err(
533                        "Project-level skill-project.toml requires [tool.fastskill] with skills_directory. \
534                        Run 'fastskill init --skills-dir <path>' or add [tool.fastskill] with skills_directory = \"...\".".to_string()
535                    );
536                }
537            }
538            ProjectContext::Ambiguous => {
539                // Ambiguous: cannot validate without clear context
540                // T059: Provide helpful error message for ambiguous context
541                return Err(
542                    "Cannot determine context for skill-project.toml. \
543                    The file location and content are ambiguous. \
544                    For skill-level: ensure SKILL.md exists in the same directory and add [metadata] section with 'id' and 'version'. \
545                    For project-level: ensure file is at project root and add [dependencies] section.".to_string()
546                );
547            }
548        }
549        Ok(())
550    }
551
552    /// Convert SkillProjectToml dependencies to SkillEntry format for installation
553    /// T027: Helper to convert unified format to legacy format for compatibility
554    pub fn to_skill_entries(&self) -> Result<Vec<SkillEntry>, String> {
555        let mut entries = Vec::new();
556
557        if let Some(ref deps_section) = self.dependencies {
558            for (skill_id, dep_spec) in &deps_section.dependencies {
559                let (source, version, groups, editable) = match dep_spec {
560                    DependencySpec::Version(version_str) => {
561                        // Version-only dependency - treat as source-based
562                        (
563                            SkillSource::Source {
564                                name: "default".to_string(),
565                                skill: skill_id.clone(),
566                                version: Some(version_str.clone()),
567                            },
568                            Some(version_str.clone()),
569                            Vec::new(),
570                            false,
571                        )
572                    }
573                    DependencySpec::Inline {
574                        source,
575                        source_specific,
576                        groups,
577                        editable,
578                    } => {
579                        let source = match source {
580                            DependencySource::Git => {
581                                let url = source_specific.url.clone().ok_or_else(|| {
582                                    format!("Git source requires 'url' field for {}", skill_id)
583                                })?;
584                                SkillSource::Git {
585                                    url,
586                                    branch: source_specific.branch.clone(),
587                                    tag: None,
588                                    subdir: None,
589                                }
590                            }
591                            DependencySource::Local => {
592                                let path = source_specific.path.clone().ok_or_else(|| {
593                                    format!("Local source requires 'path' field for {}", skill_id)
594                                })?;
595                                SkillSource::Local {
596                                    path: PathBuf::from(path),
597                                    editable: editable.unwrap_or(false),
598                                }
599                            }
600                            DependencySource::ZipUrl => {
601                                let zip_url = source_specific.zip_url.clone().ok_or_else(|| {
602                                    format!(
603                                        "ZipUrl source requires 'zip_url' field for {}",
604                                        skill_id
605                                    )
606                                })?;
607                                SkillSource::ZipUrl {
608                                    base_url: zip_url,
609                                    version: source_specific.version.clone(),
610                                }
611                            }
612                            DependencySource::Source => {
613                                let name = source_specific.name.clone().ok_or_else(|| {
614                                    format!("Source source requires 'name' field for {}", skill_id)
615                                })?;
616                                let skill = source_specific.skill.clone().ok_or_else(|| {
617                                    format!("Source source requires 'skill' field for {}", skill_id)
618                                })?;
619                                SkillSource::Source {
620                                    name,
621                                    skill,
622                                    version: source_specific.version.clone(),
623                                }
624                            }
625                        };
626                        (
627                            source,
628                            source_specific.version.clone(),
629                            groups.clone().unwrap_or_default(),
630                            editable.unwrap_or(false),
631                        )
632                    }
633                };
634
635                entries.push(SkillEntry {
636                    id: skill_id.clone(),
637                    source,
638                    version: version.unwrap_or_else(|| "*".to_string()),
639                    groups,
640                    editable,
641                });
642            }
643        }
644
645        Ok(entries)
646    }
647}
648
649/// Manifest-related errors
650#[derive(Debug, thiserror::Error)]
651pub enum ManifestError {
652    #[error("Manifest file not found: {0}")]
653    NotFound(PathBuf),
654
655    #[error("IO error: {0}")]
656    Io(#[from] std::io::Error),
657
658    #[error("Parse error: {0}")]
659    Parse(String),
660
661    #[error("Serialize error: {0}")]
662    Serialize(String),
663}
664
665#[cfg(test)]
666#[allow(clippy::unwrap_used)]
667mod tests {
668    use super::*;
669    use std::path::PathBuf;
670
671    #[test]
672    fn test_manifest_parsing() {
673        let toml_content = r#"
674            [metadata]
675            version = "1.0.0"
676
677            [[skills]]
678            id = "web-scraper"
679            source = { type = "git", url = "https://github.com/org/repo.git", branch = "main" }
680            version = "*"
681
682            [[skills]]
683            id = "dev-tools"
684            source = { type = "git", url = "https://github.com/org/dev-tools.git" }
685            groups = ["dev"]
686            version = "*"
687
688            [[skills]]
689            id = "monitoring"
690            source = { type = "source", name = "team-tools", skill = "monitoring", version = "2.1.0" }
691            groups = ["prod"]
692            version = "2.1.0"
693        "#;
694
695        let manifest: SkillsManifest = toml::from_str(toml_content).unwrap();
696
697        assert_eq!(manifest.metadata.version, "1.0.0");
698        assert_eq!(manifest.skills.len(), 3);
699
700        // Check all skills
701        let all_skills = manifest.get_all_skills();
702        assert_eq!(all_skills.len(), 3);
703
704        // Check skills without dev group
705        let without_dev = manifest.get_skills_for_groups(Some(&["dev".to_string()]), None);
706        assert_eq!(without_dev.len(), 2); // web-scraper and monitoring
707
708        // Check only prod group
709        let only_prod = manifest.get_skills_for_groups(None, Some(&["prod".to_string()]));
710        assert_eq!(only_prod.len(), 1); // monitoring
711    }
712
713    #[test]
714    fn test_skill_source_variants() {
715        // Test Git source
716        let git_source = SkillSource::Git {
717            url: "https://github.com/org/repo.git".to_string(),
718            branch: Some("main".to_string()),
719            tag: None,
720            subdir: None,
721        };
722
723        // Test Source reference
724        let source_ref = SkillSource::Source {
725            name: "team-tools".to_string(),
726            skill: "monitoring".to_string(),
727            version: Some("2.1.0".to_string()),
728        };
729
730        // Test Local source
731        let _local_source = SkillSource::Local {
732            path: PathBuf::from("./local-skills"),
733            editable: false,
734        };
735
736        // Test ZipUrl source
737        let _zip_source = SkillSource::ZipUrl {
738            base_url: "https://skills.example.com/".to_string(),
739            version: None,
740        };
741
742        // Verify they serialize correctly
743        let git_toml = toml::to_string(&git_source).unwrap();
744        assert!(git_toml.contains("type = \"git\""));
745
746        let source_toml = toml::to_string(&source_ref).unwrap();
747        assert!(source_toml.contains("type = \"source\""));
748    }
749}