1use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use rustant_core::error::ToolError;
6use rustant_core::types::{RiskLevel, ToolOutput};
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use std::path::PathBuf;
10
11use crate::registry::Tool;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14struct PracticeEntry {
15 date: DateTime<Utc>,
16 duration_mins: u32,
17 notes: String,
18 proficiency_before: u32,
19 proficiency_after: u32,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23struct Skill {
24 id: usize,
25 name: String,
26 category: String,
27 proficiency_level: u32, target_level: u32,
29 last_practiced: Option<DateTime<Utc>>,
30 practice_log: Vec<PracticeEntry>,
31 resources: Vec<String>,
32 created_at: DateTime<Utc>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36struct Milestone {
37 name: String,
38 description: String,
39 target_proficiency: u32,
40 completed: bool,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44struct LearningPath {
45 id: usize,
46 name: String,
47 skill_ids: Vec<usize>,
48 milestones: Vec<Milestone>,
49 current_milestone: usize,
50}
51
52#[derive(Debug, Default, Serialize, Deserialize)]
53struct SkillState {
54 skills: Vec<Skill>,
55 learning_paths: Vec<LearningPath>,
56 next_skill_id: usize,
57 next_path_id: usize,
58}
59
60pub struct SkillTrackerTool {
61 workspace: PathBuf,
62}
63
64impl SkillTrackerTool {
65 pub fn new(workspace: PathBuf) -> Self {
66 Self { workspace }
67 }
68
69 fn state_path(&self) -> PathBuf {
70 self.workspace
71 .join(".rustant")
72 .join("skills")
73 .join("tracker.json")
74 }
75
76 fn load_state(&self) -> SkillState {
77 let path = self.state_path();
78 if path.exists() {
79 std::fs::read_to_string(&path)
80 .ok()
81 .and_then(|s| serde_json::from_str(&s).ok())
82 .unwrap_or_default()
83 } else {
84 SkillState {
85 skills: Vec::new(),
86 learning_paths: Vec::new(),
87 next_skill_id: 1,
88 next_path_id: 1,
89 }
90 }
91 }
92
93 fn save_state(&self, state: &SkillState) -> Result<(), ToolError> {
94 let path = self.state_path();
95 if let Some(parent) = path.parent() {
96 std::fs::create_dir_all(parent).map_err(|e| ToolError::ExecutionFailed {
97 name: "skill_tracker".to_string(),
98 message: format!("Failed to create state dir: {}", e),
99 })?;
100 }
101 let json = serde_json::to_string_pretty(state).map_err(|e| ToolError::ExecutionFailed {
102 name: "skill_tracker".to_string(),
103 message: format!("Failed to serialize state: {}", e),
104 })?;
105 let tmp = path.with_extension("json.tmp");
106 std::fs::write(&tmp, &json).map_err(|e| ToolError::ExecutionFailed {
107 name: "skill_tracker".to_string(),
108 message: format!("Failed to write state: {}", e),
109 })?;
110 std::fs::rename(&tmp, &path).map_err(|e| ToolError::ExecutionFailed {
111 name: "skill_tracker".to_string(),
112 message: format!("Failed to rename state file: {}", e),
113 })?;
114 Ok(())
115 }
116
117 fn flashcards_path(&self) -> PathBuf {
118 self.workspace
119 .join(".rustant")
120 .join("flashcards")
121 .join("cards.json")
122 }
123}
124
125#[async_trait]
126impl Tool for SkillTrackerTool {
127 fn name(&self) -> &str {
128 "skill_tracker"
129 }
130
131 fn description(&self) -> &str {
132 "Track skill progression, knowledge gaps, and learning paths. Actions: add_skill, log_practice, assess, list_skills, knowledge_gaps, learning_path, progress_report, daily_practice."
133 }
134
135 fn parameters_schema(&self) -> Value {
136 json!({
137 "type": "object",
138 "properties": {
139 "action": {
140 "type": "string",
141 "enum": ["add_skill", "log_practice", "assess", "list_skills", "knowledge_gaps", "learning_path", "progress_report", "daily_practice"],
142 "description": "Action to perform"
143 },
144 "name": { "type": "string", "description": "Skill name (for add_skill)" },
145 "category": { "type": "string", "description": "Skill category (for add_skill, list_skills filter)" },
146 "target_level": { "type": "integer", "description": "Target proficiency 0-100 (default: 80)" },
147 "resources": {
148 "type": "array",
149 "items": { "type": "string" },
150 "description": "Learning resources (URLs, book names, etc.)"
151 },
152 "skill_id": { "type": "integer", "description": "Skill ID (for log_practice, assess)" },
153 "duration_mins": { "type": "integer", "description": "Practice duration in minutes" },
154 "notes": { "type": "string", "description": "Practice session notes" },
155 "new_proficiency": { "type": "integer", "description": "Updated proficiency level 0-100 after practice" },
156 "sub_action": {
157 "type": "string",
158 "enum": ["create", "update", "show"],
159 "description": "Learning path sub-action"
160 },
161 "path_id": { "type": "integer", "description": "Learning path ID" },
162 "skill_ids": {
163 "type": "array",
164 "items": { "type": "integer" },
165 "description": "Skill IDs for learning path"
166 },
167 "milestones": {
168 "type": "array",
169 "items": {
170 "type": "object",
171 "properties": {
172 "name": { "type": "string" },
173 "description": { "type": "string" },
174 "target_proficiency": { "type": "integer" }
175 }
176 },
177 "description": "Milestones for learning path"
178 },
179 "current_milestone": { "type": "integer", "description": "Current milestone index (for update)" },
180 "period": {
181 "type": "string",
182 "enum": ["week", "month", "all"],
183 "description": "Report period (default: all)"
184 }
185 },
186 "required": ["action"]
187 })
188 }
189
190 fn risk_level(&self) -> RiskLevel {
191 RiskLevel::Write
192 }
193
194 async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
195 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
196 let mut state = self.load_state();
197
198 match action {
199 "add_skill" => {
200 let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("");
201 let category = args.get("category").and_then(|v| v.as_str()).unwrap_or("");
202 if name.is_empty() || category.is_empty() {
203 return Ok(ToolOutput::text(
204 "Provide both name and category for the skill.",
205 ));
206 }
207 let target_level = args
208 .get("target_level")
209 .and_then(|v| v.as_u64())
210 .unwrap_or(80) as u32;
211 let target_level = target_level.min(100);
212 let resources: Vec<String> = args
213 .get("resources")
214 .and_then(|v| v.as_array())
215 .map(|arr| {
216 arr.iter()
217 .filter_map(|v| v.as_str().map(|s| s.to_string()))
218 .collect()
219 })
220 .unwrap_or_default();
221
222 let id = state.next_skill_id;
223 state.next_skill_id += 1;
224 state.skills.push(Skill {
225 id,
226 name: name.to_string(),
227 category: category.to_string(),
228 proficiency_level: 0,
229 target_level,
230 last_practiced: None,
231 practice_log: Vec::new(),
232 resources,
233 created_at: Utc::now(),
234 });
235 self.save_state(&state)?;
236 Ok(ToolOutput::text(format!(
237 "Added skill #{}: '{}' [{}] with target level {}.",
238 id, name, category, target_level
239 )))
240 }
241
242 "log_practice" => {
243 let skill_id = args.get("skill_id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
244 let duration_mins = args
245 .get("duration_mins")
246 .and_then(|v| v.as_u64())
247 .unwrap_or(0) as u32;
248 if skill_id == 0 || duration_mins == 0 {
249 return Ok(ToolOutput::text(
250 "Provide skill_id and duration_mins (both > 0).",
251 ));
252 }
253 let notes = args
254 .get("notes")
255 .and_then(|v| v.as_str())
256 .unwrap_or("")
257 .to_string();
258 let new_proficiency = args
259 .get("new_proficiency")
260 .and_then(|v| v.as_u64())
261 .map(|v| (v as u32).min(100));
262
263 if let Some(skill) = state.skills.iter_mut().find(|s| s.id == skill_id) {
264 let proficiency_before = skill.proficiency_level;
265 let proficiency_after = new_proficiency.unwrap_or(proficiency_before);
266 skill.practice_log.push(PracticeEntry {
267 date: Utc::now(),
268 duration_mins,
269 notes: notes.clone(),
270 proficiency_before,
271 proficiency_after,
272 });
273 skill.last_practiced = Some(Utc::now());
274 if let Some(np) = new_proficiency {
275 skill.proficiency_level = np;
276 }
277 let skill_name = skill.name.clone();
278 let current = skill.proficiency_level;
279 self.save_state(&state)?;
280 Ok(ToolOutput::text(format!(
281 "Logged {} min practice for '{}'. Proficiency: {} -> {}.",
282 duration_mins, skill_name, proficiency_before, current
283 )))
284 } else {
285 Ok(ToolOutput::text(format!("Skill #{} not found.", skill_id)))
286 }
287 }
288
289 "assess" => {
290 let skill_id = args.get("skill_id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
291 if let Some(skill) = state.skills.iter().find(|s| s.id == skill_id) {
292 let mut prompt = format!(
293 "=== Self-Assessment: {} ===\n\
294 Category: {}\n\
295 Current Proficiency: {}/100\n\
296 Target Level: {}/100\n\
297 Gap: {} points\n",
298 skill.name,
299 skill.category,
300 skill.proficiency_level,
301 skill.target_level,
302 skill.target_level.saturating_sub(skill.proficiency_level)
303 );
304 if let Some(ref last) = skill.last_practiced {
305 prompt.push_str(&format!(
306 "Last Practiced: {}\n",
307 last.format("%Y-%m-%d %H:%M UTC")
308 ));
309 } else {
310 prompt.push_str("Last Practiced: Never\n");
311 }
312 if !skill.resources.is_empty() {
313 prompt.push_str(&format!("Resources: {}\n", skill.resources.join(", ")));
314 }
315 if !skill.practice_log.is_empty() {
316 let total_mins: u32 =
317 skill.practice_log.iter().map(|e| e.duration_mins).sum();
318 let sessions = skill.practice_log.len();
319 prompt.push_str(&format!(
320 "\nPractice History: {} sessions, {} total minutes\n",
321 sessions, total_mins
322 ));
323 prompt.push_str("Recent entries:\n");
324 for entry in skill.practice_log.iter().rev().take(5) {
325 prompt.push_str(&format!(
326 " - {} ({} min): {} [{}->{}]\n",
327 entry.date.format("%Y-%m-%d"),
328 entry.duration_mins,
329 if entry.notes.is_empty() {
330 "(no notes)"
331 } else {
332 &entry.notes
333 },
334 entry.proficiency_before,
335 entry.proficiency_after,
336 ));
337 }
338 }
339 prompt.push_str(
340 "\nReflection prompts:\n\
341 1. What specific sub-skills do you feel weakest in?\n\
342 2. What has been your most effective learning method?\n\
343 3. What obstacles are preventing faster progress?\n\
344 4. What is your next concrete milestone?",
345 );
346 Ok(ToolOutput::text(prompt))
347 } else {
348 Ok(ToolOutput::text(format!("Skill #{} not found.", skill_id)))
349 }
350 }
351
352 "list_skills" => {
353 let category_filter = args.get("category").and_then(|v| v.as_str());
354 let filtered: Vec<&Skill> = state
355 .skills
356 .iter()
357 .filter(|s| {
358 category_filter
359 .map(|c| s.category.eq_ignore_ascii_case(c))
360 .unwrap_or(true)
361 })
362 .collect();
363 if filtered.is_empty() {
364 return Ok(ToolOutput::text(if let Some(cat) = category_filter {
365 format!("No skills found in category '{}'.", cat)
366 } else {
367 "No skills tracked yet.".to_string()
368 }));
369 }
370 let mut lines = Vec::new();
371 for skill in &filtered {
372 let last = skill
373 .last_practiced
374 .map(|d| d.format("%Y-%m-%d").to_string())
375 .unwrap_or_else(|| "never".to_string());
376 lines.push(format!(
377 " #{} {} [{}]: {}/{} (last: {})",
378 skill.id,
379 skill.name,
380 skill.category,
381 skill.proficiency_level,
382 skill.target_level,
383 last
384 ));
385 }
386 Ok(ToolOutput::text(format!(
387 "Skills ({}):\n{}",
388 filtered.len(),
389 lines.join("\n")
390 )))
391 }
392
393 "knowledge_gaps" => {
394 let mut gaps: Vec<&Skill> = state
395 .skills
396 .iter()
397 .filter(|s| s.proficiency_level < s.target_level)
398 .collect();
399 if gaps.is_empty() {
400 return Ok(ToolOutput::text(
401 "No knowledge gaps — all skills are at or above target!",
402 ));
403 }
404 gaps.sort_by(|a, b| {
406 let gap_a = a.target_level - a.proficiency_level;
407 let gap_b = b.target_level - b.proficiency_level;
408 gap_b.cmp(&gap_a)
409 });
410 let now = Utc::now();
411 let stale_threshold = chrono::Duration::days(14);
412 let mut lines = Vec::new();
413 for skill in &gaps {
414 let gap = skill.target_level - skill.proficiency_level;
415 let stale = skill
416 .last_practiced
417 .map(|lp| (now - lp) > stale_threshold)
418 .unwrap_or(true);
419 let stale_marker = if stale { " [STALE]" } else { "" };
420 lines.push(format!(
421 " #{} {} [{}]: {}/{} (gap: {}){} ",
422 skill.id,
423 skill.name,
424 skill.category,
425 skill.proficiency_level,
426 skill.target_level,
427 gap,
428 stale_marker
429 ));
430 }
431 Ok(ToolOutput::text(format!(
432 "Knowledge gaps ({} skills below target):\n{}",
433 gaps.len(),
434 lines.join("\n")
435 )))
436 }
437
438 "learning_path" => {
439 let sub_action = args
440 .get("sub_action")
441 .and_then(|v| v.as_str())
442 .unwrap_or("");
443 match sub_action {
444 "create" => {
445 let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("");
446 if name.is_empty() {
447 return Ok(ToolOutput::text("Provide a name for the learning path."));
448 }
449 let skill_ids: Vec<usize> = args
450 .get("skill_ids")
451 .and_then(|v| v.as_array())
452 .map(|arr| {
453 arr.iter()
454 .filter_map(|v| v.as_u64().map(|n| n as usize))
455 .collect()
456 })
457 .unwrap_or_default();
458 if skill_ids.is_empty() {
459 return Ok(ToolOutput::text(
460 "Provide at least one skill_id for the learning path.",
461 ));
462 }
463 for &sid in &skill_ids {
465 if !state.skills.iter().any(|s| s.id == sid) {
466 return Ok(ToolOutput::text(format!("Skill #{} not found.", sid)));
467 }
468 }
469 let milestones: Vec<Milestone> = args
470 .get("milestones")
471 .and_then(|v| v.as_array())
472 .map(|arr| {
473 arr.iter()
474 .map(|m| Milestone {
475 name: m
476 .get("name")
477 .and_then(|v| v.as_str())
478 .unwrap_or("")
479 .to_string(),
480 description: m
481 .get("description")
482 .and_then(|v| v.as_str())
483 .unwrap_or("")
484 .to_string(),
485 target_proficiency: m
486 .get("target_proficiency")
487 .and_then(|v| v.as_u64())
488 .unwrap_or(50)
489 as u32,
490 completed: false,
491 })
492 .collect()
493 })
494 .unwrap_or_default();
495 let id = state.next_path_id;
496 state.next_path_id += 1;
497 state.learning_paths.push(LearningPath {
498 id,
499 name: name.to_string(),
500 skill_ids,
501 milestones,
502 current_milestone: 0,
503 });
504 self.save_state(&state)?;
505 Ok(ToolOutput::text(format!(
506 "Created learning path #{}: '{}'.",
507 id, name
508 )))
509 }
510 "show" => {
511 let path_id =
512 args.get("path_id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
513 if let Some(path) = state.learning_paths.iter().find(|p| p.id == path_id) {
514 let mut output =
515 format!("Learning Path #{}: '{}'\nSkills:\n", path.id, path.name);
516 for &sid in &path.skill_ids {
517 if let Some(skill) = state.skills.iter().find(|s| s.id == sid) {
518 output.push_str(&format!(
519 " #{} {} — {}/{}\n",
520 skill.id,
521 skill.name,
522 skill.proficiency_level,
523 skill.target_level
524 ));
525 } else {
526 output.push_str(&format!(" #{} (not found)\n", sid));
527 }
528 }
529 if !path.milestones.is_empty() {
530 output.push_str("Milestones:\n");
531 for (i, ms) in path.milestones.iter().enumerate() {
532 let marker = if ms.completed {
533 "[x]"
534 } else if i == path.current_milestone {
535 "[>]"
536 } else {
537 "[ ]"
538 };
539 output.push_str(&format!(
540 " {} {} — {} (target: {})\n",
541 marker, ms.name, ms.description, ms.target_proficiency
542 ));
543 }
544 }
545 Ok(ToolOutput::text(output))
546 } else {
547 Ok(ToolOutput::text(format!(
548 "Learning path #{} not found.",
549 path_id
550 )))
551 }
552 }
553 "update" => {
554 let path_id =
555 args.get("path_id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
556 if let Some(path) =
557 state.learning_paths.iter_mut().find(|p| p.id == path_id)
558 {
559 if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
560 path.name = name.to_string();
561 }
562 if let Some(milestones) =
563 args.get("milestones").and_then(|v| v.as_array())
564 {
565 path.milestones = milestones
566 .iter()
567 .map(|m| Milestone {
568 name: m
569 .get("name")
570 .and_then(|v| v.as_str())
571 .unwrap_or("")
572 .to_string(),
573 description: m
574 .get("description")
575 .and_then(|v| v.as_str())
576 .unwrap_or("")
577 .to_string(),
578 target_proficiency: m
579 .get("target_proficiency")
580 .and_then(|v| v.as_u64())
581 .unwrap_or(50)
582 as u32,
583 completed: m
584 .get("completed")
585 .and_then(|v| v.as_bool())
586 .unwrap_or(false),
587 })
588 .collect();
589 }
590 if let Some(cm) = args.get("current_milestone").and_then(|v| v.as_u64())
591 {
592 path.current_milestone = cm as usize;
593 }
594 let path_name = path.name.clone();
595 self.save_state(&state)?;
596 Ok(ToolOutput::text(format!(
597 "Updated learning path #{}: '{}'.",
598 path_id, path_name
599 )))
600 } else {
601 Ok(ToolOutput::text(format!(
602 "Learning path #{} not found.",
603 path_id
604 )))
605 }
606 }
607 _ => Ok(ToolOutput::text(format!(
608 "Unknown sub_action: '{}'. Use: create, show, update.",
609 sub_action
610 ))),
611 }
612 }
613
614 "progress_report" => {
615 let period = args.get("period").and_then(|v| v.as_str()).unwrap_or("all");
616 let now = Utc::now();
617 let cutoff = match period {
618 "week" => Some(now - chrono::Duration::days(7)),
619 "month" => Some(now - chrono::Duration::days(30)),
620 _ => None, };
622
623 let mut total_practice_mins: u32 = 0;
624 let mut skills_improved: usize = 0;
625 let mut sessions_count: usize = 0;
626
627 for skill in &state.skills {
628 let relevant_entries: Vec<&PracticeEntry> = skill
629 .practice_log
630 .iter()
631 .filter(|e| cutoff.map(|c| e.date >= c).unwrap_or(true))
632 .collect();
633 if !relevant_entries.is_empty() {
634 sessions_count += relevant_entries.len();
635 total_practice_mins += relevant_entries
636 .iter()
637 .map(|e| e.duration_mins)
638 .sum::<u32>();
639 if let (Some(first), Some(last)) =
641 (relevant_entries.first(), relevant_entries.last())
642 && last.proficiency_after > first.proficiency_before
643 {
644 skills_improved += 1;
645 }
646 }
647 }
648
649 let milestones_completed: usize = state
651 .learning_paths
652 .iter()
653 .flat_map(|p| p.milestones.iter())
654 .filter(|m| m.completed)
655 .count();
656
657 let hours = total_practice_mins / 60;
658 let mins = total_practice_mins % 60;
659 Ok(ToolOutput::text(format!(
660 "Progress Report ({}):\n\
661 \x20 Total practice time: {}h {}m ({} sessions)\n\
662 \x20 Skills improved: {}\n\
663 \x20 Milestones completed: {}\n\
664 \x20 Skills tracked: {}",
665 period,
666 hours,
667 mins,
668 sessions_count,
669 skills_improved,
670 milestones_completed,
671 state.skills.len()
672 )))
673 }
674
675 "daily_practice" => {
676 if state.skills.is_empty() {
677 return Ok(ToolOutput::text(
678 "No skills tracked yet. Add some skills first.",
679 ));
680 }
681
682 let now = Utc::now();
683 let stale_threshold = chrono::Duration::days(14);
684
685 let mut scored: Vec<(&Skill, u32)> = state
688 .skills
689 .iter()
690 .filter(|s| s.proficiency_level < s.target_level)
691 .map(|s| {
692 let gap = s.target_level - s.proficiency_level;
693 let stale = s
694 .last_practiced
695 .map(|lp| (now - lp) > stale_threshold)
696 .unwrap_or(true);
697 let stale_bonus: u32 = if stale { 25 } else { 0 };
698 (s, gap + stale_bonus)
699 })
700 .collect();
701
702 scored.sort_by(|a, b| b.1.cmp(&a.1));
703 let top3: Vec<&(&Skill, u32)> = scored.iter().take(3).collect();
704
705 if top3.is_empty() {
706 return Ok(ToolOutput::text(
707 "All skills are at target! Consider raising your targets or adding new skills.",
708 ));
709 }
710
711 let mut output = String::from("Daily Practice Suggestions:\n");
712 for (i, (skill, score)) in top3.iter().enumerate() {
713 let stale = skill
714 .last_practiced
715 .map(|lp| (now - lp) > stale_threshold)
716 .unwrap_or(true);
717 let stale_marker = if stale { " (STALE)" } else { "" };
718 output.push_str(&format!(
719 " {}. #{} {} [{}]: {}/{} (priority: {}){}\n",
720 i + 1,
721 skill.id,
722 skill.name,
723 skill.category,
724 skill.proficiency_level,
725 skill.target_level,
726 score,
727 stale_marker
728 ));
729 }
730
731 let flashcards_path = self.flashcards_path();
733 if flashcards_path.exists()
734 && let Ok(fc_data) = std::fs::read_to_string(&flashcards_path)
735 && let Ok(fc_state) = serde_json::from_str::<Value>(&fc_data)
736 && let Some(cards) = fc_state.get("cards").and_then(|v| v.as_array())
737 {
738 let decks: std::collections::HashSet<String> = cards
739 .iter()
740 .filter_map(|c| {
741 c.get("deck")
742 .and_then(|d| d.as_str())
743 .map(|s| s.to_lowercase())
744 })
745 .collect();
746 if !decks.is_empty() {
747 let mut relevant_decks = Vec::new();
748 for (skill, _) in &top3 {
749 let name_lower = skill.name.to_lowercase();
750 let cat_lower = skill.category.to_lowercase();
751 for deck in &decks {
752 if deck.contains(&name_lower)
753 || name_lower.contains(deck.as_str())
754 || deck.contains(&cat_lower)
755 || cat_lower.contains(deck.as_str())
756 {
757 relevant_decks.push(deck.clone());
758 }
759 }
760 }
761 relevant_decks.sort();
762 relevant_decks.dedup();
763 if !relevant_decks.is_empty() {
764 output.push_str(&format!(
765 "\nRelated flashcard decks: {}",
766 relevant_decks.join(", ")
767 ));
768 }
769 }
770 }
771
772 Ok(ToolOutput::text(output))
773 }
774
775 _ => Ok(ToolOutput::text(format!(
776 "Unknown action: '{}'. Use: add_skill, log_practice, assess, list_skills, knowledge_gaps, learning_path, progress_report, daily_practice.",
777 action
778 ))),
779 }
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786 use tempfile::TempDir;
787
788 fn make_tool() -> (TempDir, SkillTrackerTool) {
789 let dir = TempDir::new().unwrap();
790 let workspace = dir.path().canonicalize().unwrap();
791 let tool = SkillTrackerTool::new(workspace);
792 (dir, tool)
793 }
794
795 #[test]
796 fn test_tool_properties() {
797 let (_dir, tool) = make_tool();
798 assert_eq!(tool.name(), "skill_tracker");
799 assert_eq!(tool.risk_level(), RiskLevel::Write);
800 }
801
802 #[tokio::test]
803 async fn test_add_skill() {
804 let (_dir, tool) = make_tool();
805 let result = tool
806 .execute(json!({
807 "action": "add_skill",
808 "name": "Rust",
809 "category": "Programming",
810 "target_level": 90,
811 "resources": ["The Rust Book", "Rustlings"]
812 }))
813 .await
814 .unwrap();
815 assert!(result.content.contains("Added skill #1"));
816 assert!(result.content.contains("Rust"));
817 assert!(result.content.contains("90"));
818
819 let list = tool
821 .execute(json!({"action": "list_skills"}))
822 .await
823 .unwrap();
824 assert!(list.content.contains("Rust"));
825 assert!(list.content.contains("Programming"));
826 assert!(list.content.contains("0/90"));
827 }
828
829 #[tokio::test]
830 async fn test_log_practice_updates_proficiency() {
831 let (_dir, tool) = make_tool();
832 tool.execute(json!({
833 "action": "add_skill",
834 "name": "Python",
835 "category": "Programming"
836 }))
837 .await
838 .unwrap();
839
840 let result = tool
841 .execute(json!({
842 "action": "log_practice",
843 "skill_id": 1,
844 "duration_mins": 60,
845 "notes": "Worked on async/await",
846 "new_proficiency": 25
847 }))
848 .await
849 .unwrap();
850 assert!(result.content.contains("60 min"));
851 assert!(result.content.contains("Python"));
852 assert!(result.content.contains("0 -> 25"));
853
854 let list = tool
856 .execute(json!({"action": "list_skills"}))
857 .await
858 .unwrap();
859 assert!(list.content.contains("25/80"));
860 }
861
862 #[tokio::test]
863 async fn test_proficiency_clamping() {
864 let (_dir, tool) = make_tool();
865 tool.execute(json!({
866 "action": "add_skill",
867 "name": "Go",
868 "category": "Programming"
869 }))
870 .await
871 .unwrap();
872
873 let result = tool
874 .execute(json!({
875 "action": "log_practice",
876 "skill_id": 1,
877 "duration_mins": 30,
878 "new_proficiency": 150
879 }))
880 .await
881 .unwrap();
882 assert!(result.content.contains("0 -> 100"));
884
885 let list = tool
886 .execute(json!({"action": "list_skills"}))
887 .await
888 .unwrap();
889 assert!(list.content.contains("100/80"));
890 }
891
892 #[tokio::test]
893 async fn test_knowledge_gaps_sorted_by_gap() {
894 let (_dir, tool) = make_tool();
895 tool.execute(json!({
897 "action": "add_skill",
898 "name": "Small Gap",
899 "category": "A"
900 }))
901 .await
902 .unwrap();
903 tool.execute(json!({
904 "action": "log_practice",
905 "skill_id": 1,
906 "duration_mins": 10,
907 "new_proficiency": 50
908 }))
909 .await
910 .unwrap();
911
912 tool.execute(json!({
914 "action": "add_skill",
915 "name": "Big Gap",
916 "category": "B"
917 }))
918 .await
919 .unwrap();
920 tool.execute(json!({
921 "action": "log_practice",
922 "skill_id": 2,
923 "duration_mins": 10,
924 "new_proficiency": 10
925 }))
926 .await
927 .unwrap();
928
929 let result = tool
930 .execute(json!({"action": "knowledge_gaps"}))
931 .await
932 .unwrap();
933 let big_pos = result.content.find("Big Gap").unwrap();
935 let small_pos = result.content.find("Small Gap").unwrap();
936 assert!(big_pos < small_pos, "Bigger gap should be listed first");
937 }
938
939 #[tokio::test]
940 async fn test_daily_practice_suggestions() {
941 let (_dir, tool) = make_tool();
942 tool.execute(json!({
943 "action": "add_skill",
944 "name": "TypeScript",
945 "category": "Programming",
946 "target_level": 80
947 }))
948 .await
949 .unwrap();
950
951 let result = tool
952 .execute(json!({"action": "daily_practice"}))
953 .await
954 .unwrap();
955 assert!(result.content.contains("Daily Practice Suggestions"));
956 assert!(result.content.contains("TypeScript"));
957 }
958
959 #[tokio::test]
960 async fn test_learning_path_crud() {
961 let (_dir, tool) = make_tool();
962 tool.execute(json!({
964 "action": "add_skill",
965 "name": "HTML",
966 "category": "Web"
967 }))
968 .await
969 .unwrap();
970 tool.execute(json!({
971 "action": "add_skill",
972 "name": "CSS",
973 "category": "Web"
974 }))
975 .await
976 .unwrap();
977
978 let result = tool
980 .execute(json!({
981 "action": "learning_path",
982 "sub_action": "create",
983 "name": "Web Fundamentals",
984 "skill_ids": [1, 2],
985 "milestones": [
986 {"name": "Basics", "description": "Learn HTML/CSS basics", "target_proficiency": 40},
987 {"name": "Advanced", "description": "Build responsive layouts", "target_proficiency": 80}
988 ]
989 }))
990 .await
991 .unwrap();
992 assert!(result.content.contains("Created learning path #1"));
993 assert!(result.content.contains("Web Fundamentals"));
994
995 let result = tool
997 .execute(json!({
998 "action": "learning_path",
999 "sub_action": "show",
1000 "path_id": 1
1001 }))
1002 .await
1003 .unwrap();
1004 assert!(result.content.contains("Web Fundamentals"));
1005 assert!(result.content.contains("HTML"));
1006 assert!(result.content.contains("CSS"));
1007 assert!(result.content.contains("Basics"));
1008 assert!(result.content.contains("Advanced"));
1009
1010 let result = tool
1012 .execute(json!({
1013 "action": "learning_path",
1014 "sub_action": "update",
1015 "path_id": 1,
1016 "name": "Web Dev Path",
1017 "current_milestone": 1
1018 }))
1019 .await
1020 .unwrap();
1021 assert!(result.content.contains("Updated learning path #1"));
1022 assert!(result.content.contains("Web Dev Path"));
1023 }
1024
1025 #[tokio::test]
1026 async fn test_assess_returns_prompt() {
1027 let (_dir, tool) = make_tool();
1028 tool.execute(json!({
1029 "action": "add_skill",
1030 "name": "Machine Learning",
1031 "category": "Data Science",
1032 "resources": ["Coursera ML Course"]
1033 }))
1034 .await
1035 .unwrap();
1036
1037 let result = tool
1038 .execute(json!({
1039 "action": "assess",
1040 "skill_id": 1
1041 }))
1042 .await
1043 .unwrap();
1044 assert!(result.content.contains("Self-Assessment: Machine Learning"));
1045 assert!(result.content.contains("Category: Data Science"));
1046 assert!(result.content.contains("Current Proficiency: 0/100"));
1047 assert!(result.content.contains("Target Level: 80/100"));
1048 assert!(result.content.contains("Reflection prompts"));
1049 assert!(result.content.contains("Coursera ML Course"));
1050 }
1051
1052 #[tokio::test]
1053 async fn test_progress_report_empty() {
1054 let (_dir, tool) = make_tool();
1055 let result = tool
1056 .execute(json!({"action": "progress_report"}))
1057 .await
1058 .unwrap();
1059 assert!(result.content.contains("Progress Report"));
1060 assert!(result.content.contains("0h 0m"));
1061 assert!(result.content.contains("Skills improved: 0"));
1062 assert!(result.content.contains("Milestones completed: 0"));
1063 assert!(result.content.contains("Skills tracked: 0"));
1064 }
1065
1066 #[tokio::test]
1067 async fn test_list_skills_filter_category() {
1068 let (_dir, tool) = make_tool();
1069 tool.execute(json!({
1070 "action": "add_skill",
1071 "name": "Rust",
1072 "category": "Programming"
1073 }))
1074 .await
1075 .unwrap();
1076 tool.execute(json!({
1077 "action": "add_skill",
1078 "name": "Piano",
1079 "category": "Music"
1080 }))
1081 .await
1082 .unwrap();
1083
1084 let result = tool
1086 .execute(json!({"action": "list_skills", "category": "Programming"}))
1087 .await
1088 .unwrap();
1089 assert!(result.content.contains("Rust"));
1090 assert!(!result.content.contains("Piano"));
1091
1092 let result = tool
1094 .execute(json!({"action": "list_skills", "category": "Music"}))
1095 .await
1096 .unwrap();
1097 assert!(!result.content.contains("Rust"));
1098 assert!(result.content.contains("Piano"));
1099
1100 let result = tool
1102 .execute(json!({"action": "list_skills"}))
1103 .await
1104 .unwrap();
1105 assert!(result.content.contains("Rust"));
1106 assert!(result.content.contains("Piano"));
1107 }
1108
1109 #[test]
1110 fn test_schema_validation() {
1111 let (_dir, tool) = make_tool();
1112 let schema = tool.parameters_schema();
1113 assert!(schema.is_object());
1114 assert!(schema.get("properties").is_some());
1115 let props = schema.get("properties").unwrap();
1116 assert!(props.get("action").is_some());
1117 assert!(props.get("name").is_some());
1118 assert!(props.get("category").is_some());
1119 assert!(props.get("skill_id").is_some());
1120 assert!(props.get("duration_mins").is_some());
1121 assert!(props.get("sub_action").is_some());
1122 assert!(props.get("period").is_some());
1123 let required = schema.get("required").unwrap().as_array().unwrap();
1124 assert_eq!(required.len(), 1);
1125 assert_eq!(required[0].as_str().unwrap(), "action");
1126 }
1127
1128 #[tokio::test]
1129 async fn test_state_roundtrip() {
1130 let (_dir, tool) = make_tool();
1131 tool.execute(json!({
1133 "action": "add_skill",
1134 "name": "Rust",
1135 "category": "Programming",
1136 "target_level": 90
1137 }))
1138 .await
1139 .unwrap();
1140 tool.execute(json!({
1141 "action": "log_practice",
1142 "skill_id": 1,
1143 "duration_mins": 45,
1144 "new_proficiency": 30
1145 }))
1146 .await
1147 .unwrap();
1148 tool.execute(json!({
1149 "action": "learning_path",
1150 "sub_action": "create",
1151 "name": "Rust Mastery",
1152 "skill_ids": [1]
1153 }))
1154 .await
1155 .unwrap();
1156
1157 let state = tool.load_state();
1159 assert_eq!(state.skills.len(), 1);
1160 assert_eq!(state.skills[0].name, "Rust");
1161 assert_eq!(state.skills[0].proficiency_level, 30);
1162 assert_eq!(state.skills[0].practice_log.len(), 1);
1163 assert_eq!(state.learning_paths.len(), 1);
1164 assert_eq!(state.learning_paths[0].name, "Rust Mastery");
1165 assert_eq!(state.next_skill_id, 2);
1166 assert_eq!(state.next_path_id, 2);
1167 }
1168
1169 #[tokio::test]
1170 async fn test_unknown_action() {
1171 let (_dir, tool) = make_tool();
1172 let result = tool
1173 .execute(json!({"action": "nonexistent"}))
1174 .await
1175 .unwrap();
1176 assert!(result.content.contains("Unknown action"));
1177 assert!(result.content.contains("nonexistent"));
1178 }
1179}