Skip to main content

fastskill_core/core/
context_resolver.rs

1use crate::core::embedding::EmbeddingService;
2use crate::core::metadata::MetadataService;
3use crate::core::service::{EmbeddingConfig, ServiceError, SkillId};
4use crate::core::skill_manager::SkillManagementService;
5use crate::core::vector_index::VectorIndexService;
6use crate::security::path::validate_path_within_root;
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11const MAX_CONTENT_SIZE: u64 = 512_000;
12const PREVIEW_BODY_LINES: usize = 20;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum ResolveScope {
17    Local,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum ContentMode {
23    #[default]
24    None,
25    Preview,
26    Full,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ResolveContextRequest {
31    pub prompt: String,
32    pub limit: usize,
33    pub scope: ResolveScope,
34    #[serde(default)]
35    pub include_content: ContentMode,
36    #[serde(default = "default_true")]
37    pub resolve_paths: bool,
38}
39
40fn default_true() -> bool {
41    true
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ResolvedSkill {
46    pub skill_id: String,
47    pub name: String,
48    pub description: String,
49    pub score: f32,
50    pub skill_md_path: Option<String>,
51    pub skill_root_path: Option<String>,
52    pub references_dir_path: Option<String>,
53    pub assets_dir_path: Option<String>,
54    pub content_preview: Option<String>,
55    pub content_full: Option<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ResolveContextResponse {
60    pub query: String,
61    pub scope: ResolveScope,
62    pub results: Vec<ResolvedSkill>,
63    pub allowed_roots: Vec<String>,
64}
65
66pub struct ContextResolver {
67    skill_manager: Arc<dyn SkillManagementService>,
68    metadata_service: Arc<dyn MetadataService>,
69    vector_index_service: Option<Arc<dyn VectorIndexService>>,
70    embedding_config: Option<EmbeddingConfig>,
71    skills_root: PathBuf,
72}
73
74impl ContextResolver {
75    pub fn new(
76        skill_manager: Arc<dyn SkillManagementService>,
77        metadata_service: Arc<dyn MetadataService>,
78        vector_index_service: Option<Arc<dyn VectorIndexService>>,
79        embedding_config: Option<EmbeddingConfig>,
80        skills_root: PathBuf,
81    ) -> Self {
82        Self {
83            skill_manager,
84            metadata_service,
85            vector_index_service,
86            embedding_config,
87            skills_root,
88        }
89    }
90
91    pub async fn resolve_context(
92        &self,
93        request: ResolveContextRequest,
94    ) -> Result<ResolveContextResponse, ServiceError> {
95        if request.prompt.trim().is_empty() {
96            return Err(ServiceError::Validation(
97                "RESOLVE_EMPTY_PROMPT: prompt cannot be empty or whitespace-only".to_string(),
98            ));
99        }
100
101        if request.limit == 0 {
102            return Err(ServiceError::Validation(
103                "RESOLVE_LIMIT_ZERO: limit must be greater than 0".to_string(),
104            ));
105        }
106
107        let search_results = self.perform_search(&request).await?;
108
109        let mut resolved = Vec::new();
110        for (skill_id_str, name, description, score) in search_results {
111            let skill_id = match SkillId::new(skill_id_str.clone()) {
112                Ok(id) => id,
113                Err(_) => continue,
114            };
115
116            let skill_def = match self.skill_manager.get_skill(&skill_id).await {
117                Ok(Some(def)) => def,
118                Ok(None) => {
119                    tracing::warn!(
120                        "RESOLVE_SKILL_NOT_INDEXED: skill '{}' found in search but missing from SkillManager",
121                        skill_id_str
122                    );
123                    continue;
124                }
125                Err(e) => {
126                    tracing::warn!(
127                        "RESOLVE_SKILL_NOT_INDEXED: lookup failed for '{}': {}",
128                        skill_id_str,
129                        e
130                    );
131                    continue;
132                }
133            };
134
135            let (skill_md_path, skill_root_path, references_dir_path, assets_dir_path) =
136                if request.resolve_paths {
137                    self.resolve_paths(&skill_def.skill_file)?
138                } else {
139                    (None, None, None, None)
140                };
141
142            let (content_preview, content_full) = self
143                .read_content(&skill_def.skill_file, &request.include_content)
144                .await?;
145
146            resolved.push(ResolvedSkill {
147                skill_id: skill_id_str,
148                name,
149                description,
150                score,
151                skill_md_path,
152                skill_root_path,
153                references_dir_path,
154                assets_dir_path,
155                content_preview,
156                content_full,
157            });
158
159            if resolved.len() >= request.limit {
160                break;
161            }
162        }
163
164        let allowed_roots = vec![self.skills_root.to_string_lossy().to_string()];
165
166        Ok(ResolveContextResponse {
167            query: request.prompt,
168            scope: request.scope,
169            results: resolved,
170            allowed_roots,
171        })
172    }
173
174    async fn perform_search(
175        &self,
176        request: &ResolveContextRequest,
177    ) -> Result<Vec<(String, String, String, f32)>, ServiceError> {
178        let embedding_attempt = self
179            .try_embedding_search(&request.prompt, request.limit)
180            .await;
181
182        if let Ok(results) = embedding_attempt {
183            return Ok(results);
184        }
185
186        self.text_search(&request.prompt, request.limit).await
187    }
188
189    async fn try_embedding_search(
190        &self,
191        prompt: &str,
192        limit: usize,
193    ) -> Result<Vec<(String, String, String, f32)>, ServiceError> {
194        let embedding_config = self
195            .embedding_config
196            .as_ref()
197            .ok_or_else(|| ServiceError::Config("RESOLVE_EMBEDDING_CONFIG_MISSING".to_string()))?;
198
199        let vector_service = self
200            .vector_index_service
201            .as_ref()
202            .ok_or_else(|| ServiceError::Config("RESOLVE_EMBEDDING_CONFIG_MISSING".to_string()))?;
203
204        let api_key = std::env::var("OPENAI_API_KEY")
205            .map_err(|_| ServiceError::Config("RESOLVE_EMBEDDING_CONFIG_MISSING".to_string()))?;
206
207        if api_key.trim().is_empty() {
208            return Err(ServiceError::Config(
209                "RESOLVE_EMBEDDING_CONFIG_MISSING".to_string(),
210            ));
211        }
212
213        let embedding_service =
214            crate::OpenAIEmbeddingService::from_config(embedding_config, api_key);
215        let query_embedding = embedding_service
216            .embed_query(prompt)
217            .await
218            .map_err(|e| ServiceError::Custom(format!("Embedding failed: {}", e)))?;
219
220        let matches = vector_service
221            .search_similar(&query_embedding, limit)
222            .await
223            .map_err(|e| ServiceError::Custom(format!("Vector search failed: {}", e)))?;
224
225        Ok(matches
226            .into_iter()
227            .map(|m| {
228                let name = m
229                    .skill
230                    .frontmatter_json
231                    .get("name")
232                    .and_then(|v| v.as_str())
233                    .unwrap_or(&m.skill.id)
234                    .to_string();
235                let description = m
236                    .skill
237                    .frontmatter_json
238                    .get("description")
239                    .and_then(|v| v.as_str())
240                    .unwrap_or("")
241                    .to_string();
242                (m.skill.id, name, description, m.similarity)
243            })
244            .collect())
245    }
246
247    async fn text_search(
248        &self,
249        prompt: &str,
250        limit: usize,
251    ) -> Result<Vec<(String, String, String, f32)>, ServiceError> {
252        let meta_list = self.metadata_service.search_skills(prompt).await?;
253
254        Ok(meta_list
255            .into_iter()
256            .take(limit)
257            .map(|m| {
258                let name = if m.name.is_empty() {
259                    m.id.as_str().to_string()
260                } else {
261                    m.name
262                };
263                (m.id.into_string(), name, m.description, 1.0)
264            })
265            .collect())
266    }
267
268    #[allow(clippy::type_complexity)]
269    fn resolve_paths(
270        &self,
271        skill_file: &Path,
272    ) -> Result<
273        (
274            Option<String>,
275            Option<String>,
276            Option<String>,
277            Option<String>,
278        ),
279        ServiceError,
280    > {
281        let skill_md = self.canonicalize_within_root(skill_file)?;
282        let root = skill_file.parent();
283        let skill_root = match root {
284            Some(r) => self.canonicalize_within_root(r)?,
285            None => None,
286        };
287
288        let references_dir = root.and_then(|r| {
289            let p = r.join("references");
290            if p.is_dir() {
291                self.canonicalize_within_root(&p).ok().flatten()
292            } else {
293                None
294            }
295        });
296        let assets_dir = root.and_then(|r| {
297            let p = r.join("assets");
298            if p.is_dir() {
299                self.canonicalize_within_root(&p).ok().flatten()
300            } else {
301                None
302            }
303        });
304
305        Ok((skill_md, skill_root, references_dir, assets_dir))
306    }
307
308    fn canonicalize_within_root(&self, path: &Path) -> Result<Option<String>, ServiceError> {
309        match validate_path_within_root(path, &self.skills_root) {
310            Ok(canonical) => Ok(Some(canonical.to_string_lossy().to_string())),
311            Err(e) => {
312                tracing::warn!(
313                    "RESOLVE_PATH_ESCAPE: path '{}' validation failed: {}",
314                    path.display(),
315                    e
316                );
317                Ok(None)
318            }
319        }
320    }
321
322    async fn read_content(
323        &self,
324        skill_file: &Path,
325        mode: &ContentMode,
326    ) -> Result<(Option<String>, Option<String>), ServiceError> {
327        if mode == &ContentMode::None {
328            return Ok((None, None));
329        }
330
331        if !skill_file.exists() {
332            tracing::warn!(
333                "RESOLVE_READ_FAILED: skill file does not exist: {}",
334                skill_file.display()
335            );
336            return Ok((None, None));
337        }
338
339        let metadata = match tokio::fs::metadata(skill_file).await {
340            Ok(m) => m,
341            Err(e) => {
342                tracing::warn!(
343                    "RESOLVE_READ_FAILED: cannot stat '{}': {}",
344                    skill_file.display(),
345                    e
346                );
347                return Ok((None, None));
348            }
349        };
350
351        let content = match tokio::fs::read_to_string(skill_file).await {
352            Ok(c) => c,
353            Err(e) => {
354                tracing::warn!(
355                    "RESOLVE_READ_FAILED: cannot read '{}': {}",
356                    skill_file.display(),
357                    e
358                );
359                return Ok((None, None));
360            }
361        };
362
363        match mode {
364            ContentMode::None => Ok((None, None)),
365            ContentMode::Preview => {
366                let preview = self.extract_preview(&content);
367                Ok((Some(preview), None))
368            }
369            ContentMode::Full => {
370                if metadata.len() > MAX_CONTENT_SIZE {
371                    tracing::warn!(
372                        "RESOLVE_CONTENT_TOO_LARGE: '{}' exceeds {} bytes",
373                        skill_file.display(),
374                        MAX_CONTENT_SIZE
375                    );
376                    Ok((None, None))
377                } else {
378                    Ok((None, Some(content)))
379                }
380            }
381        }
382    }
383
384    fn extract_preview(&self, content: &str) -> String {
385        let lines: Vec<&str> = content.lines().collect();
386        let mut frontmatter_end = 0usize;
387        let mut in_fm = false;
388        let mut fm_started = false;
389
390        for (i, line) in lines.iter().enumerate() {
391            if line.trim() == "---" {
392                if !fm_started {
393                    fm_started = true;
394                    in_fm = true;
395                } else if in_fm {
396                    frontmatter_end = i + 1;
397                    in_fm = false;
398                }
399            }
400        }
401
402        let start = if frontmatter_end > 0 {
403            frontmatter_end
404        } else {
405            0
406        };
407
408        let mut result_lines: Vec<&str> = Vec::new();
409
410        if frontmatter_end > 0 {
411            result_lines.extend_from_slice(&lines[..frontmatter_end]);
412        }
413
414        let body_lines: Vec<&&str> = lines[start..].iter().take(PREVIEW_BODY_LINES).collect();
415        result_lines.extend(body_lines.iter().map(|s| **s));
416
417        result_lines.join("\n")
418    }
419}
420
421#[cfg(test)]
422#[allow(clippy::unwrap_used)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_content_mode_default_is_none() {
428        assert_eq!(ContentMode::default(), ContentMode::None);
429    }
430
431    #[test]
432    fn test_resolve_scope_serde() {
433        let scope = ResolveScope::Local;
434        let json = serde_json::to_string(&scope).unwrap();
435        assert_eq!(json, "\"local\"");
436        let back: ResolveScope = serde_json::from_str(&json).unwrap();
437        assert_eq!(back, ResolveScope::Local);
438    }
439
440    #[test]
441    fn test_content_mode_serde() {
442        let mode = ContentMode::Preview;
443        let json = serde_json::to_string(&mode).unwrap();
444        assert_eq!(json, "\"preview\"");
445        let back: ContentMode = serde_json::from_str(&json).unwrap();
446        assert_eq!(back, ContentMode::Preview);
447    }
448
449    #[test]
450    fn test_extract_preview_frontmatter_plus_body() {
451        let content = "---\nname: test\ndescription: a test\n---\nline1\nline2\nline3\n";
452        let temp_dir = tempfile::TempDir::new().unwrap();
453        let skills_root = temp_dir.path().to_path_buf();
454
455        let resolver = ContextResolver::new(
456            Arc::new(crate::core::skill_manager::SkillManager::new()),
457            Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
458                crate::core::skill_manager::SkillManager::new(),
459            ))),
460            None,
461            None,
462            skills_root,
463        );
464
465        let preview = resolver.extract_preview(content);
466        assert!(preview.contains("name: test"));
467        assert!(preview.contains("line1"));
468        assert!(preview.contains("line2"));
469        assert!(preview.contains("line3"));
470    }
471
472    #[test]
473    fn test_extract_preview_truncates_at_20_body_lines() {
474        let body: Vec<String> = (1..=50).map(|i| format!("body line {}", i)).collect();
475        let content = format!("---\nname: test\n---\n{}", body.join("\n"));
476
477        let temp_dir = tempfile::TempDir::new().unwrap();
478        let skills_root = temp_dir.path().to_path_buf();
479
480        let resolver = ContextResolver::new(
481            Arc::new(crate::core::skill_manager::SkillManager::new()),
482            Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
483                crate::core::skill_manager::SkillManager::new(),
484            ))),
485            None,
486            None,
487            skills_root,
488        );
489
490        let preview = resolver.extract_preview(&content);
491        assert!(preview.contains("body line 20"));
492        assert!(!preview.contains("body line 21"));
493    }
494
495    #[tokio::test]
496    async fn test_resolve_empty_prompt_returns_error() {
497        let temp_dir = tempfile::TempDir::new().unwrap();
498        let resolver = ContextResolver::new(
499            Arc::new(crate::core::skill_manager::SkillManager::new()),
500            Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
501                crate::core::skill_manager::SkillManager::new(),
502            ))),
503            None,
504            None,
505            temp_dir.path().to_path_buf(),
506        );
507
508        let request = ResolveContextRequest {
509            prompt: "  ".to_string(),
510            limit: 5,
511            scope: ResolveScope::Local,
512            include_content: ContentMode::None,
513            resolve_paths: true,
514        };
515
516        let result = resolver.resolve_context(request).await;
517        assert!(result.is_err());
518        let err = result.unwrap_err();
519        assert!(err.to_string().contains("RESOLVE_EMPTY_PROMPT"));
520    }
521
522    #[tokio::test]
523    async fn test_resolve_limit_zero_returns_error() {
524        let temp_dir = tempfile::TempDir::new().unwrap();
525        let resolver = ContextResolver::new(
526            Arc::new(crate::core::skill_manager::SkillManager::new()),
527            Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
528                crate::core::skill_manager::SkillManager::new(),
529            ))),
530            None,
531            None,
532            temp_dir.path().to_path_buf(),
533        );
534
535        let request = ResolveContextRequest {
536            prompt: "test".to_string(),
537            limit: 0,
538            scope: ResolveScope::Local,
539            include_content: ContentMode::None,
540            resolve_paths: true,
541        };
542
543        let result = resolver.resolve_context(request).await;
544        assert!(result.is_err());
545        let err = result.unwrap_err();
546        assert!(err.to_string().contains("RESOLVE_LIMIT_ZERO"));
547    }
548
549    #[tokio::test]
550    async fn test_resolve_context_returns_allowed_roots() {
551        let temp_dir = tempfile::TempDir::new().unwrap();
552        let skills_root = temp_dir.path().to_path_buf();
553
554        let resolver = ContextResolver::new(
555            Arc::new(crate::core::skill_manager::SkillManager::new()),
556            Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
557                crate::core::skill_manager::SkillManager::new(),
558            ))),
559            None,
560            None,
561            skills_root.clone(),
562        );
563
564        let request = ResolveContextRequest {
565            prompt: "anything".to_string(),
566            limit: 5,
567            scope: ResolveScope::Local,
568            include_content: ContentMode::None,
569            resolve_paths: true,
570        };
571
572        let response = resolver.resolve_context(request).await.unwrap();
573        assert_eq!(response.allowed_roots.len(), 1);
574        assert_eq!(response.query, "anything");
575        assert!(response.results.is_empty());
576    }
577
578    #[tokio::test]
579    async fn test_resolve_paths_within_root() {
580        let temp_dir = tempfile::TempDir::new().unwrap();
581        let skills_dir = temp_dir.path().join("skills");
582        std::fs::create_dir_all(&skills_dir).unwrap();
583
584        let skill_dir = skills_dir.join("my-skill");
585        std::fs::create_dir_all(&skill_dir).unwrap();
586        let skill_file = skill_dir.join("SKILL.md");
587        std::fs::write(
588            &skill_file,
589            "---\nname: test\ndescription: test\n---\ncontent",
590        )
591        .unwrap();
592
593        let resolver = ContextResolver::new(
594            Arc::new(crate::core::skill_manager::SkillManager::new()),
595            Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
596                crate::core::skill_manager::SkillManager::new(),
597            ))),
598            None,
599            None,
600            skills_dir.clone(),
601        );
602
603        let (md_path, root_path, refs, assets) = resolver.resolve_paths(&skill_file).unwrap();
604
605        assert!(md_path.is_some());
606        let md = md_path.unwrap();
607        assert!(md.contains("my-skill"));
608
609        assert!(root_path.is_some());
610        assert!(refs.is_none());
611        assert!(assets.is_none());
612    }
613}