chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Learn — CLI Correction Detection
|
||||
|
||||
> See also [docs/contributing/TECHNICAL.md](../../docs/contributing/TECHNICAL.md) for the full architecture overview
|
||||
|
||||
## Purpose
|
||||
|
||||
Analyzes Claude Code session history to detect recurring CLI mistakes — commands that fail then get corrected by the agent. Powers the `rtk learn` command, which identifies error patterns (unknown flags, wrong paths, missing args) and can auto-generate `.claude/rules/cli-corrections.md` to prevent them.
|
||||
|
||||
## Key Types
|
||||
|
||||
- **`ErrorType`** — `UnknownFlag`, `CommandNotFound`, `WrongSyntax`, `WrongPath`, `MissingArg`, `PermissionDenied`, `Other(String)`
|
||||
- **`CorrectionPair`** — Raw detection: wrong command + right command + error output + confidence score
|
||||
- **`CorrectionRule`** — Deduplicated pattern: wrong pattern + right pattern + occurrence count + base command
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Uses**: `discover::provider::ClaudeProvider` (session file discovery and command extraction), `lazy_static`/`regex` (error pattern matching), `serde_json` (JSON output)
|
||||
- **Used by**: `src/main.rs` (routes `rtk learn` command)
|
||||
|
||||
## Detection Algorithm
|
||||
|
||||
1. Extract all commands from JSONL sessions via `ClaudeProvider`
|
||||
2. Scan chronologically for fail-then-succeed pairs (same base command, first has error output, second succeeds)
|
||||
3. Classify the error type using regex patterns on the error output
|
||||
4. Assign confidence scores based on similarity and error clarity
|
||||
5. Deduplicate into rules (merge identical wrong->right patterns, count occurrences)
|
||||
6. Filter by `--min-confidence` and `--min-occurrences` thresholds
|
||||
@@ -0,0 +1,629 @@
|
||||
//! Pattern-matches CLI errors against known correction rules.
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ErrorType {
|
||||
UnknownFlag,
|
||||
CommandNotFound,
|
||||
#[allow(dead_code)]
|
||||
WrongSyntax,
|
||||
WrongPath,
|
||||
MissingArg,
|
||||
PermissionDenied,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl ErrorType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ErrorType::UnknownFlag => "Unknown Flag",
|
||||
ErrorType::CommandNotFound => "Command Not Found",
|
||||
ErrorType::WrongSyntax => "Wrong Syntax",
|
||||
ErrorType::WrongPath => "Wrong Path",
|
||||
ErrorType::MissingArg => "Missing Argument",
|
||||
ErrorType::PermissionDenied => "Permission Denied",
|
||||
ErrorType::Other(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorrectionPair {
|
||||
pub wrong_command: String,
|
||||
pub right_command: String,
|
||||
pub error_output: String,
|
||||
pub error_type: ErrorType,
|
||||
pub confidence: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorrectionRule {
|
||||
pub wrong_pattern: String,
|
||||
pub right_pattern: String,
|
||||
pub error_type: ErrorType,
|
||||
pub occurrences: usize,
|
||||
pub base_command: String,
|
||||
pub example_error: String,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref UNKNOWN_FLAG_RE: Regex = Regex::new(
|
||||
r"(?i)(unexpected argument|unknown (option|flag)|unrecognized (option|flag)|invalid (option|flag))"
|
||||
).unwrap();
|
||||
|
||||
static ref CMD_NOT_FOUND_RE: Regex = Regex::new(
|
||||
r"(?i)(command not found|not recognized as an internal|no such file or directory.*command)"
|
||||
).unwrap();
|
||||
|
||||
static ref WRONG_PATH_RE: Regex = Regex::new(
|
||||
r"(?i)(no such file or directory|cannot find the path|file not found)"
|
||||
).unwrap();
|
||||
|
||||
static ref MISSING_ARG_RE: Regex = Regex::new(
|
||||
r"(?i)(requires a value|requires an argument|missing (required )?argument|expected.*argument)"
|
||||
).unwrap();
|
||||
|
||||
static ref PERMISSION_DENIED_RE: Regex = Regex::new(
|
||||
r"(?i)(permission denied|access denied|not permitted)"
|
||||
).unwrap();
|
||||
|
||||
// User rejection patterns - NOT actual errors
|
||||
static ref USER_REJECTION_RE: Regex = Regex::new(
|
||||
r"(?i)(user (doesn't want|declined|rejected|cancelled)|operation (cancelled|aborted) by user)"
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
/// Filters out user rejections - requires actual error-indicating content
|
||||
pub fn is_command_error(is_error: bool, output: &str) -> bool {
|
||||
if !is_error {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject if it's a user rejection
|
||||
if USER_REJECTION_RE.is_match(output) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must contain error-indicating content
|
||||
let output_lower = output.to_lowercase();
|
||||
output_lower.contains("error")
|
||||
|| output_lower.contains("failed")
|
||||
|| output_lower.contains("unknown")
|
||||
|| output_lower.contains("invalid")
|
||||
|| output_lower.contains("not found")
|
||||
|| output_lower.contains("permission denied")
|
||||
|| output_lower.contains("cannot")
|
||||
}
|
||||
|
||||
pub fn classify_error(output: &str) -> ErrorType {
|
||||
if UNKNOWN_FLAG_RE.is_match(output) {
|
||||
ErrorType::UnknownFlag
|
||||
} else if CMD_NOT_FOUND_RE.is_match(output) {
|
||||
ErrorType::CommandNotFound
|
||||
} else if MISSING_ARG_RE.is_match(output) {
|
||||
ErrorType::MissingArg
|
||||
} else if PERMISSION_DENIED_RE.is_match(output) {
|
||||
ErrorType::PermissionDenied
|
||||
} else if WRONG_PATH_RE.is_match(output) {
|
||||
ErrorType::WrongPath
|
||||
} else {
|
||||
ErrorType::Other("General Error".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a command with its execution result for correction detection
|
||||
pub struct CommandExecution {
|
||||
pub command: String,
|
||||
pub is_error: bool,
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
const CORRECTION_WINDOW: usize = 3;
|
||||
const MIN_CONFIDENCE: f64 = 0.6;
|
||||
|
||||
/// Extract base command (first 1-2 tokens, stripping env prefixes)
|
||||
pub fn extract_base_command(cmd: &str) -> String {
|
||||
let trimmed = cmd.trim();
|
||||
|
||||
// Strip common env prefixes
|
||||
let stripped = trimmed
|
||||
.strip_prefix("RUST_BACKTRACE=1 ")
|
||||
.or_else(|| trimmed.strip_prefix("NODE_ENV=production "))
|
||||
.or_else(|| trimmed.strip_prefix("DEBUG=* "))
|
||||
.unwrap_or(trimmed);
|
||||
|
||||
// Get first 1-2 tokens
|
||||
let parts: Vec<&str> = stripped.split_whitespace().collect();
|
||||
match parts.len() {
|
||||
0 => String::new(),
|
||||
1 => parts[0].to_string(),
|
||||
_ => format!("{} {}", parts[0], parts[1]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate similarity between two commands using Jaccard similarity
|
||||
/// Same base command = 0.5 base score + up to 0.5 from argument similarity
|
||||
pub fn command_similarity(a: &str, b: &str) -> f64 {
|
||||
let base_a = extract_base_command(a);
|
||||
let base_b = extract_base_command(b);
|
||||
|
||||
if base_a != base_b {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Extract args (everything after base command)
|
||||
let args_a: std::collections::HashSet<&str> = a
|
||||
.strip_prefix(&base_a)
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.collect();
|
||||
|
||||
let args_b: std::collections::HashSet<&str> = b
|
||||
.strip_prefix(&base_b)
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.collect();
|
||||
|
||||
if args_a.is_empty() && args_b.is_empty() {
|
||||
return 1.0; // Identical commands
|
||||
}
|
||||
|
||||
let intersection = args_a.intersection(&args_b).count();
|
||||
let union = args_a.union(&args_b).count();
|
||||
|
||||
if union == 0 {
|
||||
return 0.5; // Same base, no args
|
||||
}
|
||||
|
||||
// 0.5 for same base + up to 0.5 for arg similarity
|
||||
0.5 + (intersection as f64 / union as f64) * 0.5
|
||||
}
|
||||
|
||||
/// Check if error is a compilation/test error (TDD cycle, not CLI correction)
|
||||
fn is_tdd_cycle_error(error_type: &ErrorType, output: &str) -> bool {
|
||||
// Compilation errors
|
||||
if output.contains("error[E") || output.contains("aborting due to") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Test failures
|
||||
if output.contains("test result: FAILED") || output.contains("tests failed") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only syntax errors are CLI corrections
|
||||
matches!(error_type, ErrorType::CommandNotFound | ErrorType::Other(_))
|
||||
&& (output.contains("error[E") || output.contains("FAILED"))
|
||||
}
|
||||
|
||||
/// Check if commands differ only by path (exploration, not correction)
|
||||
fn differs_only_by_path(a: &str, b: &str) -> bool {
|
||||
let base_a = extract_base_command(a);
|
||||
let base_b = extract_base_command(b);
|
||||
|
||||
if base_a != base_b {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Simple heuristic: if similarity is very high (>0.9) but not identical,
|
||||
// likely just path differences
|
||||
let sim = command_similarity(a, b);
|
||||
sim > 0.9 && sim < 1.0
|
||||
}
|
||||
|
||||
pub fn find_corrections(commands: &[CommandExecution]) -> Vec<CorrectionPair> {
|
||||
let mut corrections = Vec::new();
|
||||
|
||||
for i in 0..commands.len() {
|
||||
let cmd = &commands[i];
|
||||
|
||||
// Must be an actual error
|
||||
if !is_command_error(cmd.is_error, &cmd.output) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let error_type = classify_error(&cmd.output);
|
||||
|
||||
// Skip TDD cycle errors
|
||||
if is_tdd_cycle_error(&error_type, &cmd.output) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look ahead for correction within CORRECTION_WINDOW
|
||||
for candidate in commands.iter().skip(i + 1).take(CORRECTION_WINDOW) {
|
||||
let similarity = command_similarity(&cmd.command, &candidate.command);
|
||||
|
||||
// Must meet minimum similarity
|
||||
if similarity < 0.5 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if only path differs (exploration)
|
||||
if differs_only_by_path(&cmd.command, &candidate.command) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if identical commands (same error repeated)
|
||||
if cmd.command == candidate.command {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate confidence
|
||||
let mut confidence = similarity;
|
||||
|
||||
// Boost confidence if correction succeeded
|
||||
if !is_command_error(candidate.is_error, &candidate.output) {
|
||||
confidence = (confidence + 0.2).min(1.0);
|
||||
}
|
||||
|
||||
// Must meet minimum confidence
|
||||
if confidence < MIN_CONFIDENCE {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found a correction!
|
||||
corrections.push(CorrectionPair {
|
||||
wrong_command: cmd.command.clone(),
|
||||
right_command: candidate.command.clone(),
|
||||
error_output: cmd.output.chars().take(500).collect(),
|
||||
error_type: error_type.clone(),
|
||||
confidence,
|
||||
});
|
||||
|
||||
// Take first match only
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
corrections
|
||||
}
|
||||
|
||||
/// Extract the specific token that changed between wrong and right commands
|
||||
fn extract_diff_token(wrong: &str, right: &str) -> String {
|
||||
let wrong_parts: std::collections::HashSet<&str> = wrong.split_whitespace().collect();
|
||||
let right_parts: std::collections::HashSet<&str> = right.split_whitespace().collect();
|
||||
|
||||
// Find tokens in wrong but not in right (removed)
|
||||
let removed: Vec<&str> = wrong_parts.difference(&right_parts).copied().collect();
|
||||
|
||||
// Find tokens in right but not in wrong (added)
|
||||
let added: Vec<&str> = right_parts.difference(&wrong_parts).copied().collect();
|
||||
|
||||
// Return the most distinctive change
|
||||
if !removed.is_empty() && !added.is_empty() {
|
||||
format!("{} → {}", removed[0], added[0])
|
||||
} else if !removed.is_empty() {
|
||||
format!("removed {}", removed[0])
|
||||
} else if !added.is_empty() {
|
||||
format!("added {}", added[0])
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deduplicate_corrections(pairs: Vec<CorrectionPair>) -> Vec<CorrectionRule> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut groups: HashMap<(String, String, String), Vec<CorrectionPair>> = HashMap::new();
|
||||
|
||||
// Group by (base_command, error_type, diff_token)
|
||||
for pair in pairs {
|
||||
let base = extract_base_command(&pair.wrong_command);
|
||||
let error_type_str = pair.error_type.as_str().to_string();
|
||||
let diff_token = extract_diff_token(&pair.wrong_command, &pair.right_command);
|
||||
|
||||
let key = (base, error_type_str, diff_token);
|
||||
groups.entry(key).or_default().push(pair);
|
||||
}
|
||||
|
||||
// For each group, keep the best confidence example
|
||||
let mut rules = Vec::new();
|
||||
for ((base_command, _error_type_str, _diff_token), mut group) in groups {
|
||||
// Sort by confidence descending
|
||||
group.sort_by(|a, b| {
|
||||
b.confidence
|
||||
.partial_cmp(&a.confidence)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
let best = &group[0];
|
||||
let occurrences = group.len();
|
||||
|
||||
// Reconstruct ErrorType from string (simplified - just use first one)
|
||||
let error_type = best.error_type.clone();
|
||||
|
||||
rules.push(CorrectionRule {
|
||||
wrong_pattern: best.wrong_command.clone(),
|
||||
right_pattern: best.right_command.clone(),
|
||||
error_type,
|
||||
occurrences,
|
||||
base_command,
|
||||
example_error: best.error_output.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by occurrences descending (most common mistakes first)
|
||||
rules.sort_by_key(|b| std::cmp::Reverse(b.occurrences));
|
||||
|
||||
rules
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_command_error_requires_error_flag() {
|
||||
assert!(!is_command_error(false, "error: unknown flag"));
|
||||
assert!(is_command_error(true, "error: unknown flag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_command_error_filters_user_rejection() {
|
||||
assert!(!is_command_error(true, "The user doesn't want to proceed"));
|
||||
assert!(!is_command_error(true, "Operation cancelled by user"));
|
||||
assert!(is_command_error(true, "error: permission denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_command_error_requires_error_content() {
|
||||
assert!(!is_command_error(true, "All good, success!"));
|
||||
assert!(is_command_error(true, "error: something failed"));
|
||||
assert!(is_command_error(true, "unknown flag --foo"));
|
||||
assert!(is_command_error(true, "invalid option"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_error_unknown_flag() {
|
||||
assert_eq!(
|
||||
classify_error("error: unexpected argument '--foo'"),
|
||||
ErrorType::UnknownFlag
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error("unknown option: --bar"),
|
||||
ErrorType::UnknownFlag
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error("unrecognized flag: -x"),
|
||||
ErrorType::UnknownFlag
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_error_command_not_found() {
|
||||
assert_eq!(
|
||||
classify_error("bash: foobar: command not found"),
|
||||
ErrorType::CommandNotFound
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error("'xyz' is not recognized as an internal or external command"),
|
||||
ErrorType::CommandNotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_error_all_types() {
|
||||
assert_eq!(
|
||||
classify_error("No such file or directory: foo.txt"),
|
||||
ErrorType::WrongPath
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error("error: --output requires a value"),
|
||||
ErrorType::MissingArg
|
||||
);
|
||||
assert_eq!(
|
||||
classify_error("permission denied: /etc/shadow"),
|
||||
ErrorType::PermissionDenied
|
||||
);
|
||||
assert!(matches!(
|
||||
classify_error("something went wrong"),
|
||||
ErrorType::Other(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_command() {
|
||||
assert_eq!(extract_base_command("git commit"), "git commit");
|
||||
assert_eq!(extract_base_command("cargo test"), "cargo test");
|
||||
assert_eq!(
|
||||
extract_base_command("git commit --amend -m 'fix'"),
|
||||
"git commit"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_base_command("RUST_BACKTRACE=1 cargo test"),
|
||||
"cargo test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_similarity_same_base() {
|
||||
assert_eq!(command_similarity("git commit", "git commit"), 1.0);
|
||||
assert_eq!(command_similarity("git status", "npm install"), 0.0);
|
||||
let sim = command_similarity("git commit --amend", "git commit --ammend");
|
||||
// Debug: check what similarity actually is
|
||||
println!("Similarity: {}", sim);
|
||||
// Same base (0.5) + both have 1 arg, 0 intersection = 0.5 + 0 = 0.5
|
||||
assert_eq!(sim, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_corrections_basic() {
|
||||
let commands = vec![
|
||||
CommandExecution {
|
||||
command: "git commit --ammend".to_string(),
|
||||
is_error: true,
|
||||
output: "error: unexpected argument '--ammend'".to_string(),
|
||||
},
|
||||
CommandExecution {
|
||||
command: "git commit --amend".to_string(),
|
||||
is_error: false,
|
||||
output: "[main abc123] Fix bug".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let corrections = find_corrections(&commands);
|
||||
assert_eq!(corrections.len(), 1);
|
||||
assert_eq!(corrections[0].wrong_command, "git commit --ammend");
|
||||
assert_eq!(corrections[0].right_command, "git commit --amend");
|
||||
assert!(corrections[0].confidence >= 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_corrections_window_limit() {
|
||||
let commands = vec![
|
||||
CommandExecution {
|
||||
command: "git commit --ammend".to_string(),
|
||||
is_error: true,
|
||||
output: "error: unexpected argument '--ammend'".to_string(),
|
||||
},
|
||||
CommandExecution {
|
||||
command: "ls".to_string(),
|
||||
is_error: false,
|
||||
output: "file1.txt\nfile2.txt".to_string(),
|
||||
},
|
||||
CommandExecution {
|
||||
command: "pwd".to_string(),
|
||||
is_error: false,
|
||||
output: "/home/user".to_string(),
|
||||
},
|
||||
CommandExecution {
|
||||
command: "echo test".to_string(),
|
||||
is_error: false,
|
||||
output: "test".to_string(),
|
||||
},
|
||||
// Outside CORRECTION_WINDOW (3)
|
||||
CommandExecution {
|
||||
command: "git commit --amend".to_string(),
|
||||
is_error: false,
|
||||
output: "[main abc123] Fix".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let corrections = find_corrections(&commands);
|
||||
assert_eq!(corrections.len(), 0); // Too far apart
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_corrections_excludes_tdd_cycle() {
|
||||
let commands = vec![
|
||||
CommandExecution {
|
||||
command: "cargo test".to_string(),
|
||||
is_error: true,
|
||||
output: "error[E0425]: cannot find value `x`\ntest result: FAILED".to_string(),
|
||||
},
|
||||
CommandExecution {
|
||||
command: "cargo test".to_string(),
|
||||
is_error: false,
|
||||
output: "test result: ok. 5 passed".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let corrections = find_corrections(&commands);
|
||||
assert_eq!(corrections.len(), 0); // TDD cycle, not CLI correction
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_corrections_path_exploration() {
|
||||
let commands = vec![
|
||||
CommandExecution {
|
||||
command: "cat file1.txt".to_string(),
|
||||
is_error: true,
|
||||
output: "cat: file1.txt: No such file or directory".to_string(),
|
||||
},
|
||||
CommandExecution {
|
||||
command: "cat file2.txt".to_string(),
|
||||
is_error: false,
|
||||
output: "content here".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let corrections = find_corrections(&commands);
|
||||
// Should be filtered as path exploration (differs_only_by_path)
|
||||
// Actually, this should NOT be filtered since base commands differ enough
|
||||
// Let me adjust: they have same base "cat" but different args
|
||||
assert_eq!(corrections.len(), 0); // Different files = exploration
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_corrections_min_confidence() {
|
||||
let commands = vec![
|
||||
CommandExecution {
|
||||
command: "git commit --foo --bar --baz".to_string(),
|
||||
is_error: true,
|
||||
output: "error: unexpected argument '--foo'".to_string(),
|
||||
},
|
||||
CommandExecution {
|
||||
command: "git commit --qux".to_string(),
|
||||
is_error: false,
|
||||
output: "[main abc123] Fix".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let corrections = find_corrections(&commands);
|
||||
// Similarity = 0.5 (same base) + 0 (no arg overlap) = 0.5
|
||||
// With success boost: 0.5 + 0.2 = 0.7, which passes MIN_CONFIDENCE
|
||||
// So we expect 1 correction (this is a valid correction despite different args)
|
||||
assert_eq!(corrections.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplicate_corrections_merges_same() {
|
||||
let pairs = vec![
|
||||
CorrectionPair {
|
||||
wrong_command: "git commit --ammend".to_string(),
|
||||
right_command: "git commit --amend".to_string(),
|
||||
error_output: "error: unexpected argument '--ammend'".to_string(),
|
||||
error_type: ErrorType::UnknownFlag,
|
||||
confidence: 0.8,
|
||||
},
|
||||
CorrectionPair {
|
||||
wrong_command: "git commit --ammend -m 'fix'".to_string(),
|
||||
right_command: "git commit --amend -m 'fix'".to_string(),
|
||||
error_output: "error: unexpected argument '--ammend'".to_string(),
|
||||
error_type: ErrorType::UnknownFlag,
|
||||
confidence: 0.9,
|
||||
},
|
||||
CorrectionPair {
|
||||
wrong_command: "git commit --ammend".to_string(),
|
||||
right_command: "git commit --amend".to_string(),
|
||||
error_output: "error: unexpected argument '--ammend'".to_string(),
|
||||
error_type: ErrorType::UnknownFlag,
|
||||
confidence: 0.7,
|
||||
},
|
||||
];
|
||||
|
||||
let rules = deduplicate_corrections(pairs);
|
||||
assert_eq!(rules.len(), 1); // Merged into single rule
|
||||
assert_eq!(rules[0].occurrences, 3);
|
||||
assert_eq!(rules[0].base_command, "git commit");
|
||||
// Should keep highest confidence example (0.9)
|
||||
assert!(rules[0].wrong_pattern.contains("'fix'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplicate_corrections_keeps_distinct() {
|
||||
let pairs = vec![
|
||||
CorrectionPair {
|
||||
wrong_command: "git commit --ammend".to_string(),
|
||||
right_command: "git commit --amend".to_string(),
|
||||
error_output: "error: unexpected argument '--ammend'".to_string(),
|
||||
error_type: ErrorType::UnknownFlag,
|
||||
confidence: 0.8,
|
||||
},
|
||||
CorrectionPair {
|
||||
wrong_command: "git push --force".to_string(),
|
||||
right_command: "git push --force-with-lease".to_string(),
|
||||
error_output: "error: --force is dangerous".to_string(),
|
||||
error_type: ErrorType::WrongSyntax,
|
||||
confidence: 0.7,
|
||||
},
|
||||
];
|
||||
|
||||
let rules = deduplicate_corrections(pairs);
|
||||
assert_eq!(rules.len(), 2); // Different base commands and errors
|
||||
assert_eq!(rules[0].occurrences, 1);
|
||||
assert_eq!(rules[1].occurrences, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Watches for repeated CLI mistakes in coding sessions and suggests corrections.
|
||||
|
||||
pub mod detector;
|
||||
pub mod report;
|
||||
|
||||
use crate::discover::provider::{ClaudeProvider, SessionProvider};
|
||||
use anyhow::Result;
|
||||
use detector::{deduplicate_corrections, find_corrections, CommandExecution};
|
||||
use report::{format_console_report, write_rules_file};
|
||||
|
||||
pub fn run(
|
||||
project: Option<String>,
|
||||
all: bool,
|
||||
since: u64,
|
||||
format: String,
|
||||
write_rules: bool,
|
||||
min_confidence: f64,
|
||||
min_occurrences: usize,
|
||||
) -> Result<()> {
|
||||
let provider = ClaudeProvider;
|
||||
|
||||
// Determine project filter (same logic as discover)
|
||||
let project_filter = if all {
|
||||
None
|
||||
} else if let Some(p) = project {
|
||||
Some(p)
|
||||
} else {
|
||||
// Default: current working directory
|
||||
let cwd = std::env::current_dir()?;
|
||||
let cwd_str = cwd.to_string_lossy().to_string();
|
||||
let encoded = ClaudeProvider::encode_project_path(&cwd_str);
|
||||
Some(encoded)
|
||||
};
|
||||
|
||||
// Discover sessions
|
||||
let sessions = provider.discover_sessions(project_filter.as_deref(), Some(since))?;
|
||||
|
||||
if sessions.is_empty() {
|
||||
println!("No Claude Code sessions found in the last {} days.", since);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Extract commands from all sessions
|
||||
let mut all_commands: Vec<CommandExecution> = Vec::new();
|
||||
|
||||
for session_path in &sessions {
|
||||
let extracted = match provider.extract_commands(session_path) {
|
||||
Ok(cmds) => cmds,
|
||||
Err(_) => continue, // Skip malformed sessions
|
||||
};
|
||||
|
||||
for ext_cmd in extracted {
|
||||
// Only process commands with output content
|
||||
if let Some(output) = ext_cmd.output_content {
|
||||
all_commands.push(CommandExecution {
|
||||
command: ext_cmd.command,
|
||||
is_error: ext_cmd.is_error,
|
||||
output,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by sequence index to maintain chronological order
|
||||
// (already sorted by extraction order within each session)
|
||||
|
||||
// Find corrections
|
||||
let corrections = find_corrections(&all_commands);
|
||||
|
||||
if corrections.is_empty() {
|
||||
println!(
|
||||
"No CLI corrections detected in {} sessions.",
|
||||
sessions.len()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Filter by confidence
|
||||
let filtered: Vec<_> = corrections
|
||||
.into_iter()
|
||||
.filter(|c| c.confidence >= min_confidence)
|
||||
.collect();
|
||||
|
||||
// Deduplicate
|
||||
let mut rules = deduplicate_corrections(filtered.clone());
|
||||
|
||||
// Filter by occurrences
|
||||
rules.retain(|r| r.occurrences >= min_occurrences);
|
||||
|
||||
// Output
|
||||
match format.as_str() {
|
||||
"json" => {
|
||||
// JSON output
|
||||
let json = serde_json::json!({
|
||||
"sessions_scanned": sessions.len(),
|
||||
"total_corrections": filtered.len(),
|
||||
"rules": rules.iter().map(|r| serde_json::json!({
|
||||
"wrong": r.wrong_pattern,
|
||||
"right": r.right_pattern,
|
||||
"error_type": r.error_type.as_str(),
|
||||
"occurrences": r.occurrences,
|
||||
"base_command": r.base_command,
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&json)?);
|
||||
}
|
||||
_ => {
|
||||
// Text output
|
||||
let report = format_console_report(&rules, filtered.len(), sessions.len(), since);
|
||||
print!("{}", report);
|
||||
|
||||
if write_rules && !rules.is_empty() {
|
||||
let rules_path = ".claude/rules/cli-corrections.md";
|
||||
write_rules_file(&rules, rules_path)?;
|
||||
println!("\nWritten to: {}", rules_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Formats and persists correction suggestions for the user.
|
||||
|
||||
use crate::learn::detector::CorrectionRule;
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn format_console_report(
|
||||
rules: &[CorrectionRule],
|
||||
total_corrections: usize,
|
||||
sessions: usize,
|
||||
days: u64,
|
||||
) -> String {
|
||||
let mut output = String::new();
|
||||
|
||||
output.push_str(&format!(
|
||||
"RTK Learn -- {} rules from {} corrections ({} sessions, {} days)\n",
|
||||
rules.len(),
|
||||
total_corrections,
|
||||
sessions,
|
||||
days
|
||||
));
|
||||
|
||||
if rules.is_empty() {
|
||||
output.push_str("\nNo CLI corrections detected.\n");
|
||||
return output;
|
||||
}
|
||||
|
||||
output.push('\n');
|
||||
|
||||
for rule in rules {
|
||||
let count_marker = if rule.occurrences > 1 {
|
||||
format!("[{}x] ", rule.occurrences)
|
||||
} else {
|
||||
" ".to_string()
|
||||
};
|
||||
|
||||
output.push_str(&format!(
|
||||
"{}{} → {}\n",
|
||||
count_marker, rule.wrong_pattern, rule.right_pattern
|
||||
));
|
||||
|
||||
// Show error snippet (first line only)
|
||||
let error_line = rule.example_error.lines().next().unwrap_or("").trim();
|
||||
if !error_line.is_empty() {
|
||||
output.push_str(&format!(" Error: {}\n", error_line));
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
pub fn write_rules_file(rules: &[CorrectionRule], path: &str) -> Result<()> {
|
||||
let path_obj = Path::new(path);
|
||||
|
||||
// Create parent directory if it doesn't exist
|
||||
if let Some(parent) = path_obj.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("# CLI Corrections (auto-generated by rtk learn)\n");
|
||||
content.push_str("# Run `rtk learn --write-rules` to update\n\n");
|
||||
|
||||
if rules.is_empty() {
|
||||
content.push_str("No CLI corrections detected yet.\n");
|
||||
fs::write(path, content)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Group by base command
|
||||
let mut grouped: HashMap<String, Vec<&CorrectionRule>> = HashMap::new();
|
||||
for rule in rules {
|
||||
grouped
|
||||
.entry(rule.base_command.clone())
|
||||
.or_default()
|
||||
.push(rule);
|
||||
}
|
||||
|
||||
// Sort base commands alphabetically
|
||||
let mut base_commands: Vec<String> = grouped.keys().cloned().collect();
|
||||
base_commands.sort();
|
||||
|
||||
for base_cmd in base_commands {
|
||||
let rules_for_cmd = grouped.get(&base_cmd).unwrap();
|
||||
|
||||
// Capitalize first letter for section header
|
||||
let section_header = capitalize_first(&base_cmd);
|
||||
content.push_str(&format!("## {}\n", section_header));
|
||||
|
||||
for rule in rules_for_cmd {
|
||||
let occurrence_note = if rule.occurrences > 1 {
|
||||
format!(" (seen {}x)", rule.occurrences)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
content.push_str(&format!(
|
||||
"- Use `{}` not `{}`{}\n",
|
||||
rule.right_pattern, rule.wrong_pattern, occurrence_note
|
||||
));
|
||||
}
|
||||
|
||||
content.push('\n');
|
||||
}
|
||||
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capitalize_first(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::learn::detector::ErrorType;
|
||||
|
||||
#[test]
|
||||
fn test_format_console_report_empty() {
|
||||
let report = format_console_report(&[], 0, 0, 30);
|
||||
assert!(report.contains("0 rules"));
|
||||
assert!(report.contains("0 corrections"));
|
||||
assert!(report.contains("No CLI corrections detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_console_report_with_rules() {
|
||||
let rules = vec![
|
||||
CorrectionRule {
|
||||
wrong_pattern: "git commit --ammend".to_string(),
|
||||
right_pattern: "git commit --amend".to_string(),
|
||||
error_type: ErrorType::UnknownFlag,
|
||||
occurrences: 3,
|
||||
base_command: "git commit".to_string(),
|
||||
example_error: "error: unexpected argument '--ammend'".to_string(),
|
||||
},
|
||||
CorrectionRule {
|
||||
wrong_pattern: "gh pr edit -t".to_string(),
|
||||
right_pattern: "gh pr edit --title".to_string(),
|
||||
error_type: ErrorType::UnknownFlag,
|
||||
occurrences: 1,
|
||||
base_command: "gh pr".to_string(),
|
||||
example_error: "unknown flag: -t".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let report = format_console_report(&rules, 4, 10, 30);
|
||||
assert!(report.contains("2 rules"));
|
||||
assert!(report.contains("4 corrections"));
|
||||
assert!(report.contains("[3x]"));
|
||||
assert!(report.contains("--ammend"));
|
||||
assert!(report.contains("--amend"));
|
||||
assert!(report.contains("Error: error: unexpected argument"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_rules_file_markdown() {
|
||||
let rules = vec![CorrectionRule {
|
||||
wrong_pattern: "git commit --ammend".to_string(),
|
||||
right_pattern: "git commit --amend".to_string(),
|
||||
error_type: ErrorType::UnknownFlag,
|
||||
occurrences: 3,
|
||||
base_command: "git commit".to_string(),
|
||||
example_error: "error: unexpected argument '--ammend'".to_string(),
|
||||
}];
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let path = temp_dir.path().join("cli-corrections.md");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
write_rules_file(&rules, path_str).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("# CLI Corrections"));
|
||||
assert!(content.contains("## Git commit"));
|
||||
assert!(content.contains("Use `git commit --amend` not `git commit --ammend`"));
|
||||
assert!(content.contains("(seen 3x)"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user