use chrono::{DateTime, Utc};
use crate::core::knowledge::ProjectKnowledge;
use crate::core::memory_policy::MemoryPolicy;
use crate::core::session::{Finding, SessionState};
#[derive(Debug, Clone, Copy)]
pub(crate) struct ConsolidationBudgets {
pub max_decisions: usize,
pub max_findings: usize,
}
impl Default for ConsolidationBudgets {
fn default() -> Self {
Self {
max_decisions: 5,
max_findings: 8,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct ConsolidationOutcome {
pub promoted: u32,
pub promoted_decisions: u32,
pub promoted_findings: u32,
pub lifecycle_archived: usize,
pub lifecycle_remaining: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct ConsolidateOptions {
pub import_session: bool,
pub decision_budget: Option<usize>,
pub finding_budget: Option<usize>,
pub finding_salience_floor: Option<u32>,
pub decision_confidence: f32,
pub finding_confidence: f32,
pub incremental: bool,
pub run_lifecycle: bool,
pub reclaim_stores: bool,
pub emit_event: bool,
pub dry_run: bool,
}
impl ConsolidateOptions {
pub(crate) fn manual() -> Self {
Self {
import_session: true,
decision_budget: None,
finding_budget: None,
finding_salience_floor: None,
decision_confidence: 0.85,
finding_confidence: 0.7,
incremental: false,
run_lifecycle: true,
reclaim_stores: true,
emit_event: false,
dry_run: false,
}
}
pub(crate) fn scheduled(b: ConsolidationBudgets) -> Self {
Self {
import_session: true,
decision_budget: Some(b.max_decisions),
finding_budget: Some(b.max_findings),
finding_salience_floor: Some(45),
decision_confidence: 0.9,
finding_confidence: 0.75,
incremental: false,
run_lifecycle: true,
reclaim_stores: false,
emit_event: true,
dry_run: false,
}
}
pub(crate) fn incremental_auto() -> Self {
Self {
import_session: true,
decision_budget: None,
finding_budget: None,
finding_salience_floor: None,
decision_confidence: 0.85,
finding_confidence: 0.7,
incremental: true,
run_lifecycle: false,
reclaim_stores: false,
emit_event: false,
dry_run: false,
}
}
#[must_use]
pub(crate) fn into_dry_run(mut self) -> Self {
self.dry_run = true;
self
}
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ImportCounts {
pub decisions: usize,
pub findings: usize,
}
impl ImportCounts {
pub(crate) fn total(self) -> usize {
self.decisions + self.findings
}
}
pub(crate) fn import_session_into(
knowledge: &mut ProjectKnowledge,
session: &SessionState,
opts: &ConsolidateOptions,
policy: &MemoryPolicy,
watermark: Option<DateTime<Utc>>,
) -> ImportCounts {
let is_new = |ts: DateTime<Utc>| watermark.is_none_or(|w| ts > w);
let mut decisions: Vec<&crate::core::session::Decision> = session
.decisions
.iter()
.filter(|d| is_new(d.timestamp))
.collect();
decisions.sort_by_key(|d| std::cmp::Reverse(d.timestamp));
if let Some(n) = opts.decision_budget {
decisions.truncate(n);
}
let mut decision_count = 0;
for d in &decisions {
let key = slug_key(&d.summary, 50);
knowledge.remember(
"decision",
&key,
&d.summary,
&session.id,
opts.decision_confidence,
policy,
);
decision_count += 1;
}
let mut findings: Vec<&Finding> = session
.findings
.iter()
.filter(|f| is_new(f.timestamp))
.collect();
findings.sort_by_key(|f| std::cmp::Reverse(f.timestamp));
let mut finding_count = 0;
for f in &findings {
if opts.finding_budget.is_some_and(|n| finding_count >= n) {
break;
}
if let Some(floor) = opts.finding_salience_floor
&& crate::core::memory_salience::text_salience(&f.summary) < floor
{
continue;
}
let key = finding_key(f);
knowledge.remember(
"finding",
&key,
&f.summary,
&session.id,
opts.finding_confidence,
policy,
);
finding_count += 1;
}
ImportCounts {
decisions: decision_count,
findings: finding_count,
}
}
pub(crate) fn finding_key(f: &Finding) -> String {
match (&f.file, f.line) {
(Some(file), Some(line)) => format!("{file}:{line}"),
(Some(file), None) => file.clone(),
(None, _) => format!("finding-{}", slug_key(&f.summary, 36)),
}
}
pub(crate) fn consolidate_latest(
project_root: &str,
budgets: ConsolidationBudgets,
) -> Result<ConsolidationOutcome, String> {
let opts = ConsolidateOptions::scheduled(budgets);
let report =
crate::tools::ctx_knowledge::consolidate_project_knowledge_with(project_root, &opts)?;
Ok(ConsolidationOutcome {
promoted: (report.imported_decisions + report.imported_findings) as u32,
promoted_decisions: report.imported_decisions as u32,
promoted_findings: report.imported_findings as u32,
lifecycle_archived: report.lifecycle.archived_count,
lifecycle_remaining: report.lifecycle.remaining_facts,
})
}
pub(crate) fn slug_key(s: &str, max: usize) -> String {
let mut out = String::new();
for ch in s.chars() {
if out.len() >= max {
break;
}
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if (ch.is_whitespace() || ch == '-' || ch == '_')
&& !out.ends_with('-')
&& !out.is_empty()
{
out.push('-');
}
}
out.trim_matches('-').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn consolidate_promotes_decisions_and_salient_findings_only() {
let _lock = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().expect("tempdir");
crate::test_env::set_var(
"LEAN_CTX_DATA_DIR",
tmp.path().to_string_lossy().to_string(),
);
let project_root = tmp.path().join("proj");
std::fs::create_dir_all(&project_root).expect("mkdir");
let project_root_str = project_root.to_string_lossy().to_string();
let mut session = SessionState::new();
session.project_root = Some(project_root_str.clone());
session.add_decision("Use archive-only memory lifecycle", None);
session.add_finding(None, None, "panic: index out of bounds");
session.add_finding(None, None, "just a note");
session.save().expect("save session");
let out = consolidate_latest(
&project_root_str,
ConsolidationBudgets {
max_decisions: 5,
max_findings: 5,
},
)
.expect("consolidate");
assert!(out.promoted_decisions >= 1);
assert!(out.promoted_findings >= 1);
let k = ProjectKnowledge::load(&project_root_str).expect("knowledge saved");
let active = k.facts.iter().filter(|f| f.is_current()).count();
assert!(active >= 2, "expected promoted facts");
crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
}
#[test]
fn finding_key_is_content_based_and_deterministic() {
let f1 = Finding {
file: Some("src/main.rs".into()),
line: Some(42),
summary: "boom".into(),
timestamp: Utc::now(),
};
assert_eq!(finding_key(&f1), "src/main.rs:42");
let f2 = Finding {
file: None,
line: None,
summary: "Race condition in cache".into(),
timestamp: Utc::now(),
};
assert_eq!(finding_key(&f2), finding_key(&f2));
assert_eq!(finding_key(&f2), "finding-race-condition-in-cache");
}
}