Skip to main content

fastskill_core/core/
project.rs

1//! Project-level file resolution and context detection
2
3use super::manifest::{FileResolutionResult, ProjectContext};
4use std::path::Path;
5
6/// Resolve skill-project.toml file from project root
7///
8/// Resolution priority (FR-008):
9/// 1. Project root: `./skill-project.toml`
10/// 2. Walk up directory tree: Search parent directories
11/// 3. Fallback: Empty/default configuration (file not found, will be created if needed)
12///
13/// Context detection (T050, T051):
14/// - First uses file location (SKILL.md presence)
15/// - If ambiguous, falls back to content-based detection
16pub fn resolve_project_file(start_path: &Path) -> FileResolutionResult {
17    // Start from the given path and walk up
18    let mut current = start_path.to_path_buf();
19
20    // First, try to find the file starting from current directory
21    loop {
22        let project_file = current.join("skill-project.toml");
23
24        if project_file.exists() {
25            // First, detect context from file location
26            let file_context = detect_context(&project_file);
27
28            // File location is authoritative: SKILL.md in same dir = skill-level, else project-level.
29            // Do not override to Skill from content when there is no SKILL.md, since project-level
30            // manifests may also have [metadata].id (e.g. workspace id).
31            let final_context = file_context;
32
33            return FileResolutionResult {
34                path: project_file,
35                context: final_context,
36                found: true,
37            };
38        }
39
40        // Check if we've reached the filesystem root
41        if let Some(parent) = current.parent() {
42            current = parent.to_path_buf();
43        } else {
44            break;
45        }
46    }
47
48    // File not found - return default at project root (start_path)
49    let default_path = start_path.join("skill-project.toml");
50    FileResolutionResult {
51        path: default_path,
52        context: ProjectContext::Project, // Default to project context
53        found: false,
54    }
55}
56
57/// Detect context for a skill-project.toml file
58///
59/// Detection logic (FR-006):
60/// 1. If skill-project.toml is in directory containing SKILL.md → Skill
61/// 2. If skill-project.toml is at project root (no SKILL.md in same directory) → Project
62/// 3. Otherwise → Ambiguous (check content: [metadata].id vs [dependencies])
63pub fn detect_context(project_file: &Path) -> ProjectContext {
64    let dir = project_file.parent().unwrap_or_else(|| Path::new("."));
65    let skill_md = dir.join("SKILL.md");
66
67    if skill_md.exists() {
68        return ProjectContext::Skill;
69    }
70
71    // Check if we're at project root (no SKILL.md in same directory)
72    // For now, if no SKILL.md, assume project context
73    // Content-based detection will be handled separately
74    ProjectContext::Project
75}
76
77/// Detect context from file content (for ambiguous cases)
78///
79/// Content-based detection (FR-006):
80/// - If [metadata].id is present → Skill context
81/// - If [dependencies] is present → Project context
82/// - Otherwise → Ambiguous
83pub fn detect_context_from_content(project: &super::manifest::SkillProjectToml) -> ProjectContext {
84    // Check for skill-level indicators
85    if let Some(ref metadata) = project.metadata {
86        if metadata.id.is_some() {
87            return ProjectContext::Skill;
88        }
89    }
90
91    // Check for project-level indicators
92    if let Some(ref deps) = project.dependencies {
93        if !deps.dependencies.is_empty() {
94            return ProjectContext::Project;
95        }
96    }
97
98    // Ambiguous - cannot determine from content
99    ProjectContext::Ambiguous
100}
101
102#[cfg(test)]
103#[allow(clippy::unwrap_used)]
104mod tests {
105    use super::*;
106    use std::fs;
107    use tempfile::TempDir;
108
109    #[test]
110    fn test_resolve_project_file_found() {
111        let temp_dir = TempDir::new().unwrap();
112        let project_file = temp_dir.path().join("skill-project.toml");
113        fs::write(&project_file, "[dependencies]\n").unwrap();
114
115        let result = resolve_project_file(temp_dir.path());
116
117        assert!(result.found);
118        assert_eq!(result.path, project_file);
119    }
120
121    #[test]
122    fn test_resolve_project_file_not_found() {
123        let temp_dir = TempDir::new().unwrap();
124
125        let result = resolve_project_file(temp_dir.path());
126
127        assert!(!result.found);
128        assert_eq!(result.path, temp_dir.path().join("skill-project.toml"));
129    }
130
131    #[test]
132    fn test_detect_context_skill() {
133        let temp_dir = TempDir::new().unwrap();
134        let project_file = temp_dir.path().join("skill-project.toml");
135        fs::write(&project_file, "[metadata]\n").unwrap();
136        fs::write(temp_dir.path().join("SKILL.md"), "# Skill\n").unwrap();
137
138        let context = detect_context(&project_file);
139
140        assert_eq!(context, ProjectContext::Skill);
141    }
142
143    #[test]
144    fn test_detect_context_project() {
145        let temp_dir = TempDir::new().unwrap();
146        let project_file = temp_dir.path().join("skill-project.toml");
147        fs::write(&project_file, "[dependencies]\n").unwrap();
148        // No SKILL.md
149
150        let context = detect_context(&project_file);
151
152        assert_eq!(context, ProjectContext::Project);
153    }
154
155    #[test]
156    fn test_resolve_project_file_walk_up() {
157        // T013: Test file resolution priority - walk up directory tree
158        let temp_dir = TempDir::new().unwrap();
159        let root_file = temp_dir.path().join("skill-project.toml");
160        fs::write(&root_file, "[dependencies]\n").unwrap();
161
162        // Create a subdirectory
163        let subdir = temp_dir.path().join("subdir");
164        fs::create_dir_all(&subdir).unwrap();
165
166        // Resolve from subdirectory - should find file in parent
167        let result = resolve_project_file(&subdir);
168
169        assert!(result.found);
170        assert_eq!(result.path, root_file);
171        assert_eq!(result.context, ProjectContext::Project);
172    }
173
174    #[test]
175    fn test_resolve_project_file_closest_wins() {
176        // T013: Test that closest file wins when multiple exist
177        let temp_dir = TempDir::new().unwrap();
178
179        // Create file at root
180        let root_file = temp_dir.path().join("skill-project.toml");
181        fs::write(&root_file, "[dependencies]\nroot = \"1.0.0\"\n").unwrap();
182
183        // Create subdirectory with its own file
184        let subdir = temp_dir.path().join("subdir");
185        fs::create_dir_all(&subdir).unwrap();
186        let subdir_file = subdir.join("skill-project.toml");
187        fs::write(&subdir_file, "[dependencies]\nsubdir = \"2.0.0\"\n").unwrap();
188
189        // Resolve from subdirectory - should find file in subdirectory (closest)
190        let result = resolve_project_file(&subdir);
191
192        assert!(result.found);
193        assert_eq!(result.path, subdir_file);
194    }
195}