Skip to main content

rustant_tools/
skill_tracker.rs

1//! Skill tracker tool — track skill progression, knowledge gaps, and learning paths.
2
3use 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, // 0-100
28    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                // Sort by biggest gap first
405                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                        // Validate skill IDs exist
464                        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, // "all"
621                };
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                        // Check if proficiency improved during this period
640                        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                // Count completed milestones across all learning paths
650                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                // Score each skill: higher score = higher priority
686                // Score = gap_size + stale_bonus
687                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                // Cross-reference with flashcards if available
732                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        // Verify it appears in list
820        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        // Verify proficiency updated in list
855        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        // Should be clamped to 100
883        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        // Skill 1: gap = 80 - 50 = 30
896        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        // Skill 2: gap = 80 - 10 = 70
913        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        // Big Gap (gap=70) should appear before Small Gap (gap=30)
934        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        // Add skills first
963        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        // Create path
979        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        // Show path
996        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        // Update path
1011        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        // Filter by Programming
1085        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        // Filter by Music
1093        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        // No filter — both present
1101        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        // Add skill, log practice, create path
1132        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        // Verify state persists by loading fresh
1158        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}