1use crate::analysis::backend::AnalysisBackend;
2use crate::analysis::claude_cli::ClaudeCliBackend;
3use crate::errors::CoreError;
4use crate::models::{
5 KnowledgeNode, NodeType, Pattern, PatternStatus, PatternType, SkillDraft, SkillValidation,
6 SuggestedTarget,
7};
8use crate::util;
9
10const MAX_RETRIES: usize = 2;
11
12pub fn node_to_pattern(node: &KnowledgeNode) -> Pattern {
14 let pattern_type = match node.node_type {
15 NodeType::Skill => PatternType::WorkflowPattern,
16 NodeType::Rule | NodeType::Directive => PatternType::RepetitiveInstruction,
17 NodeType::Pattern => PatternType::RecurringMistake,
18 NodeType::Preference | NodeType::Memory => PatternType::WorkflowPattern,
19 };
20 let suggested_target = match node.node_type {
21 NodeType::Skill => SuggestedTarget::Skill,
22 NodeType::Rule
23 | NodeType::Directive
24 | NodeType::Pattern
25 | NodeType::Preference
26 | NodeType::Memory => SuggestedTarget::ClaudeMd,
27 };
28
29 Pattern {
30 id: node.id.clone(),
31 pattern_type,
32 description: node.content.clone(),
33 confidence: node.confidence,
34 times_seen: 1,
35 first_seen: node.created_at,
36 last_seen: node.updated_at,
37 last_projected: None,
38 status: PatternStatus::Active,
39 source_sessions: vec![],
40 related_files: vec![],
41 suggested_content: node.content.clone(),
42 suggested_target,
43 project: node.project_id.clone(),
44 generation_failed: false,
45 }
46}
47
48const SKILL_VALIDATION_SCHEMA: &str = r#"{"type":"object","properties":{"valid":{"type":"boolean"},"feedback":{"type":"string"}},"required":["valid","feedback"],"additionalProperties":false}"#;
50
51pub fn generate_with_retry(
53 backend: &dyn AnalysisBackend,
54 pattern: &Pattern,
55 max_retries: usize,
56) -> Result<SkillDraft, CoreError> {
57 let mut feedback = String::new();
58 let retries = max_retries.min(MAX_RETRIES);
59
60 for attempt in 0..=retries {
61 let prompt = build_generation_prompt(pattern, if attempt > 0 { Some(&feedback) } else { None });
62 let response = backend.execute(&prompt, None)?;
63 let content = util::strip_code_fences(&response.text);
64
65 let name = match parse_skill_name(&content) {
66 Some(n) => n,
67 None => {
68 feedback = "The skill must have valid YAML frontmatter with a 'name' field.".to_string();
69 continue;
70 }
71 };
72
73 let draft = SkillDraft {
74 name,
75 content: content.clone(),
76 pattern_id: pattern.id.clone(),
77 };
78
79 let validation_prompt = build_validation_prompt(&content, pattern);
81 match backend.execute(&validation_prompt, Some(SKILL_VALIDATION_SCHEMA)) {
82 Ok(val_response) => {
83 match parse_validation(&val_response.text) {
84 Some(v) if v.valid => return Ok(draft),
85 Some(v) => {
86 feedback = v.feedback;
87 }
88 None => {
89 if has_valid_frontmatter(&content) {
91 return Ok(draft);
92 }
93 feedback = "Skill validation response was unparseable.".to_string();
94 }
95 }
96 }
97 Err(_) => {
98 if has_valid_frontmatter(&content) {
100 return Ok(draft);
101 }
102 feedback = "Skill validation call failed.".to_string();
103 }
104 }
105 }
106
107 Err(CoreError::Analysis(format!(
108 "skill generation failed after {} retries for pattern {}",
109 retries, pattern.id
110 )))
111}
112
113fn build_generation_prompt(pattern: &Pattern, feedback: Option<&str>) -> String {
114 let feedback_section = match feedback {
115 Some(fb) => format!(
116 "\n\n## Previous Attempt Feedback\n\nYour previous attempt was rejected: {fb}\nPlease address this feedback in your new attempt.\n"
117 ),
118 None => String::new(),
119 };
120
121 let related = if pattern.related_files.is_empty() {
122 "None".to_string()
123 } else {
124 pattern.related_files.join(", ")
125 };
126
127 format!(
128 r#"You are an expert at writing Claude Code skills. A skill is a reusable instruction file that Claude Code discovers and applies automatically.
129
130Generate a skill for the following discovered pattern:
131
132**Pattern Type:** {pattern_type}
133**Description:** {description}
134**Suggested Content:** {suggested_content}
135**Related Files:** {related}
136**Times Seen:** {times_seen}
137{feedback_section}
138## Skill Format
139
140The skill MUST follow this exact format:
141
142```
143---
144name: lowercase-letters-numbers-hyphens-only
145description: Use when [specific triggering conditions]. Include keywords like error messages, tool names, symptoms.
146---
147
148[Skill body: Clear, actionable instructions with specific commands and file paths.]
149```
150
151## Examples
152
153Example 1:
154```
155---
156name: run-tests-after-rust-changes
157description: Use when modifying .rs files in src/, when making code changes that could break functionality, or when the user mentions testing.
158---
159
160After modifying any Rust source file (.rs), always run the test suite:
161
1621. Run `cargo test` in the workspace root
1632. If tests fail, fix the failing tests before proceeding
1643. Run `cargo clippy` to check for warnings
165```
166
167Example 2:
168```
169---
170name: python-uv-package-management
171description: Use when installing Python packages, setting up virtual environments, seeing pip-related errors, or when pyproject.toml is present.
172---
173
174Always use `uv` for Python package management instead of `pip`:
175
1761. Install packages: `uv pip install <package>`
1772. Create virtual environments: `uv venv`
1783. Sync from requirements: `uv pip sync requirements.txt`
1794. Never use bare `pip install`
180```
181
182## Requirements
183
184- **name**: lowercase letters, numbers, and hyphens only. Descriptive of the skill's purpose.
185- **description**: MUST start with "Use when...". Describe TRIGGERING CONDITIONS, not what the skill does. Include relevant keywords (error messages, tool names, file types). Total YAML frontmatter must be under 1024 characters.
186- **body**: Actionable, specific instructions. Use numbered steps for procedures. Reference concrete commands and paths.
187
188Return ONLY the skill content (YAML frontmatter + body), no explanation or wrapping."#,
189 pattern_type = pattern.pattern_type,
190 description = pattern.description,
191 suggested_content = pattern.suggested_content,
192 related = related,
193 times_seen = pattern.times_seen,
194 )
195}
196
197fn build_validation_prompt(skill_content: &str, pattern: &Pattern) -> String {
198 format!(
199 r#"You are a quality reviewer for Claude Code skills. Review the following skill and determine if it meets quality standards.
200
201## Skill Content
202
203```
204{skill_content}
205```
206
207## Original Pattern
208
209**Description:** {description}
210**Suggested Content:** {suggested_content}
211
212## Quality Criteria
213
2141. **name** field: lowercase letters, numbers, and hyphens only
2152. **description**: Starts with "Use when..."
2163. **description**: Describes triggering conditions, NOT what the skill does
2174. **Total YAML frontmatter**: Under 1024 characters
2185. **Body**: Actionable and specific instructions
2196. **Relevance**: Skill actually addresses the original pattern
220
221Return ONLY a JSON object (no markdown wrapping):
222{{"valid": true, "feedback": ""}}
223or
224{{"valid": false, "feedback": "explanation of what needs to be fixed"}}"#,
225 skill_content = skill_content,
226 description = pattern.description,
227 suggested_content = pattern.suggested_content,
228 )
229}
230
231pub fn parse_skill_name(content: &str) -> Option<String> {
233 let lines: Vec<&str> = content.lines().collect();
234 if lines.is_empty() || lines[0].trim() != "---" {
235 return None;
236 }
237
238 for line in &lines[1..] {
239 let trimmed = line.trim();
240 if trimmed == "---" {
241 break;
242 }
243 if let Some(rest) = trimmed.strip_prefix("name:") {
244 let name = rest.trim().trim_matches('"').trim_matches('\'').to_string();
245 if !name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
246 return Some(name);
247 }
248 }
249 }
250 None
251}
252
253fn has_valid_frontmatter(content: &str) -> bool {
255 let lines: Vec<&str> = content.lines().collect();
256 if lines.is_empty() || lines[0].trim() != "---" {
257 return false;
258 }
259 lines[1..].iter().any(|line| line.trim() == "---")
261}
262
263fn parse_validation(text: &str) -> Option<SkillValidation> {
266 serde_json::from_str(text.trim()).ok()
267}
268
269pub fn skill_path(project_root: &str, name: &str) -> String {
271 format!("{project_root}/.claude/skills/{name}/SKILL.md")
272}
273
274fn find_writing_skills_in_plugins_dir(plugins_dir: &std::path::Path) -> Option<String> {
281 let pattern = plugins_dir
282 .join("cache")
283 .join("*")
284 .join("superpowers")
285 .join("*")
286 .join("skills")
287 .join("writing-skills")
288 .join("SKILL.md");
289
290 let pattern_str = pattern.to_string_lossy();
291 let mut matches: Vec<std::path::PathBuf> = glob::glob(&pattern_str)
292 .ok()?
293 .filter_map(|r| r.ok())
294 .collect();
295
296 if matches.is_empty() {
297 return None;
298 }
299
300 matches.sort();
301 let skill_path = matches.last()?;
302 let skill_dir = skill_path.parent()?;
303
304 let skill_content = std::fs::read_to_string(skill_path).ok()?;
305
306 let mut companions: Vec<std::path::PathBuf> = std::fs::read_dir(skill_dir)
308 .ok()?
309 .filter_map(|entry| entry.ok())
310 .map(|entry| entry.path())
311 .filter(|p| {
312 p.extension().map(|ext| ext == "md").unwrap_or(false)
313 && p.file_name().map(|n| n != "SKILL.md").unwrap_or(false)
314 })
315 .collect();
316 companions.sort();
317
318 let mut result = skill_content;
319 for companion in &companions {
320 if let Ok(companion_content) = std::fs::read_to_string(companion) {
321 let filename = companion
322 .file_name()
323 .map(|n| n.to_string_lossy().into_owned())
324 .unwrap_or_default();
325 result.push_str(&format!(
326 "\n\n---\n## Companion: {filename}\n\n{companion_content}"
327 ));
328 }
329 }
330
331 Some(result)
332}
333
334pub fn find_writing_skills_content() -> Option<String> {
337 let home = std::env::var("HOME").ok()?;
338 let plugins_dir = std::path::PathBuf::from(home).join(".claude").join("plugins");
339 find_writing_skills_in_plugins_dir(&plugins_dir)
340}
341
342fn check_superpowers_in_file(path: &std::path::Path) -> bool {
345 let Ok(content) = std::fs::read_to_string(path) else { return false };
346 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else { return false };
347 json.get("plugins")
348 .and_then(|p| p.as_object())
349 .map(|plugins| plugins.keys().any(|k| k.contains("superpowers")))
350 .unwrap_or(false)
351}
352
353pub fn is_superpowers_installed() -> bool {
356 let home = std::env::var("HOME").unwrap_or_default();
357 let path = std::path::PathBuf::from(home)
358 .join(".claude")
359 .join("plugins")
360 .join("installed_plugins.json");
361 check_superpowers_in_file(&path)
362}
363
364pub fn generate_skill_slug(content: &str) -> String {
368 content
369 .split(|c: char| !c.is_alphanumeric())
370 .filter(|w| w.len() >= 2)
371 .take(4)
372 .collect::<Vec<_>>()
373 .join("-")
374 .to_lowercase()
375}
376
377pub fn skill_exists_at_target(skills_dir: &std::path::Path, node_content: &str) -> bool {
380 let slug = generate_skill_slug(node_content);
381 skills_dir.join(&slug).join("SKILL.md").exists()
382}
383
384pub fn skill_target_dir(node: &KnowledgeNode, project_path: Option<&str>) -> std::path::PathBuf {
387 match node.scope {
388 crate::models::NodeScope::Global => {
389 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
390 std::path::PathBuf::from(home).join(".claude").join("skills")
391 }
392 crate::models::NodeScope::Project => {
393 std::path::PathBuf::from(project_path.unwrap_or("."))
394 .join(".claude")
395 .join("skills")
396 }
397 }
398}
399
400pub struct SkillGenerationResult {
402 pub created: bool,
404 pub skill_path: std::path::PathBuf,
406 pub input_tokens: u64,
408 pub output_tokens: u64,
409}
410
411fn build_agentic_skill_prompt_with_instructions(
413 node_content: &str,
414 confidence: f64,
415 target_dir: &str,
416 writing_skills_content: Option<&str>,
417) -> String {
418 let instructions_section = match writing_skills_content {
419 Some(content) => format!(
420 "\n---BEGIN WRITING-SKILLS INSTRUCTIONS---\n{content}\n---END WRITING-SKILLS INSTRUCTIONS---\n"
421 ),
422 None => String::new(),
423 };
424
425 format!(
426 r#"You are an expert at writing Claude Code skills. A skill is a reusable instruction file that Claude Code discovers and applies automatically.
427{instructions_section}
428## Task
429
430Create a skill based on the following observed pattern (confidence: {confidence}):
431
432{node_content}
433
434## Instructions
435
4361. Choose a descriptive kebab-case skill name (lowercase letters, numbers, hyphens only).
4372. Write the skill to: `{target_dir}/{{skill-name}}/SKILL.md`
438 - Replace `{{skill-name}}` with the actual name you choose.
4393. The skill file MUST have YAML frontmatter with:
440 - `name`: the kebab-case skill name
441 - `description`: MUST start with "Use when..." and describe triggering conditions
4424. The body should be concise, actionable instructions.
443
444## Format Example
445
446```
447---
448name: run-tests-before-commit
449description: Use when making code changes, modifying source files, or preparing to commit.
450---
451
452Always run the test suite before committing:
453
4541. Run `cargo test` in the workspace root
4552. Fix any failing tests before proceeding
456```
457
458Write the skill file now using your file writing tools."#,
459 confidence = confidence,
460 node_content = node_content,
461 target_dir = target_dir,
462 )
463}
464
465fn snapshot_existing_skills(skills_dir: &std::path::Path) -> std::collections::HashSet<std::path::PathBuf> {
467 let pattern = skills_dir.join("*").join("SKILL.md");
468 let pattern_str = pattern.to_string_lossy();
469 glob::glob(&pattern_str)
470 .map(|paths| paths.filter_map(|r| r.ok()).collect())
471 .unwrap_or_default()
472}
473
474fn find_created_skill(
476 skills_dir: &std::path::Path,
477 before: &std::collections::HashSet<std::path::PathBuf>,
478) -> Option<std::path::PathBuf> {
479 let pattern = skills_dir.join("*").join("SKILL.md");
480 let pattern_str = pattern.to_string_lossy();
481 glob::glob(&pattern_str)
482 .ok()?
483 .filter_map(|r| r.ok())
484 .find(|p| !before.contains(p))
485}
486
487pub fn generate_skill_agentic(
490 backend: &ClaudeCliBackend,
491 node: &KnowledgeNode,
492 project_path: Option<&str>,
493) -> Result<SkillGenerationResult, CoreError> {
494 let writing_skills_content = find_writing_skills_content();
496
497 let target_dir = skill_target_dir(node, project_path);
499
500 std::fs::create_dir_all(&target_dir).map_err(|e| {
502 CoreError::Analysis(format!(
503 "failed to create skills directory {}: {e}",
504 target_dir.display()
505 ))
506 })?;
507
508 if skill_exists_at_target(&target_dir, &node.content) {
510 let slug = generate_skill_slug(&node.content);
511 let existing_path = target_dir.join(&slug).join("SKILL.md");
512 return Ok(SkillGenerationResult {
513 created: true, skill_path: existing_path,
515 input_tokens: 0,
516 output_tokens: 0,
517 });
518 }
519
520 let target_dir_str = target_dir.to_string_lossy();
522 let prompt = build_agentic_skill_prompt_with_instructions(
523 &node.content,
524 node.confidence,
525 &target_dir_str,
526 writing_skills_content.as_deref(),
527 );
528
529 let cwd = match node.scope {
531 crate::models::NodeScope::Project => project_path,
532 crate::models::NodeScope::Global => None,
533 };
534
535 let before = snapshot_existing_skills(&target_dir);
537
538 let response = backend.execute_agentic(&prompt, cwd)?;
540
541 let found = find_created_skill(&target_dir, &before);
543 let created = found.is_some();
544 let skill_path = found.unwrap_or_else(|| target_dir.join("unknown").join("SKILL.md"));
545
546 Ok(SkillGenerationResult {
547 created,
548 skill_path,
549 input_tokens: response.input_tokens,
550 output_tokens: response.output_tokens,
551 })
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 #[test]
559 fn test_find_writing_skills_in_dir() {
560 let dir = tempfile::TempDir::new().unwrap();
561 let skill_dir = dir.path()
562 .join("cache")
563 .join("marketplace")
564 .join("superpowers")
565 .join("1.0.0")
566 .join("skills")
567 .join("writing-skills");
568 std::fs::create_dir_all(&skill_dir).unwrap();
569 std::fs::write(skill_dir.join("SKILL.md"), "# Writing Skills\nMain content here.").unwrap();
570 std::fs::write(skill_dir.join("best-practices.md"), "# Best Practices\nCompanion content.").unwrap();
571
572 let result = find_writing_skills_in_plugins_dir(dir.path());
573 assert!(result.is_some());
574 let content = result.unwrap();
575 assert!(content.contains("Main content here."));
576 assert!(content.contains("Companion content."));
577 }
578
579 #[test]
580 fn test_find_writing_skills_in_dir_missing() {
581 let dir = tempfile::TempDir::new().unwrap();
582 let result = find_writing_skills_in_plugins_dir(dir.path());
583 assert!(result.is_none());
584 }
585
586 #[test]
587 fn test_find_writing_skills_picks_latest_version() {
588 let dir = tempfile::TempDir::new().unwrap();
589 for version in &["1.0.0", "2.0.0"] {
590 let skill_dir = dir.path()
591 .join("cache")
592 .join("mkt")
593 .join("superpowers")
594 .join(version)
595 .join("skills")
596 .join("writing-skills");
597 std::fs::create_dir_all(&skill_dir).unwrap();
598 std::fs::write(skill_dir.join("SKILL.md"), format!("version {version}")).unwrap();
599 }
600 let result = find_writing_skills_in_plugins_dir(dir.path());
601 assert!(result.is_some());
602 let content = result.unwrap();
603 assert!(content.contains("version 2.0.0"));
605 }
606
607 #[test]
608 fn test_parse_skill_name_valid() {
609 let content = "---\nname: run-tests-after-changes\ndescription: Use when modifying files\n---\n\nBody here.";
610 assert_eq!(parse_skill_name(content), Some("run-tests-after-changes".to_string()));
611 }
612
613 #[test]
614 fn test_parse_skill_name_quoted() {
615 let content = "---\nname: \"my-skill\"\ndescription: Use when stuff\n---\n\nBody.";
616 assert_eq!(parse_skill_name(content), Some("my-skill".to_string()));
617 }
618
619 #[test]
620 fn test_parse_skill_name_invalid_chars() {
621 let content = "---\nname: My Skill Name\ndescription: test\n---\n";
622 assert_eq!(parse_skill_name(content), None);
623 }
624
625 #[test]
626 fn test_parse_skill_name_no_frontmatter() {
627 let content = "Just some text";
628 assert_eq!(parse_skill_name(content), None);
629 }
630
631 #[test]
632 fn test_has_valid_frontmatter() {
633 assert!(has_valid_frontmatter("---\nname: test\n---\nbody"));
634 assert!(!has_valid_frontmatter("no frontmatter"));
635 assert!(!has_valid_frontmatter("---\nno closing delimiter"));
636 }
637
638 #[test]
639 fn test_parse_validation_valid() {
640 let text = r#"{"valid": true, "feedback": ""}"#;
641 let v = parse_validation(text).unwrap();
642 assert!(v.valid);
643 assert!(v.feedback.is_empty());
644 }
645
646 #[test]
647 fn test_parse_validation_invalid() {
648 let text = r#"{"valid": false, "feedback": "description doesn't start with Use when"}"#;
649 let v = parse_validation(text).unwrap();
650 assert!(!v.valid);
651 assert!(v.feedback.contains("Use when"));
652 }
653
654 #[test]
655 fn test_skill_validation_schema_is_valid_json() {
656 let value: serde_json::Value = serde_json::from_str(SKILL_VALIDATION_SCHEMA)
657 .expect("SKILL_VALIDATION_SCHEMA must be valid JSON");
658 assert_eq!(value["type"], "object");
659 assert!(value["properties"]["valid"].is_object());
660 assert!(value["properties"]["feedback"].is_object());
661 }
662
663 #[test]
664 fn test_skill_path() {
665 assert_eq!(
666 skill_path("/home/user/project", "run-tests"),
667 "/home/user/project/.claude/skills/run-tests/SKILL.md"
668 );
669 }
670
671 #[test]
672 fn test_node_to_pattern() {
673 use crate::models::*;
674 use chrono::Utc;
675 let node = KnowledgeNode {
676 id: "node-1".to_string(),
677 node_type: NodeType::Skill,
678 scope: NodeScope::Global,
679 project_id: None,
680 content: "Pre-PR checklist: run tests, lint, format, commit".to_string(),
681 confidence: 0.78,
682 status: NodeStatus::Active,
683 created_at: Utc::now(),
684 updated_at: Utc::now(),
685 projected_at: None,
686 pr_url: None,
687 };
688
689 let pattern = node_to_pattern(&node);
690 assert_eq!(pattern.id, "node-1");
691 assert_eq!(pattern.description, node.content);
692 assert_eq!(pattern.suggested_content, node.content);
693 assert_eq!(pattern.confidence, 0.78);
694 assert_eq!(pattern.suggested_target, SuggestedTarget::Skill);
695 }
696
697 #[test]
698 fn test_is_superpowers_installed_with_valid_json() {
699 let dir = tempfile::TempDir::new().unwrap();
700 let plugins_path = dir.path().join("installed_plugins.json");
701 std::fs::write(&plugins_path, r#"{"version":2,"plugins":{"superpowers@marketplace":[{"scope":"user"}]}}"#).unwrap();
702 assert!(check_superpowers_in_file(&plugins_path));
703 }
704
705 #[test]
706 fn test_is_superpowers_installed_no_superpowers() {
707 let dir = tempfile::TempDir::new().unwrap();
708 let plugins_path = dir.path().join("installed_plugins.json");
709 std::fs::write(&plugins_path, r#"{"version":2,"plugins":{"other-plugin@marketplace":[]}}"#).unwrap();
710 assert!(!check_superpowers_in_file(&plugins_path));
711 }
712
713 #[test]
714 fn test_is_superpowers_installed_missing_file() {
715 let dir = tempfile::TempDir::new().unwrap();
716 let plugins_path = dir.path().join("nonexistent.json");
717 assert!(!check_superpowers_in_file(&plugins_path));
718 }
719
720 #[test]
721 fn test_is_superpowers_installed_invalid_json() {
722 let dir = tempfile::TempDir::new().unwrap();
723 let plugins_path = dir.path().join("installed_plugins.json");
724 std::fs::write(&plugins_path, "not json").unwrap();
725 assert!(!check_superpowers_in_file(&plugins_path));
726 }
727
728 #[test]
729 fn test_generate_skill_slug_basic() {
730 assert_eq!(
731 generate_skill_slug("Pre-PR checklist: run tests, lint, format"),
732 "pre-pr-checklist-run"
733 );
734 }
735
736 #[test]
737 fn test_generate_skill_slug_short_words() {
738 assert_eq!(
739 generate_skill_slug("CI check failures before merging"),
740 "ci-check-failures-before"
741 );
742 }
743
744 #[test]
745 fn test_generate_skill_slug_single_char_filtered() {
746 assert_eq!(
747 generate_skill_slug("Run a test before commit"),
748 "run-test-before-commit"
749 );
750 }
751
752 #[test]
753 fn test_generate_skill_slug_uppercase() {
754 assert_eq!(
755 generate_skill_slug("Rust Error Handling Pattern"),
756 "rust-error-handling-pattern"
757 );
758 }
759
760 #[test]
761 fn test_generate_skill_slug_already_kebab() {
762 assert_eq!(
763 generate_skill_slug("pre-commit-hook"),
764 "pre-commit-hook"
765 );
766 }
767
768 #[test]
769 fn test_skill_target_dir_global() {
770 use crate::models::*;
771 let node = KnowledgeNode {
772 id: "n1".to_string(),
773 node_type: NodeType::Skill,
774 scope: NodeScope::Global,
775 project_id: None,
776 content: "test".to_string(),
777 confidence: 0.8,
778 status: NodeStatus::Active,
779 created_at: chrono::Utc::now(),
780 updated_at: chrono::Utc::now(),
781 projected_at: None,
782 pr_url: None,
783 };
784 let dir = skill_target_dir(&node, None);
785 assert!(dir.to_string_lossy().ends_with(".claude/skills"));
786 }
787
788 #[test]
789 fn test_skill_target_dir_project() {
790 use crate::models::*;
791 let node = KnowledgeNode {
792 id: "n2".to_string(),
793 node_type: NodeType::Skill,
794 scope: NodeScope::Project,
795 project_id: Some("my-project".to_string()),
796 content: "test".to_string(),
797 confidence: 0.8,
798 status: NodeStatus::Active,
799 created_at: chrono::Utc::now(),
800 updated_at: chrono::Utc::now(),
801 projected_at: None,
802 pr_url: None,
803 };
804 let dir = skill_target_dir(&node, Some("/home/user/my-project"));
805 assert_eq!(dir, std::path::PathBuf::from("/home/user/my-project/.claude/skills"));
806 }
807
808 #[test]
809 fn test_build_agentic_skill_prompt_with_instructions() {
810 let prompt = build_agentic_skill_prompt_with_instructions(
811 "Always run tests before committing",
812 0.85,
813 "/home/user/.claude/skills",
814 Some("# Writing Skills Instructions\nDo this and that."),
815 );
816 assert!(prompt.contains("---BEGIN WRITING-SKILLS INSTRUCTIONS---"));
817 assert!(prompt.contains("---END WRITING-SKILLS INSTRUCTIONS---"));
818 assert!(prompt.contains("Always run tests before committing"));
819 assert!(prompt.contains("0.85"));
820 assert!(prompt.contains("/home/user/.claude/skills"));
821 assert!(prompt.contains("Do this and that."));
822 }
823
824 #[test]
825 fn test_build_agentic_skill_prompt_without_instructions() {
826 let prompt = build_agentic_skill_prompt_with_instructions(
827 "test content",
828 0.7,
829 "/tmp/skills",
830 None,
831 );
832 assert!(prompt.contains("test content"));
833 assert!(prompt.contains("/tmp/skills"));
834 assert!(!prompt.contains("WRITING-SKILLS INSTRUCTIONS"));
835 }
836
837 #[test]
838 fn test_find_created_skill_finds_new_file() {
839 let dir = tempfile::TempDir::new().unwrap();
840 let skill1_dir = dir.path().join("skill-one");
842 std::fs::create_dir_all(&skill1_dir).unwrap();
843 std::fs::write(skill1_dir.join("SKILL.md"), "skill one content").unwrap();
844
845 let before = snapshot_existing_skills(dir.path());
847 assert_eq!(before.len(), 1);
848
849 let skill2_dir = dir.path().join("skill-two");
851 std::fs::create_dir_all(&skill2_dir).unwrap();
852 std::fs::write(skill2_dir.join("SKILL.md"), "skill two content").unwrap();
853
854 let result = find_created_skill(dir.path(), &before);
855 assert!(result.is_some());
856 let found = result.unwrap();
857 assert!(found.to_string_lossy().contains("skill-two"));
858 }
859
860 #[test]
861 fn test_find_created_skill_ignores_preexisting() {
862 let dir = tempfile::TempDir::new().unwrap();
863 let skill1_dir = dir.path().join("skill-one");
865 std::fs::create_dir_all(&skill1_dir).unwrap();
866 std::fs::write(skill1_dir.join("SKILL.md"), "skill one content").unwrap();
867
868 let before = snapshot_existing_skills(dir.path());
870 let result = find_created_skill(dir.path(), &before);
871 assert!(result.is_none());
872 }
873
874 #[test]
875 fn test_find_created_skill_returns_none_when_empty() {
876 let dir = tempfile::TempDir::new().unwrap();
877 let before = snapshot_existing_skills(dir.path());
878 let result = find_created_skill(dir.path(), &before);
879 assert!(result.is_none());
880 }
881
882 #[test]
883 fn test_skill_already_exists_at_target() {
884 let dir = tempfile::TempDir::new().unwrap();
885 let skills_dir = dir.path().join(".claude").join("skills");
886 let slug_dir = skills_dir.join("pre-pr-checklist-run");
888 std::fs::create_dir_all(&slug_dir).unwrap();
889 std::fs::write(slug_dir.join("SKILL.md"), "---\nname: pre-pr-checklist-run\n---\nExisting skill.").unwrap();
890
891 assert!(skill_exists_at_target(&skills_dir, "Pre-PR checklist: run tests"));
892 }
893
894 #[test]
895 fn test_skill_does_not_exist_at_target() {
896 let dir = tempfile::TempDir::new().unwrap();
897 let skills_dir = dir.path().join(".claude").join("skills");
898 std::fs::create_dir_all(&skills_dir).unwrap();
899
900 assert!(!skill_exists_at_target(&skills_dir, "Pre-PR checklist: run tests"));
901 }
902}