1use std::collections::BTreeSet;
4
5use crate::core::auto_findings::AutoFinding;
6use crate::core::knowledge::{SolutionDecisionKind, SolutionDecisionMeta, SolutionStatus};
7
8pub fn detect_stdlib_choice(old_content: &str, new_content: &str) -> Option<String> {
10 let old_imports = stdlib_imports(old_content);
11
12 stdlib_imports(new_content)
13 .into_iter()
14 .find(|import| !old_imports.contains(import))
15 .map(|import| format!("Selected standard library import `{import}`"))
16}
17
18pub fn detect_debt_marker(new_content: &str) -> Option<String> {
20 new_content
21 .lines()
22 .map(str::trim_start)
23 .find_map(|line| line.strip_prefix("// lean-ctx:"))
24 .map(str::trim)
25 .map(|marker| {
26 if marker.is_empty() {
27 "Added lean-ctx debt marker".to_string()
28 } else {
29 format!("Added lean-ctx debt marker: {marker}")
30 }
31 })
32}
33
34pub fn detect_dep_removal(old_manifest: &str, new_manifest: &str) -> Option<String> {
36 let old_dependencies = dependency_names(old_manifest);
37 let new_dependencies = dependency_names(new_manifest);
38 let removed: Vec<_> = old_dependencies
39 .difference(&new_dependencies)
40 .cloned()
41 .collect();
42
43 match removed.as_slice() {
44 [] => None,
45 [dependency] => Some(format!("Removed dependency: {dependency}")),
46 dependencies => Some(format!("Removed dependencies: {}", dependencies.join(", "))),
47 }
48}
49
50pub fn detect_one_liner(old_lines: usize, new_lines: usize) -> Option<String> {
56 let removed_lines = old_lines.saturating_sub(new_lines);
57 let reduced_by_half = new_lines <= old_lines / 2;
58
59 (old_lines > new_lines && removed_lines >= 5 && reduced_by_half)
60 .then(|| format!("Simplified implementation from {old_lines} to {new_lines} lines"))
61}
62
63pub fn decisions_from_edit(
68 path: &str,
69 old_content: &str,
70 new_content: &str,
71) -> Vec<SolutionDecisionMeta> {
72 let mut decisions = Vec::new();
73 let scope = vec![path.to_string()];
74
75 if let Some(chosen) = detect_stdlib_choice(old_content, new_content) {
76 decisions.push(SolutionDecisionMeta {
77 kind: SolutionDecisionKind::StdlibChosen,
78 chosen,
79 alternatives: vec!["Add or retain a third-party dependency".to_string()],
80 rationale: Some("The edit introduced a standard-library import.".to_string()),
81 status: SolutionStatus::Accepted,
82 scope: scope.clone(),
83 loc_impact: None,
84 upgrade_condition: None,
85 });
86 }
87
88 if let Some(chosen) = detect_dep_removal(old_content, new_content) {
89 decisions.push(SolutionDecisionMeta {
90 kind: SolutionDecisionKind::Reuse,
91 chosen,
92 alternatives: vec!["Keep the removed dependency".to_string()],
93 rationale: Some("The edit removed a manifest dependency.".to_string()),
94 status: SolutionStatus::Accepted,
95 scope: scope.clone(),
96 loc_impact: None,
97 upgrade_condition: None,
98 });
99 }
100
101 if !old_content.contains("// lean-ctx:") {
102 if let Some(chosen) = detect_debt_marker(new_content) {
103 decisions.push(SolutionDecisionMeta {
104 kind: SolutionDecisionKind::DebtAccepted,
105 chosen,
106 alternatives: Vec::new(),
107 rationale: Some("The edit explicitly marked deferred work.".to_string()),
108 status: SolutionStatus::Deferred,
109 scope: scope.clone(),
110 loc_impact: None,
111 upgrade_condition: Some("Resolve the lean-ctx debt marker.".to_string()),
112 });
113 }
114 }
115
116 let old_lines = old_content.lines().count();
117 let new_lines = new_content.lines().count();
118 if let Some(chosen) = detect_one_liner(old_lines, new_lines) {
119 decisions.push(SolutionDecisionMeta {
120 kind: SolutionDecisionKind::OneLineSolution,
121 chosen,
122 alternatives: Vec::new(),
123 rationale: Some("The edit substantially reduced implementation size.".to_string()),
124 status: SolutionStatus::Accepted,
125 scope,
126 loc_impact: Some(new_lines as i32 - old_lines as i32),
127 upgrade_condition: None,
128 });
129 }
130
131 decisions
132}
133
134pub fn capture_edit_decisions(
137 project_root: &str,
138 path: &str,
139 old_content: &str,
140 new_content: &str,
141) {
142 let cfg = crate::core::config::Config::load();
143 if !cfg.solution.enabled || !cfg.solution.track_decisions {
144 return;
145 }
146 for decision in decisions_from_edit(path, old_content, new_content) {
147 capture_decision(project_root, &decision);
148 }
149}
150
151fn capture_decision(project_root: &str, decision: &SolutionDecisionMeta) {
152 let kind = decision.kind.tracker_key();
153 let category = if matches!(decision.kind, SolutionDecisionKind::DebtAccepted) {
154 "solution-debt"
155 } else {
156 "solution-decision"
157 };
158 let summary = serde_json::to_string(&decision)
159 .unwrap_or_else(|_| format!("{category}: {}", decision.chosen));
160
161 crate::core::solution_tracker::record_decision(kind);
162 crate::core::auto_capture::capture_finding(
163 project_root,
164 &AutoFinding {
165 file: decision.scope.first().map(|path| format!("{path}#{kind}")),
168 summary: format!("{category}: {summary}"),
169 },
170 );
171}
172
173fn stdlib_imports(content: &str) -> BTreeSet<String> {
174 content
175 .lines()
176 .map(strip_line_comment)
177 .map(str::trim)
178 .filter(|line| line.starts_with("use std::") || line.starts_with("pub use std::"))
179 .map(str::to_string)
180 .collect()
181}
182
183fn dependency_names(manifest: &str) -> BTreeSet<String> {
184 let mut dependencies = BTreeSet::new();
185 let mut in_dependency_section = false;
186
187 for line in manifest.lines().map(strip_line_comment).map(str::trim) {
188 if let Some(section) = line
189 .strip_prefix('[')
190 .and_then(|line| line.strip_suffix(']'))
191 {
192 in_dependency_section = is_dependency_section(section);
193 continue;
194 }
195
196 if !in_dependency_section || line.is_empty() {
197 continue;
198 }
199
200 if let Some((name, _)) = line.split_once('=') {
201 let name = name.trim().trim_matches(['\'', '"']);
202 if !name.is_empty() {
203 dependencies.insert(name.to_string());
204 }
205 }
206 }
207
208 dependencies
209}
210
211fn is_dependency_section(section: &str) -> bool {
212 let section = section.trim();
213 ["dependencies", "dev-dependencies", "build-dependencies"]
214 .iter()
215 .any(|dependency_section| {
216 section == *dependency_section || section.ends_with(&format!(".{dependency_section}"))
217 })
218}
219
220fn strip_line_comment(line: &str) -> &str {
221 line.split_once('#').map_or(line, |(content, _)| content)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::{
227 decisions_from_edit, detect_debt_marker, detect_dep_removal, detect_one_liner,
228 detect_stdlib_choice,
229 };
230 use crate::core::knowledge::SolutionDecisionKind;
231
232 #[test]
233 fn detects_new_stdlib_import() {
234 assert_eq!(
235 detect_stdlib_choice(
236 "use crate::core::Config;\n",
237 "use crate::core::Config;\nuse std::collections::HashMap;\n",
238 ),
239 Some("Selected standard library import `use std::collections::HashMap;`".to_string())
240 );
241 }
242
243 #[test]
244 fn ignores_preexisting_stdlib_import() {
245 let content = "use std::collections::HashMap;\n";
246 assert_eq!(detect_stdlib_choice(content, content), None);
247 }
248
249 #[test]
250 fn detects_debt_marker() {
251 assert_eq!(
252 detect_debt_marker(" // lean-ctx: remove after migration\n"),
253 Some("Added lean-ctx debt marker: remove after migration".to_string())
254 );
255 }
256
257 #[test]
258 fn ignores_content_without_debt_marker() {
259 assert_eq!(detect_debt_marker("// TODO: revisit\n"), None);
260 }
261
262 #[test]
263 fn detects_removed_dependencies_in_sorted_order() {
264 let old_manifest = "[dependencies]\nzeta = \"1\"\nalpha = \"1\"\nkeep = \"1\"\n";
265 let new_manifest = "[dependencies]\nkeep = \"2\"\n";
266
267 assert_eq!(
268 detect_dep_removal(old_manifest, new_manifest),
269 Some("Removed dependencies: alpha, zeta".to_string())
270 );
271 }
272
273 #[test]
274 fn ignores_dependency_version_changes() {
275 assert_eq!(
276 detect_dep_removal(
277 "[dev-dependencies]\npretty_assertions = \"1\"\n",
278 "[dev-dependencies]\npretty_assertions = \"2\"\n",
279 ),
280 None
281 );
282 }
283
284 #[test]
285 fn detects_substantial_line_reduction() {
286 assert_eq!(
287 detect_one_liner(12, 4),
288 Some("Simplified implementation from 12 to 4 lines".to_string())
289 );
290 }
291
292 #[test]
293 fn ignores_small_or_insufficient_reductions() {
294 assert_eq!(detect_one_liner(12, 8), None);
295 assert_eq!(detect_one_liner(4, 1), None);
296 }
297
298 #[test]
299 fn classifies_edit_decisions_with_solution_metadata() {
300 let old = "[dependencies]\nlegacy = \"1\"\n";
301 let new =
302 "use std::collections::HashMap;\n[dependencies]\n// lean-ctx: remove after migration\n";
303 let decisions = decisions_from_edit("Cargo.toml", old, new);
304
305 assert!(
306 decisions
307 .iter()
308 .any(|decision| matches!(decision.kind, SolutionDecisionKind::StdlibChosen))
309 );
310 assert!(
311 decisions
312 .iter()
313 .any(|decision| matches!(decision.kind, SolutionDecisionKind::Reuse))
314 );
315 assert!(
316 decisions
317 .iter()
318 .any(|decision| matches!(decision.kind, SolutionDecisionKind::DebtAccepted))
319 );
320 assert!(
321 decisions
322 .iter()
323 .all(|decision| decision.scope == vec!["Cargo.toml".to_string()])
324 );
325 }
326}