Skip to main content

fastskill_core/core/
project_config.rs

1//! Project configuration loading and validation
2//!
3//! This module provides a single source of truth for project configuration:
4//! - Project root directory
5//! - Project file path (skill-project.toml)
6//! - Skills directory location
7//!
8//! All CLI commands and HTTP handlers should use load_project_config() to get these paths.
9//! No hardcoded ".claude/skills" or fallback logic - the project file must exist and be valid.
10
11use super::manifest::ProjectContext;
12use super::manifest::SkillProjectToml;
13use super::project;
14use std::path::{Path, PathBuf};
15
16/// Validated project configuration
17/// Contains all paths needed for project-level operations
18#[derive(Debug, Clone)]
19pub struct ProjectConfig {
20    /// Directory containing skill-project.toml (the project root)
21    pub project_root: PathBuf,
22    /// Full path to skill-project.toml file
23    pub project_file_path: PathBuf,
24    /// Resolved skills directory (absolute or relative to project root)
25    pub skills_directory: PathBuf,
26}
27
28/// Load and validate project configuration from skill-project.toml
29///
30/// This function enforces the following requirements:
31/// 1. skill-project.toml must exist (no fallback)
32/// 2. The file must be project-level (not skill-level with SKILL.md)
33/// 3. [tool.fastskill].skills_directory must be present
34///
35/// # Errors
36///
37/// Returns an error if:
38/// - skill-project.toml is not found in this directory or any parent
39/// - The file is skill-level (same directory as SKILL.md)
40/// - [tool.fastskill].skills_directory is missing
41/// - The file cannot be parsed
42pub fn load_project_config(start_path: &Path) -> Result<ProjectConfig, String> {
43    // Step 1: Resolve the project file
44    let project_file_result = project::resolve_project_file(start_path);
45
46    // Step 2: Check if file was found
47    if !project_file_result.found {
48        return Err(
49            "skill-project.toml not found in this directory or any parent. \
50            Create it at the top level of your workspace (e.g. run 'fastskill init' there), \
51            then run this command again."
52                .to_string(),
53        );
54    }
55
56    let project_file_path = project_file_result.path;
57
58    // Step 3: Check context - must be project-level, not skill-level
59    if project_file_result.context != ProjectContext::Project {
60        return Err(
61            "skill-project.toml here is for a skill (same directory as SKILL.md). \
62            Run install/add/list/update from the project root that has [dependencies] \
63            and [tool.fastskill] with skills_directory."
64                .to_string(),
65        );
66    }
67
68    // Step 4: Load the project file
69    let project = SkillProjectToml::load_from_file(&project_file_path)
70        .map_err(|e| format!("Failed to load skill-project.toml: {}", e))?;
71
72    // Step 5: Validate that skills_directory is present
73    let skills_directory_opt = project
74        .tool
75        .as_ref()
76        .and_then(|t| t.fastskill.as_ref())
77        .and_then(|f| f.skills_directory.as_ref());
78
79    let skills_directory = match skills_directory_opt {
80        Some(dir) => dir.clone(),
81        None => {
82            return Err(
83                "project-level skill-project.toml requires [tool.fastskill] with skills_directory. \
84                Run 'fastskill init --skills-dir <path>' at project root or add it manually.".to_string()
85            );
86        }
87    };
88
89    // Step 6: Resolve skills_directory (relative to project root if not absolute)
90    let project_root = project_file_path
91        .parent()
92        .unwrap_or_else(|| Path::new("."))
93        .to_path_buf();
94
95    let resolved_skills_directory = if skills_directory.is_absolute() {
96        skills_directory
97    } else {
98        project_root.join(&skills_directory)
99    };
100
101    // Step 7: Return validated configuration
102    Ok(ProjectConfig {
103        project_root,
104        project_file_path,
105        skills_directory: resolved_skills_directory,
106    })
107}
108
109#[cfg(test)]
110#[allow(clippy::unwrap_used)]
111mod tests {
112    use super::*;
113    use std::fs;
114    use tempfile::TempDir;
115
116    #[test]
117    fn test_load_project_config_not_found() {
118        let temp_dir = TempDir::new().unwrap();
119
120        let result = load_project_config(temp_dir.path());
121
122        assert!(result.is_err());
123        assert!(result.unwrap_err().contains("skill-project.toml not found"));
124    }
125
126    #[test]
127    fn test_load_project_config_skill_level() {
128        let temp_dir = TempDir::new().unwrap();
129
130        // Create SKILL.md to make it skill-level
131        fs::write(temp_dir.path().join("SKILL.md"), "# Test Skill").unwrap();
132
133        // Create skill-project.toml
134        let content = r#"
135[metadata]
136id = "test-skill"
137version = "1.0.0"
138        "#;
139        fs::write(temp_dir.path().join("skill-project.toml"), content).unwrap();
140
141        let result = load_project_config(temp_dir.path());
142
143        assert!(result.is_err());
144        assert!(result.unwrap_err().contains("is for a skill"));
145    }
146
147    #[test]
148    fn test_load_project_config_missing_skills_directory() {
149        let temp_dir = TempDir::new().unwrap();
150
151        // Create project-level skill-project.toml without [tool.fastskill]
152        let content = r#"
153[dependencies]
154test-skill = "1.0.0"
155        "#;
156        fs::write(temp_dir.path().join("skill-project.toml"), content).unwrap();
157
158        let result = load_project_config(temp_dir.path());
159
160        assert!(result.is_err());
161        assert!(result
162            .unwrap_err()
163            .contains("requires [tool.fastskill] with skills_directory"));
164    }
165
166    #[test]
167    fn test_load_project_config_success_absolute() {
168        let temp_dir = TempDir::new().unwrap();
169        let skills_path = temp_dir.path().join("skills");
170
171        // Create project-level skill-project.toml with absolute skills_directory
172        let content = format!(
173            r#"
174[dependencies]
175test-skill = "1.0.0"
176
177[tool.fastskill]
178skills_directory = "{}"
179        "#,
180            skills_path.display()
181        );
182        fs::write(temp_dir.path().join("skill-project.toml"), content).unwrap();
183
184        let result = load_project_config(temp_dir.path());
185
186        assert!(result.is_ok());
187        let config = result.unwrap();
188        assert_eq!(config.project_root, temp_dir.path());
189        assert_eq!(
190            config.project_file_path,
191            temp_dir.path().join("skill-project.toml")
192        );
193        assert_eq!(config.skills_directory, skills_path);
194    }
195
196    #[test]
197    fn test_load_project_config_success_relative() {
198        let temp_dir = TempDir::new().unwrap();
199
200        // Create project-level skill-project.toml with relative skills_directory
201        let content = r#"
202[dependencies]
203test-skill = "1.0.0"
204
205[tool.fastskill]
206skills_directory = ".claude/skills"
207        "#;
208        fs::write(temp_dir.path().join("skill-project.toml"), content).unwrap();
209
210        let result = load_project_config(temp_dir.path());
211
212        assert!(result.is_ok());
213        let config = result.unwrap();
214        assert_eq!(config.project_root, temp_dir.path());
215        assert_eq!(
216            config.project_file_path,
217            temp_dir.path().join("skill-project.toml")
218        );
219        assert_eq!(
220            config.skills_directory,
221            temp_dir.path().join(".claude/skills")
222        );
223    }
224
225    #[test]
226    fn test_load_project_config_walks_up() {
227        let temp_dir = TempDir::new().unwrap();
228
229        // Create project-level skill-project.toml at root
230        let content = r#"
231[dependencies]
232test-skill = "1.0.0"
233
234[tool.fastskill]
235skills_directory = ".claude/skills"
236        "#;
237        fs::write(temp_dir.path().join("skill-project.toml"), content).unwrap();
238
239        // Create subdirectory
240        let subdir = temp_dir.path().join("subdir");
241        fs::create_dir_all(&subdir).unwrap();
242
243        // Load from subdirectory - should find parent file
244        let result = load_project_config(&subdir);
245
246        assert!(result.is_ok());
247        let config = result.unwrap();
248        assert_eq!(config.project_root, temp_dir.path());
249        assert_eq!(
250            config.project_file_path,
251            temp_dir.path().join("skill-project.toml")
252        );
253        assert_eq!(
254            config.skills_directory,
255            temp_dir.path().join(".claude/skills")
256        );
257    }
258}