Skip to main content

lean_ctx/core/
solution_auto_capture.rs

1//! Heuristics for recognizing solution decisions from edit context.
2
3use std::collections::BTreeSet;
4
5use crate::core::auto_findings::AutoFinding;
6use crate::core::knowledge::{SolutionDecisionKind, SolutionDecisionMeta, SolutionStatus};
7
8/// Detect a newly added standard-library import.
9pub 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
18/// Detect an explicit lean-ctx debt marker.
19pub 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
34/// Detect dependencies present in the old manifest but absent from the new one.
35pub 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
50/// Detect a substantial implementation-size reduction.
51///
52/// A reduction counts when it removes at least five lines and cuts the size by
53/// at least half. This ignores ordinary small edits while surfacing one-liner
54/// style simplifications.
55pub 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
63/// Build decision metadata from a successful edit without persisting it.
64///
65/// Keeping detection pure makes the classifications testable and lets the edit
66/// hooks perform persistence only after the write has succeeded.
67pub 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
134/// Persist decisions detected from a completed edit through the normal
135/// auto-capture path, so they are available to `ctx_knowledge` immediately.
136pub 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            // Include the kind in the derived key so distinct decisions in the
166            // same file are stored separately rather than overwriting each other.
167            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}