1use chrono::{DateTime, Utc};
16
17use crate::core::knowledge::ProjectKnowledge;
18use crate::core::memory_policy::MemoryPolicy;
19use crate::core::session::{Finding, SessionState};
20
21#[derive(Debug, Clone, Copy)]
23pub struct ConsolidationBudgets {
24 pub max_decisions: usize,
25 pub max_findings: usize,
26}
27
28impl Default for ConsolidationBudgets {
29 fn default() -> Self {
30 Self {
31 max_decisions: 5,
32 max_findings: 8,
33 }
34 }
35}
36
37#[derive(Debug, Clone)]
40pub struct ConsolidationOutcome {
41 pub promoted: u32,
42 pub promoted_decisions: u32,
43 pub promoted_findings: u32,
44 pub lifecycle_archived: usize,
45 pub lifecycle_remaining: usize,
46}
47
48#[derive(Debug, Clone)]
52pub struct ConsolidateOptions {
53 pub import_session: bool,
55 pub decision_budget: Option<usize>,
57 pub finding_budget: Option<usize>,
59 pub finding_salience_floor: Option<u32>,
61 pub decision_confidence: f32,
63 pub finding_confidence: f32,
65 pub incremental: bool,
68 pub run_lifecycle: bool,
70 pub reclaim_stores: bool,
72 pub emit_event: bool,
74 pub dry_run: bool,
76}
77
78impl ConsolidateOptions {
79 pub fn manual() -> Self {
82 Self {
83 import_session: true,
84 decision_budget: None,
85 finding_budget: None,
86 finding_salience_floor: None,
87 decision_confidence: 0.85,
88 finding_confidence: 0.7,
89 incremental: false,
90 run_lifecycle: true,
91 reclaim_stores: true,
92 emit_event: false,
93 dry_run: false,
94 }
95 }
96
97 pub fn scheduled(b: ConsolidationBudgets) -> Self {
100 Self {
101 import_session: true,
102 decision_budget: Some(b.max_decisions),
103 finding_budget: Some(b.max_findings),
104 finding_salience_floor: Some(45),
105 decision_confidence: 0.9,
106 finding_confidence: 0.75,
107 incremental: false,
108 run_lifecycle: true,
109 reclaim_stores: false,
110 emit_event: true,
111 dry_run: false,
112 }
113 }
114
115 pub fn incremental_auto() -> Self {
117 Self {
118 import_session: true,
119 decision_budget: None,
120 finding_budget: None,
121 finding_salience_floor: None,
122 decision_confidence: 0.85,
123 finding_confidence: 0.7,
124 incremental: true,
125 run_lifecycle: false,
126 reclaim_stores: false,
127 emit_event: false,
128 dry_run: false,
129 }
130 }
131
132 #[must_use]
134 pub fn into_dry_run(mut self) -> Self {
135 self.dry_run = true;
136 self
137 }
138}
139
140#[derive(Debug, Default, Clone, Copy)]
142pub struct ImportCounts {
143 pub decisions: usize,
144 pub findings: usize,
145}
146
147impl ImportCounts {
148 pub fn total(self) -> usize {
149 self.decisions + self.findings
150 }
151}
152
153pub(crate) fn import_session_into(
158 knowledge: &mut ProjectKnowledge,
159 session: &SessionState,
160 opts: &ConsolidateOptions,
161 policy: &MemoryPolicy,
162 watermark: Option<DateTime<Utc>>,
163) -> ImportCounts {
164 let is_new = |ts: DateTime<Utc>| watermark.is_none_or(|w| ts > w);
165
166 let mut decisions: Vec<&crate::core::session::Decision> = session
167 .decisions
168 .iter()
169 .filter(|d| is_new(d.timestamp))
170 .collect();
171 decisions.sort_by_key(|d| std::cmp::Reverse(d.timestamp));
172 if let Some(n) = opts.decision_budget {
173 decisions.truncate(n);
174 }
175 let mut decision_count = 0;
176 for d in &decisions {
177 let key = slug_key(&d.summary, 50);
178 knowledge.remember(
179 "decision",
180 &key,
181 &d.summary,
182 &session.id,
183 opts.decision_confidence,
184 policy,
185 );
186 decision_count += 1;
187 }
188
189 let mut findings: Vec<&Finding> = session
190 .findings
191 .iter()
192 .filter(|f| is_new(f.timestamp))
193 .collect();
194 findings.sort_by_key(|f| std::cmp::Reverse(f.timestamp));
195 let mut finding_count = 0;
196 for f in &findings {
197 if opts.finding_budget.is_some_and(|n| finding_count >= n) {
198 break;
199 }
200 if let Some(floor) = opts.finding_salience_floor
201 && crate::core::memory_salience::text_salience(&f.summary) < floor
202 {
203 continue;
204 }
205 let key = finding_key(f);
206 knowledge.remember(
207 "finding",
208 &key,
209 &f.summary,
210 &session.id,
211 opts.finding_confidence,
212 policy,
213 );
214 finding_count += 1;
215 }
216
217 ImportCounts {
218 decisions: decision_count,
219 findings: finding_count,
220 }
221}
222
223pub(crate) fn finding_key(f: &Finding) -> String {
227 match (&f.file, f.line) {
228 (Some(file), Some(line)) => format!("{file}:{line}"),
229 (Some(file), None) => file.clone(),
230 (None, _) => format!("finding-{}", slug_key(&f.summary, 36)),
231 }
232}
233
234pub fn consolidate_latest(
238 project_root: &str,
239 budgets: ConsolidationBudgets,
240) -> Result<ConsolidationOutcome, String> {
241 let opts = ConsolidateOptions::scheduled(budgets);
242 let report =
243 crate::tools::ctx_knowledge::consolidate_project_knowledge_with(project_root, &opts)?;
244 Ok(ConsolidationOutcome {
245 promoted: (report.imported_decisions + report.imported_findings) as u32,
246 promoted_decisions: report.imported_decisions as u32,
247 promoted_findings: report.imported_findings as u32,
248 lifecycle_archived: report.lifecycle.archived_count,
249 lifecycle_remaining: report.lifecycle.remaining_facts,
250 })
251}
252
253pub(crate) fn slug_key(s: &str, max: usize) -> String {
256 let mut out = String::new();
257 for ch in s.chars() {
258 if out.len() >= max {
259 break;
260 }
261 if ch.is_ascii_alphanumeric() {
262 out.push(ch.to_ascii_lowercase());
263 } else if (ch.is_whitespace() || ch == '-' || ch == '_')
264 && !out.ends_with('-')
265 && !out.is_empty()
266 {
267 out.push('-');
268 }
269 }
270 out.trim_matches('-').to_string()
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn consolidate_promotes_decisions_and_salient_findings_only() {
279 let _lock = crate::core::data_dir::test_env_lock();
280 let tmp = tempfile::tempdir().expect("tempdir");
281 crate::test_env::set_var(
282 "LEAN_CTX_DATA_DIR",
283 tmp.path().to_string_lossy().to_string(),
284 );
285
286 let project_root = tmp.path().join("proj");
287 std::fs::create_dir_all(&project_root).expect("mkdir");
288 let project_root_str = project_root.to_string_lossy().to_string();
289
290 let mut session = SessionState::new();
291 session.project_root = Some(project_root_str.clone());
292 session.add_decision("Use archive-only memory lifecycle", None);
293 session.add_finding(None, None, "panic: index out of bounds");
294 session.add_finding(None, None, "just a note");
295 session.save().expect("save session");
296
297 let out = consolidate_latest(
298 &project_root_str,
299 ConsolidationBudgets {
300 max_decisions: 5,
301 max_findings: 5,
302 },
303 )
304 .expect("consolidate");
305 assert!(out.promoted_decisions >= 1);
306 assert!(out.promoted_findings >= 1);
307
308 let k = ProjectKnowledge::load(&project_root_str).expect("knowledge saved");
309 let active = k.facts.iter().filter(|f| f.is_current()).count();
310 assert!(active >= 2, "expected promoted facts");
311
312 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
313 }
314
315 #[test]
316 fn finding_key_is_content_based_and_deterministic() {
317 let f1 = Finding {
318 file: Some("src/main.rs".into()),
319 line: Some(42),
320 summary: "boom".into(),
321 timestamp: Utc::now(),
322 };
323 assert_eq!(finding_key(&f1), "src/main.rs:42");
324
325 let f2 = Finding {
326 file: None,
327 line: None,
328 summary: "Race condition in cache".into(),
329 timestamp: Utc::now(),
330 };
331 assert_eq!(finding_key(&f2), finding_key(&f2));
333 assert_eq!(finding_key(&f2), "finding-race-condition-in-cache");
334 }
335}