1use std::collections::hash_map::DefaultHasher;
29use std::hash::{Hash, Hasher};
30use std::path::Path;
31
32use kimetsu_core::KimetsuResult;
33use rusqlite::Connection;
34use serde::{Deserialize, Serialize};
35
36use crate::project::{load_project, load_project_readonly};
37
38const DIGEST_CHAR_BUDGET: usize = 1_600;
42const TOP_MEMORY_COUNT: usize = 5;
44const RECENT_RUNS_COUNT: usize = 3;
46const MEMORY_SNIPPET_CHARS: usize = 180;
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct DigestMeta {
53 pub input_hash: u64,
55 pub built_at: String,
57}
58
59pub fn build_or_load_digest(workspace: &Path, force_rebuild: bool) -> Option<String> {
71 build_or_load_digest_inner(workspace, force_rebuild).unwrap_or(None)
72}
73
74fn build_or_load_digest_inner(
75 workspace: &Path,
76 force_rebuild: bool,
77) -> KimetsuResult<Option<String>> {
78 let (paths, config, conn) = load_project_readonly(workspace)?;
79 let repo_root_str = paths.repo_root.to_string_lossy().to_string();
80
81 let inputs = gather_inputs(&conn, &repo_root_str)?;
83 if inputs.is_empty() {
84 return Ok(None);
85 }
86
87 let hash = content_hash(&inputs);
89
90 let cache_path = paths.kimetsu_dir.join("digest.md");
92 let meta_path = paths.kimetsu_dir.join("digest-meta.json");
93
94 if !force_rebuild {
96 if let Some(cached) = try_load_cache(&cache_path, &meta_path, hash) {
97 return Ok(Some(cached));
98 }
99 }
100
101 let digest_text = assemble_rule_based(&inputs, &config)?;
103 if digest_text.trim().is_empty() {
104 return Ok(None);
105 }
106
107 let meta = DigestMeta {
109 input_hash: hash,
110 built_at: now_utc_rfc3339(),
111 };
112 atomic_write_text(&cache_path, &digest_text);
113 atomic_write_json_meta(&meta_path, &meta);
114
115 Ok(Some(digest_text))
116}
117
118pub fn is_stale(workspace: &Path) -> bool {
128 is_stale_inner(workspace).unwrap_or(false)
129}
130
131fn is_stale_inner(workspace: &Path) -> KimetsuResult<bool> {
132 let (paths, _config, conn) = load_project_readonly(workspace)?;
133 let repo_root_str = paths.repo_root.to_string_lossy().to_string();
134
135 let meta_path = paths.kimetsu_dir.join("digest-meta.json");
136 let cache_path = paths.kimetsu_dir.join("digest.md");
137
138 if !cache_path.exists() || !meta_path.exists() {
139 return Ok(true);
140 }
141
142 let meta = load_meta(&meta_path)?;
143 let inputs = gather_inputs(&conn, &repo_root_str)?;
144 let current_hash = content_hash(&inputs);
145
146 Ok(meta.input_hash != current_hash)
147}
148
149pub fn record_warmstart_served(workspace: &Path, digest_chars: usize, resume_chars: usize) {
158 let _ = record_warmstart_served_inner(workspace, digest_chars, resume_chars);
159}
160
161fn record_warmstart_served_inner(
162 workspace: &Path,
163 digest_chars: usize,
164 resume_chars: usize,
165) -> KimetsuResult<()> {
166 if digest_chars == 0 && resume_chars == 0 {
167 return Ok(());
168 }
169 let (_paths, _config, conn) = load_project(workspace)?;
170 let ts = now_utc_rfc3339();
171
172 if digest_chars > 0 {
173 let approx_tokens = digest_chars / 4;
174 let event = kimetsu_core::event::Event::new(
175 kimetsu_core::ids::RunId::new(),
176 "digest_served",
177 serde_json::json!({
178 "digest_chars": digest_chars,
179 "approx_tokens": approx_tokens,
180 "ts": ts,
181 }),
182 );
183 let _ = crate::projector::insert_event(&conn, &event);
184 }
185
186 if resume_chars > 0 {
187 let approx_tokens = resume_chars / 4;
188 let event = kimetsu_core::event::Event::new(
189 kimetsu_core::ids::RunId::new(),
190 "resume_served",
191 serde_json::json!({
192 "resume_chars": resume_chars,
193 "approx_tokens": approx_tokens,
194 "ts": ts,
195 }),
196 );
197 let _ = crate::projector::insert_event(&conn, &event);
198 }
199
200 Ok(())
201}
202
203#[derive(Debug, Default)]
207struct DigestInputs {
208 top_memories: Vec<(String, String)>,
210 manifests: Vec<(String, String)>,
212 recent_runs: Vec<String>,
214}
215
216impl DigestInputs {
217 fn is_empty(&self) -> bool {
218 self.top_memories.is_empty() && self.manifests.is_empty() && self.recent_runs.is_empty()
219 }
220}
221
222fn gather_inputs(conn: &Connection, repo_root: &str) -> KimetsuResult<DigestInputs> {
223 let mut inputs = DigestInputs::default();
224
225 {
231 let mut stmt = conn.prepare(
232 "SELECT kind, text
233 FROM memories
234 WHERE invalidated_at IS NULL
235 AND superseded_by IS NULL
236 ORDER BY
237 CASE WHEN use_count > 0
238 THEN (usefulness_score / CAST(use_count AS REAL))
239 ELSE 0.0
240 END DESC,
241 use_count DESC,
242 created_at DESC
243 LIMIT ?1",
244 )?;
245 let rows = stmt.query_map([TOP_MEMORY_COUNT as i64], |row| {
246 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
247 })?;
248 for (kind, text) in rows.flatten() {
249 let snippet: String = text.chars().take(MEMORY_SNIPPET_CHARS).collect();
250 inputs.top_memories.push((kind, snippet));
251 }
252 }
253
254 {
256 let mut stmt = conn.prepare(
257 "SELECT manifest_kind, manifest_path
258 FROM repo_manifests
259 WHERE repo_root = ?1
260 LIMIT 10",
261 )?;
262 let rows = stmt.query_map([repo_root], |row| {
263 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
264 })?;
265 for pair in rows.flatten() {
266 inputs.manifests.push(pair);
267 }
268 }
269
270 {
272 let mut stmt = conn.prepare(
273 "SELECT task
274 FROM work_episodes
275 WHERE repo_root = ?1
276 AND superseded_by IS NULL
277 ORDER BY created_at DESC
278 LIMIT ?2",
279 )?;
280 let rows = stmt.query_map([repo_root, &RECENT_RUNS_COUNT.to_string()], |row| {
281 row.get::<_, String>(0)
282 })?;
283 for task in rows.flatten() {
284 if !task.trim().is_empty() {
285 inputs.recent_runs.push(task);
286 }
287 }
288 }
289
290 Ok(inputs)
291}
292
293fn content_hash(inputs: &DigestInputs) -> u64 {
296 let mut h = DefaultHasher::new();
297 for (kind, text) in &inputs.top_memories {
298 kind.hash(&mut h);
299 text.hash(&mut h);
300 }
301 for (mk, mp) in &inputs.manifests {
302 mk.hash(&mut h);
303 mp.hash(&mut h);
304 }
305 for task in &inputs.recent_runs {
306 task.hash(&mut h);
307 }
308 h.finish()
309}
310
311fn assemble_rule_based(
314 inputs: &DigestInputs,
315 _config: &kimetsu_core::config::ProjectConfig,
316) -> KimetsuResult<String> {
317 let mut parts: Vec<String> = Vec::new();
318
319 if !inputs.manifests.is_empty() {
321 let manifest_list: Vec<String> = inputs
322 .manifests
323 .iter()
324 .map(|(kind, path)| format!("{kind}: {path}"))
325 .collect();
326 parts.push(format!("Project manifests: {}", manifest_list.join(", ")));
327 }
328
329 if !inputs.recent_runs.is_empty() {
331 let focus = inputs
332 .recent_runs
333 .iter()
334 .map(|t| t.trim().to_string())
335 .filter(|t| !t.is_empty())
336 .collect::<Vec<_>>();
337 if !focus.is_empty() {
338 parts.push(format!("Current focus: {}", focus.join(" / ")));
339 }
340 }
341
342 if !inputs.top_memories.is_empty() {
344 parts.push("Key conventions and facts:".to_string());
345 for (kind, text) in &inputs.top_memories {
346 parts.push(format!("[{kind}] {text}"));
347 }
348 }
349
350 let digest = parts.join("\n");
351
352 if digest.len() > DIGEST_CHAR_BUDGET {
354 let mut s: String = digest.chars().take(DIGEST_CHAR_BUDGET - 3).collect();
355 s.push_str("...");
356 Ok(s)
357 } else {
358 Ok(digest)
359 }
360}
361
362fn try_load_cache(cache_path: &Path, meta_path: &Path, current_hash: u64) -> Option<String> {
365 if !cache_path.exists() || !meta_path.exists() {
366 return None;
367 }
368 let meta = load_meta(meta_path).ok()?;
369 if meta.input_hash != current_hash {
370 return None;
371 }
372 std::fs::read_to_string(cache_path).ok()
373}
374
375fn load_meta(meta_path: &Path) -> KimetsuResult<DigestMeta> {
376 let text = std::fs::read_to_string(meta_path)?;
377 Ok(serde_json::from_str(&text)?)
378}
379
380fn atomic_write_text(path: &Path, content: &str) {
382 let Some(parent) = path.parent() else {
383 return;
384 };
385 let _ = std::fs::create_dir_all(parent);
386 let tmp = path.with_extension("md.tmp");
387 if std::fs::write(&tmp, content).is_ok() {
388 let _ = std::fs::rename(&tmp, path);
389 }
390}
391
392fn atomic_write_json_meta(path: &Path, meta: &DigestMeta) {
394 let Some(parent) = path.parent() else {
395 return;
396 };
397 let _ = std::fs::create_dir_all(parent);
398 let Ok(text) = serde_json::to_string(meta) else {
399 return;
400 };
401 let tmp = path.with_extension("json.tmp");
402 if std::fs::write(&tmp, &text).is_ok() {
403 let _ = std::fs::rename(&tmp, path);
404 }
405}
406
407fn now_utc_rfc3339() -> String {
408 time::OffsetDateTime::now_utc()
409 .format(&time::format_description::well_known::Rfc3339)
410 .unwrap_or_default()
411}
412
413#[cfg(test)]
416mod tests {
417 use kimetsu_core::paths::git_init_boundary;
418
419 use super::*;
420 use crate::{project, user_brain};
421
422 fn tmp_workspace(name: &str) -> std::path::PathBuf {
423 let ts = std::time::SystemTime::now()
424 .duration_since(std::time::UNIX_EPOCH)
425 .map(|d| d.as_nanos())
426 .unwrap_or(0);
427 let dir = std::env::temp_dir().join(format!("kimetsu-digest-{name}-{ts}"));
428 std::fs::create_dir_all(&dir).expect("create tmp");
429 dir
430 }
431
432 #[test]
434 fn empty_brain_returns_none() {
435 let dir = tmp_workspace("empty");
436 git_init_boundary(&dir);
437 user_brain::with_user_brain_disabled(|| {
438 project::init_project(&dir, true).expect("init");
439 let result = build_or_load_digest(&dir, false);
440 assert!(result.is_none(), "empty brain must return None digest");
441 });
442 std::fs::remove_dir_all(dir).ok();
443 }
444
445 #[test]
447 fn digest_with_memories_is_bounded() {
448 let dir = tmp_workspace("bounded");
449 git_init_boundary(&dir);
450 user_brain::with_user_brain_disabled(|| {
451 project::init_project(&dir, true).expect("init");
452 project::add_memory(
455 &dir,
456 kimetsu_core::memory::MemoryScope::Project,
457 kimetsu_core::memory::MemoryKind::Convention,
458 "Always use git_init_boundary before init_project in tests",
459 )
460 .expect("add_memory");
461
462 let digest = build_or_load_digest(&dir, true).expect("digest must be Some");
463 assert!(!digest.is_empty(), "digest must be non-empty");
464 assert!(
465 digest.len() <= DIGEST_CHAR_BUDGET + 3,
466 "digest must respect char budget: {} chars",
467 digest.len()
468 );
469 });
470 std::fs::remove_dir_all(dir).ok();
471 }
472
473 #[test]
475 fn cache_is_reused_on_second_call() {
476 let dir = tmp_workspace("cache");
477 git_init_boundary(&dir);
478 user_brain::with_user_brain_disabled(|| {
479 project::init_project(&dir, true).expect("init");
480 project::add_memory(
481 &dir,
482 kimetsu_core::memory::MemoryScope::Project,
483 kimetsu_core::memory::MemoryKind::Fact,
484 "Rust edition 2024 is the target edition for this workspace",
485 )
486 .expect("add_memory");
487 let d1 = build_or_load_digest(&dir, true).expect("first build");
488 let d2 = build_or_load_digest(&dir, false).expect("cached load");
489 assert_eq!(d1, d2, "cached digest must match first build");
490 });
491 std::fs::remove_dir_all(dir).ok();
492 }
493
494 #[test]
496 fn force_rebuild_bypasses_cache() {
497 let dir = tmp_workspace("force");
498 git_init_boundary(&dir);
499 user_brain::with_user_brain_disabled(|| {
500 project::init_project(&dir, true).expect("init");
501 project::add_memory(
502 &dir,
503 kimetsu_core::memory::MemoryScope::Project,
504 kimetsu_core::memory::MemoryKind::Convention,
505 "Force rebuild test convention",
506 )
507 .expect("add_memory");
508 let d1 = build_or_load_digest(&dir, true).expect("first build");
509 let d2 = build_or_load_digest(&dir, true).expect("forced rebuild");
510 assert_eq!(
512 d1, d2,
513 "forced rebuild must produce same content when inputs unchanged"
514 );
515 });
516 std::fs::remove_dir_all(dir).ok();
517 }
518
519 #[test]
521 fn is_stale_true_when_no_cache() {
522 let dir = tmp_workspace("stale");
523 git_init_boundary(&dir);
524 user_brain::with_user_brain_disabled(|| {
525 project::init_project(&dir, true).expect("init");
526 assert!(is_stale(&dir), "must be stale when cache does not exist");
527 });
528 std::fs::remove_dir_all(dir).ok();
529 }
530
531 #[test]
533 fn is_stale_false_after_build() {
534 let dir = tmp_workspace("fresh");
535 git_init_boundary(&dir);
536 user_brain::with_user_brain_disabled(|| {
537 project::init_project(&dir, true).expect("init");
538 project::add_memory(
539 &dir,
540 kimetsu_core::memory::MemoryScope::Project,
541 kimetsu_core::memory::MemoryKind::Fact,
542 "After-build staleness check fact",
543 )
544 .expect("add_memory");
545 let _ = build_or_load_digest(&dir, true);
546 assert!(
547 !is_stale(&dir),
548 "must NOT be stale immediately after a fresh build"
549 );
550 });
551 std::fs::remove_dir_all(dir).ok();
552 }
553
554 #[test]
557 fn digest_size_within_400_token_budget() {
558 let inputs = DigestInputs {
561 top_memories: (0..10)
562 .map(|i| {
563 (
564 "convention".to_string(),
565 "A".repeat(MEMORY_SNIPPET_CHARS) + &format!(" #{i}"),
566 )
567 })
568 .collect(),
569 manifests: (0..5)
570 .map(|i| ("cargo".to_string(), format!("Cargo{i}.toml")))
571 .collect(),
572 recent_runs: (0..5).map(|i| format!("task {i}")).collect(),
573 };
574 let config = kimetsu_core::config::ProjectConfig::default_for_project("test");
575 let digest = assemble_rule_based(&inputs, &config).expect("assemble");
576 let char_count = digest.chars().count();
577 assert!(
578 char_count <= DIGEST_CHAR_BUDGET + 3,
579 "digest must fit in budget: got {char_count} chars (budget={DIGEST_CHAR_BUDGET})"
580 );
581 let approx_tokens = char_count / 4;
583 assert!(
584 approx_tokens <= 420,
585 "approx token count {approx_tokens} must be ≤ 420"
586 );
587 }
588
589 #[test]
591 fn record_warmstart_served_is_best_effort() {
592 let tmp = std::env::temp_dir().join("kimetsu-digest-roi-besteffort");
593 record_warmstart_served(&tmp, 500, 100);
595 }
596}