kodegen_utils/
suggestions.rs

1//! User-facing suggestion system for `edit_block` failures
2//!
3//! Provides actionable guidance when edit operations fail, matching
4//! the helpful UX of Desktop Commander's error messages.
5
6use std::path::PathBuf;
7
8// ============================================================================
9// FAILURE REASONS
10// ============================================================================
11
12/// Reasons why an `edit_block` operation might fail
13#[derive(Debug, Clone)]
14pub enum EditFailureReason {
15    /// No match found at all
16    NoMatchFound,
17
18    /// Fuzzy match found but below similarity threshold
19    FuzzyMatchBelowThreshold {
20        similarity: f64,
21        threshold: f64,
22        found_text: String,
23    },
24
25    /// Fuzzy match found above threshold (not actually a failure, but user needs guidance)
26    FuzzyMatchAboveThreshold {
27        similarity: f64,
28        is_whitespace_only: bool,
29    },
30
31    /// Unexpected number of occurrences
32    UnexpectedCount { expected: usize, found: usize },
33
34    /// Empty search string
35    EmptySearch,
36
37    /// Identical old and new strings
38    IdenticalStrings,
39}
40
41// ============================================================================
42// SUGGESTION BUILDER
43// ============================================================================
44
45/// Context needed to build helpful suggestions
46#[derive(Debug, Clone)]
47pub struct SuggestionContext {
48    pub file_path: String,
49    pub search_string: String,
50    pub line_number: Option<usize>,
51
52    // Future hooks for EDIT_05 and EDIT_06
53    pub log_path: Option<PathBuf>,
54    pub execution_time_ms: Option<f64>,
55}
56
57/// Actionable suggestion for users
58#[derive(Debug, Clone)]
59pub struct Suggestion {
60    /// Main message explaining what happened
61    pub message: String,
62
63    /// List of actionable steps the user can take
64    pub actions: Vec<String>,
65}
66
67impl Suggestion {
68    /// Build a suggestion for a specific failure reason
69    #[must_use]
70    pub fn for_failure(reason: &EditFailureReason, context: &SuggestionContext) -> Self {
71        match reason {
72            EditFailureReason::FuzzyMatchAboveThreshold {
73                similarity,
74                is_whitespace_only,
75            } => Self::fuzzy_match_above_threshold(*similarity, *is_whitespace_only, context),
76
77            EditFailureReason::FuzzyMatchBelowThreshold {
78                similarity,
79                threshold,
80                found_text,
81            } => Self::fuzzy_match_below_threshold(*similarity, *threshold, found_text, context),
82
83            EditFailureReason::UnexpectedCount { expected, found } => {
84                Self::unexpected_count(*expected, *found, context)
85            }
86
87            EditFailureReason::NoMatchFound => Self::no_match_found(context),
88
89            EditFailureReason::EmptySearch => Self::empty_search(),
90
91            EditFailureReason::IdenticalStrings => Self::identical_strings(),
92        }
93    }
94
95    /// Format the complete suggestion message
96    #[must_use]
97    pub fn format(&self) -> String {
98        let mut output = String::new();
99
100        if !self.actions.is_empty() {
101            output.push_str("\n💡 Suggestions:\n");
102            for (i, action) in self.actions.iter().enumerate() {
103                output.push_str(&format!("{}. {}\n", i + 1, action));
104            }
105        }
106
107        output
108    }
109
110    // ========================================================================
111    // PRIVATE BUILDERS FOR EACH SCENARIO
112    // ========================================================================
113
114    fn fuzzy_match_above_threshold(
115        similarity: f64,
116        is_whitespace_only: bool,
117        context: &SuggestionContext,
118    ) -> Self {
119        let mut actions = vec![
120            "Copy the exact text from the diff above".to_string(),
121            "   Example: Use the text after {+...+} in the diff".to_string(),
122        ];
123
124        if is_whitespace_only {
125            actions.push(
126                "The difference is only whitespace - check for extra/missing spaces or tabs"
127                    .to_string(),
128            );
129        }
130
131        // Future hook for EDIT_05
132        if let Some(ref log_path) = context.log_path {
133            actions.push(format!(
134                "For detailed analysis, check log: {}",
135                log_path.display()
136            ));
137        }
138
139        let mut message = if let Some(line_num) = context.line_number {
140            format!(
141                "Exact match not found in {}, but found similar text at line {} with {:.1}% similarity",
142                context.file_path,
143                line_num,
144                similarity * 100.0
145            )
146        } else {
147            format!(
148                "Exact match not found in {}, but found similar text with {:.1}% similarity",
149                context.file_path,
150                similarity * 100.0
151            )
152        };
153
154        // Add execution time if available (EDIT_06 hook)
155        if let Some(time_ms) = context.execution_time_ms {
156            message.push_str(&format!(" (found in {time_ms:.2}ms)"));
157        }
158
159        message.push('.');
160
161        Self { message, actions }
162    }
163
164    fn fuzzy_match_below_threshold(
165        similarity: f64,
166        threshold: f64,
167        found_text: &str,
168        context: &SuggestionContext,
169    ) -> Self {
170        let mut message = format!(
171            "Search content not found in {}. The closest match was \"{}\" \
172             with only {:.1}% similarity, which is below the {:.1}% threshold",
173            context.file_path,
174            found_text,
175            similarity * 100.0,
176            threshold * 100.0
177        );
178
179        // Add execution time if available (EDIT_06 hook)
180        if let Some(time_ms) = context.execution_time_ms {
181            message.push_str(&format!(" (search completed in {time_ms:.2}ms)"));
182        }
183
184        message.push('.');
185
186        let mut actions = vec![
187            "Check if you're searching in the correct file".to_string(),
188            "Try a smaller, more unique search string".to_string(),
189            "   Example: Instead of searching for entire function, search for a unique line within it".to_string(),
190            "Check for typos in your search string".to_string(),
191        ];
192
193        // Future hook for EDIT_05
194        if let Some(ref log_path) = context.log_path {
195            actions.push(format!(
196                "For detailed analysis, check log: {}",
197                log_path.display()
198            ));
199        }
200
201        Self { message, actions }
202    }
203
204    fn unexpected_count(expected: usize, found: usize, _context: &SuggestionContext) -> Self {
205        let message = format!("Expected to replace {expected} occurrence(s), but found {found}.");
206
207        let actions = if found > expected {
208            vec![
209                format!(
210                    "If you want to replace all {} occurrences, set expected_replacements to {}",
211                    found, found
212                ),
213                format!("   Example: edit_block(..., expected_replacements: {})", found),
214                "To replace specific occurrences, make your search string more unique by including surrounding context".to_string(),
215                "   Example: Instead of 'foo', use 'function bar() {\\n  foo\\n}'".to_string(),
216            ]
217        } else {
218            vec![
219                "Some occurrences may have already been replaced".to_string(),
220                "Check if the file content has changed since you last read it".to_string(),
221                format!(
222                    "Update expected_replacements to {} to match actual count",
223                    found
224                ),
225            ]
226        };
227
228        Self { message, actions }
229    }
230
231    fn no_match_found(context: &SuggestionContext) -> Self {
232        let message = format!(
233            "No occurrences of the search string found in {}",
234            context.file_path
235        );
236
237        let actions = vec![
238            "Verify you're searching in the correct file".to_string(),
239            "Check for typos in your search string".to_string(),
240            "Try searching for a smaller, more specific substring".to_string(),
241        ];
242
243        Self { message, actions }
244    }
245
246    fn empty_search() -> Self {
247        Self {
248            message: "Empty search strings are not allowed.".to_string(),
249            actions: vec!["Provide a non-empty string to search for".to_string()],
250        }
251    }
252
253    fn identical_strings() -> Self {
254        Self {
255            message: "old_string and new_string are identical.".to_string(),
256            actions: vec!["No changes would be made - provide different strings".to_string()],
257        }
258    }
259}