1use std::collections::hash_map::DefaultHasher;
28use std::hash::{Hash, Hasher};
29use std::path::Path;
30
31use kimetsu_core::KimetsuResult;
32use rusqlite::Connection;
33use serde::{Deserialize, Serialize};
34
35use crate::project::{load_project, load_project_readonly};
36
37const DIGEST_CHAR_BUDGET: usize = 1_600;
41const TOP_MEMORY_COUNT: usize = 5;
43const MEMORY_SNIPPET_CHARS: usize = 180;
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct DigestMeta {
50 pub input_hash: u64,
52 pub built_at: String,
54}
55
56pub fn build_or_load_digest(workspace: &Path, force_rebuild: bool) -> Option<String> {
68 build_or_load_digest_inner(workspace, force_rebuild).unwrap_or(None)
69}
70
71fn build_or_load_digest_inner(
72 workspace: &Path,
73 force_rebuild: bool,
74) -> KimetsuResult<Option<String>> {
75 let (paths, config, conn) = load_project_readonly(workspace)?;
76 let repo_root_str = paths.repo_root.to_string_lossy().to_string();
77
78 let inputs = gather_inputs(&conn, &repo_root_str)?;
80 if inputs.is_empty() {
81 return Ok(None);
82 }
83
84 let hash = content_hash(&inputs);
86
87 let cache_path = paths.kimetsu_dir.join("digest.md");
89 let meta_path = paths.kimetsu_dir.join("digest-meta.json");
90
91 let digest_text = assemble_rule_based(&inputs, &config)?;
95 if digest_text.trim().is_empty() {
96 return Ok(None);
97 }
98
99 if !force_rebuild {
101 if let Some(cached) = try_load_cache(&cache_path, &meta_path, hash) {
102 if cached == digest_text {
103 return Ok(Some(digest_text));
104 }
105 }
106 }
107
108 let meta = DigestMeta {
110 input_hash: hash,
111 built_at: now_utc_rfc3339(),
112 };
113 atomic_write_text(&cache_path, &digest_text);
114 atomic_write_json_meta(&meta_path, &meta);
115
116 Ok(Some(digest_text))
117}
118
119pub fn load_cached_digest(workspace: &Path) -> Option<String> {
126 let (paths, _config, _conn) = load_project_readonly(workspace).ok()?;
127 let text = std::fs::read_to_string(paths.kimetsu_dir.join("digest.md")).ok()?;
128 if text.trim().is_empty() {
129 None
130 } else {
131 Some(text)
132 }
133}
134
135pub fn is_stale(workspace: &Path) -> bool {
143 is_stale_inner(workspace).unwrap_or(false)
144}
145
146fn is_stale_inner(workspace: &Path) -> KimetsuResult<bool> {
147 let (paths, _config, conn) = load_project_readonly(workspace)?;
148 let repo_root_str = paths.repo_root.to_string_lossy().to_string();
149
150 let meta_path = paths.kimetsu_dir.join("digest-meta.json");
151 let cache_path = paths.kimetsu_dir.join("digest.md");
152
153 if !cache_path.exists() || !meta_path.exists() {
154 return Ok(true);
155 }
156
157 let meta = load_meta(&meta_path)?;
158 let inputs = gather_inputs(&conn, &repo_root_str)?;
159 let current_hash = content_hash(&inputs);
160
161 Ok(meta.input_hash != current_hash)
162}
163
164pub fn warm_start_block(workspace: &Path) -> Option<String> {
183 warm_start_block_scoped(workspace, "")
184}
185pub fn warm_start_block_scoped(workspace: &Path, identity: &str) -> Option<String> {
186 let block = prepare_warm_start_block_scoped(workspace, identity)?;
187 record_warmstart_served(workspace, block.digest_chars, block.resume_chars);
188 Some(block.context)
189}
190
191pub struct PreparedWarmStart {
193 pub context: String,
194 pub digest_chars: usize,
195 pub resume_chars: usize,
196}
197
198pub fn prepare_warm_start_block_scoped(
199 workspace: &Path,
200 identity: &str,
201) -> Option<PreparedWarmStart> {
202 let warm_start_enabled = kimetsu_core::paths::ProjectPaths::discover(workspace)
204 .ok()
205 .and_then(|paths| crate::project::load_config(&paths).ok())
206 .map(|cfg| cfg.broker.warm_start)
207 .unwrap_or(true);
208 if !warm_start_enabled {
209 return None;
210 }
211
212 let digest = build_or_load_digest(workspace, false);
216 let resume = crate::episode::render_resume_context_scoped(workspace, identity);
217
218 let profile = user_profile_block(workspace);
226
227 let skills = skills_block(workspace);
232
233 if digest.is_none() && resume.is_none() && profile.is_none() && skills.is_none() {
234 return None;
235 }
236
237 let mut parts: Vec<String> = Vec::new();
238 if let Some(d) = &digest {
239 parts.push(format!("## Repo context\n{d}"));
240 }
241 if let Some(p) = &profile {
242 parts.push(format!("## How you like to work\n{p}"));
243 }
244 if let Some(r) = &resume {
245 parts.push(format!("## Your prior session\n{r}"));
246 }
247 if let Some(s) = &skills {
250 parts.push(format!("## Skills ready to graduate\n{s}"));
251 }
252
253 Some(PreparedWarmStart {
254 context: parts.join("\n\n"),
255 digest_chars: digest.as_ref().map(|d| d.len()).unwrap_or(0),
256 resume_chars: resume.as_ref().map(|r| r.len()).unwrap_or(0),
257 })
258}
259
260fn skills_block(workspace: &Path) -> Option<String> {
265 let (_paths, _config, conn) = load_project_readonly(workspace).ok()?;
266 crate::skill_synthesis::graduation_notice(&conn)
267}
268
269fn user_profile_block(workspace: &Path) -> Option<String> {
274 let (_paths, config, conn) = load_project_readonly(workspace).ok()?;
275 let user_conn =
278 crate::user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)
279 .ok()
280 .flatten();
281 let profile = crate::user_profile::build_profile(&conn, user_conn.as_ref()).ok()?;
282 crate::user_profile::render_profile(&profile)
283}
284
285pub fn record_warmstart_served(workspace: &Path, digest_chars: usize, resume_chars: usize) {
294 let _ = record_warmstart_served_inner(workspace, digest_chars, resume_chars);
295}
296
297fn record_warmstart_served_inner(
298 workspace: &Path,
299 digest_chars: usize,
300 resume_chars: usize,
301) -> KimetsuResult<()> {
302 if digest_chars == 0 && resume_chars == 0 {
303 return Ok(());
304 }
305 let (_paths, _config, conn) = load_project(workspace)?;
306 let ts = now_utc_rfc3339();
307
308 if digest_chars > 0 {
309 let approx_tokens = digest_chars / 4;
310 let event = kimetsu_core::event::Event::new(
311 kimetsu_core::ids::RunId::new(),
312 "digest_served",
313 serde_json::json!({
314 "digest_chars": digest_chars,
315 "approx_tokens": approx_tokens,
316 "ts": ts,
317 }),
318 );
319 let _ = crate::projector::insert_event(&conn, &event);
320 }
321
322 if resume_chars > 0 {
323 let approx_tokens = resume_chars / 4;
324 let event = kimetsu_core::event::Event::new(
325 kimetsu_core::ids::RunId::new(),
326 "resume_served",
327 serde_json::json!({
328 "resume_chars": resume_chars,
329 "approx_tokens": approx_tokens,
330 "ts": ts,
331 }),
332 );
333 let _ = crate::projector::insert_event(&conn, &event);
334 }
335
336 Ok(())
337}
338
339#[derive(Debug, Default)]
343struct DigestInputs {
344 top_memories: Vec<(String, String)>,
346 manifests: Vec<(String, String)>,
348}
349
350impl DigestInputs {
351 fn is_empty(&self) -> bool {
352 self.top_memories.is_empty() && self.manifests.is_empty()
353 }
354}
355
356fn gather_inputs(conn: &Connection, repo_root: &str) -> KimetsuResult<DigestInputs> {
357 let mut inputs = DigestInputs::default();
358
359 {
371 let mut stmt = conn.prepare(
372 "SELECT kind, text
373 FROM memories
374 WHERE invalidated_at IS NULL
375 AND superseded_by IS NULL
376 AND (valid_from IS NULL OR julianday(valid_from) <= julianday('now'))
377 AND (valid_to IS NULL OR julianday(valid_to) > julianday('now'))
378 AND kind != 'preference'
379 ORDER BY
380 CASE WHEN use_count > 0
381 THEN (usefulness_score / CAST(use_count AS REAL))
382 ELSE 0.0
383 END DESC,
384 use_count DESC,
385 created_at DESC
386 LIMIT ?1",
387 )?;
388 let rows = stmt.query_map([TOP_MEMORY_COUNT as i64], |row| {
389 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
390 })?;
391 for (kind, text) in rows.flatten() {
392 let snippet: String = text.chars().take(MEMORY_SNIPPET_CHARS).collect();
393 inputs.top_memories.push((kind, snippet));
394 }
395 }
396
397 {
399 let mut stmt = conn.prepare(
400 "SELECT manifest_kind, manifest_path
401 FROM repo_manifests
402 WHERE repo_root = ?1
403 LIMIT 10",
404 )?;
405 let rows = stmt.query_map([repo_root], |row| {
406 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
407 })?;
408 for pair in rows.flatten() {
409 inputs.manifests.push(pair);
410 }
411 }
412
413 Ok(inputs)
416}
417
418fn content_hash(inputs: &DigestInputs) -> u64 {
421 let mut h = DefaultHasher::new();
422 for (kind, text) in &inputs.top_memories {
423 kind.hash(&mut h);
424 text.hash(&mut h);
425 }
426 for (mk, mp) in &inputs.manifests {
427 mk.hash(&mut h);
428 mp.hash(&mut h);
429 }
430 h.finish()
431}
432
433fn assemble_rule_based(
436 inputs: &DigestInputs,
437 _config: &kimetsu_core::config::ProjectConfig,
438) -> KimetsuResult<String> {
439 let mut parts: Vec<String> = Vec::new();
440
441 if !inputs.manifests.is_empty() {
443 let manifest_list: Vec<String> = inputs
444 .manifests
445 .iter()
446 .map(|(kind, path)| format!("{kind}: {path}"))
447 .collect();
448 parts.push(format!("Project manifests: {}", manifest_list.join(", ")));
449 }
450
451 if !inputs.top_memories.is_empty() {
453 parts.push("Key conventions and facts:".to_string());
454 for (kind, text) in &inputs.top_memories {
455 parts.push(format!("[{kind}] {text}"));
456 }
457 }
458
459 let digest = parts.join("\n");
460
461 if digest.len() > DIGEST_CHAR_BUDGET {
463 let mut s: String = digest.chars().take(DIGEST_CHAR_BUDGET - 3).collect();
464 s.push_str("...");
465 Ok(s)
466 } else {
467 Ok(digest)
468 }
469}
470
471fn try_load_cache(cache_path: &Path, meta_path: &Path, current_hash: u64) -> Option<String> {
474 if !cache_path.exists() || !meta_path.exists() {
475 return None;
476 }
477 let meta = load_meta(meta_path).ok()?;
478 if meta.input_hash != current_hash {
479 return None;
480 }
481 std::fs::read_to_string(cache_path).ok()
482}
483
484fn load_meta(meta_path: &Path) -> KimetsuResult<DigestMeta> {
485 let text = std::fs::read_to_string(meta_path)?;
486 Ok(serde_json::from_str(&text)?)
487}
488
489fn atomic_write_text(path: &Path, content: &str) {
491 let Some(parent) = path.parent() else {
492 return;
493 };
494 let _ = std::fs::create_dir_all(parent);
495 let tmp = path.with_extension("md.tmp");
496 if std::fs::write(&tmp, content).is_ok() {
497 let _ = std::fs::rename(&tmp, path);
498 }
499}
500
501fn atomic_write_json_meta(path: &Path, meta: &DigestMeta) {
503 let Some(parent) = path.parent() else {
504 return;
505 };
506 let _ = std::fs::create_dir_all(parent);
507 let Ok(text) = serde_json::to_string(meta) else {
508 return;
509 };
510 let tmp = path.with_extension("json.tmp");
511 if std::fs::write(&tmp, &text).is_ok() {
512 let _ = std::fs::rename(&tmp, path);
513 }
514}
515
516fn now_utc_rfc3339() -> String {
517 time::OffsetDateTime::now_utc()
518 .format(&time::format_description::well_known::Rfc3339)
519 .unwrap_or_default()
520}
521
522#[cfg(test)]
525mod tests {
526 use kimetsu_core::paths::git_init_boundary;
527
528 use super::*;
529 use crate::{project, user_brain};
530
531 #[test]
532 fn hardening_warm_profile_honors_user_brain_opt_out() {
533 user_brain::with_user_brain_disabled(|| {
534 let dir = tmp_workspace("hardening-warm-profile-off");
535 git_init_boundary(&dir);
536 project::init_project(&dir, false).unwrap();
537 let global_dir = dir.join("isolated-global");
538 std::fs::create_dir_all(&global_dir).unwrap();
539 unsafe {
541 std::env::set_var("KIMETSU_USER_BRAIN_DIR", &global_dir);
542 }
543 let global =
544 Connection::open(kimetsu_core::paths::user_brain_db_path().unwrap()).unwrap();
545 crate::schema::initialize(&global).unwrap();
546 global.execute("INSERT INTO memories(memory_id,scope,kind,text,normalized_text,confidence,provenance_snapshot_json,created_at) VALUES('global','global_user','preference','PRIVATE_GLOBAL','private_global',1.0,'{}','2026-01-01T00:00:00Z')", []).unwrap();
547 let env_disabled = user_profile_block(&dir);
548 let (paths, mut config, conn) = load_project_readonly(&dir).unwrap();
549 config.kimetsu.use_user_brain = false;
550 std::fs::write(paths.project_toml, config.to_toml().unwrap()).unwrap();
551 unsafe {
552 std::env::remove_var("KIMETSU_USER_BRAIN");
553 }
554 let config_disabled = user_profile_block(&dir);
555 unsafe {
556 std::env::set_var("KIMETSU_USER_BRAIN", "0");
557 std::env::remove_var("KIMETSU_USER_BRAIN_DIR");
558 }
559 drop(conn);
560 drop(global);
561 std::fs::remove_dir_all(dir).unwrap();
562 assert!(
563 env_disabled.is_none(),
564 "environment opt-out leaked global profile"
565 );
566 assert!(
567 config_disabled.is_none(),
568 "project opt-out leaked global profile"
569 );
570 });
571 }
572
573 #[test]
574 fn hardening_warm_digest_revalidates_corrected_and_retired_claims() {
575 user_brain::with_user_brain_disabled(|| {
576 let dir = tmp_workspace("hardening-warm-current");
577 git_init_boundary(&dir);
578 project::init_project(&dir, false).unwrap();
579 let id = project::add_memory(
580 &dir,
581 kimetsu_core::memory::MemoryScope::Project,
582 kimetsu_core::memory::MemoryKind::Fact,
583 "ORIGINAL port is 4001",
584 )
585 .unwrap();
586 assert!(
587 build_or_load_digest(&dir, true)
588 .unwrap()
589 .contains("ORIGINAL")
590 );
591 project::edit_memory(&dir, &id, Some("CORRECTED port is 4002"), None).unwrap();
592 let block = warm_start_block_scoped(&dir, "lane-a").unwrap();
593 assert!(
594 !block.contains("ORIGINAL"),
595 "stale cache must never reintroduce corrected text"
596 );
597 assert!(block.contains("CORRECTED"));
598 let (paths, _, conn) = load_project_readonly(&dir).unwrap();
601 std::fs::write(paths.kimetsu_dir.join("digest.md"), "ORIGINAL port is 4001").unwrap();
602 let mixed = warm_start_block_scoped(&dir, "lane-a").unwrap();
603 assert!(
604 mixed.contains("CORRECTED") && !mixed.contains("ORIGINAL"),
605 "mixed cache generations leaked: {mixed}"
606 );
607 drop(conn);
608 project::invalidate_memory(&dir, &id, Some("wrong claim")).unwrap();
609 assert!(
610 !warm_start_block_scoped(&dir, "lane-a")
611 .unwrap_or_default()
612 .contains("CORRECTED")
613 );
614 std::fs::remove_dir_all(dir).unwrap();
615 });
616 }
617
618 #[test]
619 fn hardening_warm_digest_excludes_invalid_time_and_other_task_focus() {
620 user_brain::with_user_brain_disabled(|| {
621 let dir = tmp_workspace("hardening-warm-validity");
622 git_init_boundary(&dir);
623 project::init_project(&dir, false).unwrap();
624 for (text, from, to) in [
625 ("CURRENT endpoint", None, None),
626 ("FUTURE endpoint", Some("2099-01-01T00:00:00Z"), None),
627 ("EXPIRED endpoint", None, Some("2020-01-01T00:00:00Z")),
628 ] {
629 project::add_memory_with_validity(
630 &dir,
631 kimetsu_core::memory::MemoryScope::Project,
632 kimetsu_core::memory::MemoryKind::Fact,
633 text,
634 from,
635 to,
636 )
637 .unwrap();
638 }
639 for lane in ["ALPHA", "BETA"] {
640 crate::episode::capture_episode(
641 &dir,
642 crate::episode::EpisodePayload {
643 identity: lane.into(),
644 task: format!("{lane} task title"),
645 summary: format!("{lane} task state"),
646 ..Default::default()
647 },
648 )
649 .unwrap();
650 }
651 let block = warm_start_block_scoped(&dir, "ALPHA").unwrap();
652 assert!(block.contains("CURRENT") && block.contains("ALPHA"));
653 assert!(
654 !block.contains("FUTURE") && !block.contains("EXPIRED") && !block.contains("BETA"),
655 "{block}"
656 );
657 std::fs::remove_dir_all(dir).unwrap();
658 });
659 }
660
661 fn tmp_workspace(name: &str) -> std::path::PathBuf {
662 let ts = std::time::SystemTime::now()
663 .duration_since(std::time::UNIX_EPOCH)
664 .map(|d| d.as_nanos())
665 .unwrap_or(0);
666 let dir = std::env::temp_dir().join(format!("kimetsu-digest-{name}-{ts}"));
667 std::fs::create_dir_all(&dir).expect("create tmp");
668 dir
669 }
670
671 #[test]
673 fn empty_brain_returns_none() {
674 let dir = tmp_workspace("empty");
675 git_init_boundary(&dir);
676 user_brain::with_user_brain_disabled(|| {
677 project::init_project(&dir, true).expect("init");
678 let result = build_or_load_digest(&dir, false);
679 assert!(result.is_none(), "empty brain must return None digest");
680 });
681 std::fs::remove_dir_all(dir).ok();
682 }
683
684 #[test]
686 fn digest_with_memories_is_bounded() {
687 let dir = tmp_workspace("bounded");
688 git_init_boundary(&dir);
689 user_brain::with_user_brain_disabled(|| {
690 project::init_project(&dir, true).expect("init");
691 project::add_memory(
694 &dir,
695 kimetsu_core::memory::MemoryScope::Project,
696 kimetsu_core::memory::MemoryKind::Convention,
697 "Always use git_init_boundary before init_project in tests",
698 )
699 .expect("add_memory");
700
701 let digest = build_or_load_digest(&dir, true).expect("digest must be Some");
702 assert!(!digest.is_empty(), "digest must be non-empty");
703 assert!(
704 digest.len() <= DIGEST_CHAR_BUDGET + 3,
705 "digest must respect char budget: {} chars",
706 digest.len()
707 );
708 });
709 std::fs::remove_dir_all(dir).ok();
710 }
711
712 #[test]
714 fn cache_is_reused_on_second_call() {
715 let dir = tmp_workspace("cache");
716 git_init_boundary(&dir);
717 user_brain::with_user_brain_disabled(|| {
718 project::init_project(&dir, true).expect("init");
719 project::add_memory(
720 &dir,
721 kimetsu_core::memory::MemoryScope::Project,
722 kimetsu_core::memory::MemoryKind::Fact,
723 "Rust edition 2024 is the target edition for this workspace",
724 )
725 .expect("add_memory");
726 let d1 = build_or_load_digest(&dir, true).expect("first build");
727 let d2 = build_or_load_digest(&dir, false).expect("cached load");
728 assert_eq!(d1, d2, "cached digest must match first build");
729 });
730 std::fs::remove_dir_all(dir).ok();
731 }
732
733 #[test]
735 fn force_rebuild_bypasses_cache() {
736 let dir = tmp_workspace("force");
737 git_init_boundary(&dir);
738 user_brain::with_user_brain_disabled(|| {
739 project::init_project(&dir, true).expect("init");
740 project::add_memory(
741 &dir,
742 kimetsu_core::memory::MemoryScope::Project,
743 kimetsu_core::memory::MemoryKind::Convention,
744 "Force rebuild test convention",
745 )
746 .expect("add_memory");
747 let d1 = build_or_load_digest(&dir, true).expect("first build");
748 let d2 = build_or_load_digest(&dir, true).expect("forced rebuild");
749 assert_eq!(
751 d1, d2,
752 "forced rebuild must produce same content when inputs unchanged"
753 );
754 });
755 std::fs::remove_dir_all(dir).ok();
756 }
757
758 #[test]
760 fn is_stale_true_when_no_cache() {
761 let dir = tmp_workspace("stale");
762 git_init_boundary(&dir);
763 user_brain::with_user_brain_disabled(|| {
764 project::init_project(&dir, true).expect("init");
765 assert!(is_stale(&dir), "must be stale when cache does not exist");
766 });
767 std::fs::remove_dir_all(dir).ok();
768 }
769
770 #[test]
772 fn is_stale_false_after_build() {
773 let dir = tmp_workspace("fresh");
774 git_init_boundary(&dir);
775 user_brain::with_user_brain_disabled(|| {
776 project::init_project(&dir, true).expect("init");
777 project::add_memory(
778 &dir,
779 kimetsu_core::memory::MemoryScope::Project,
780 kimetsu_core::memory::MemoryKind::Fact,
781 "After-build staleness check fact",
782 )
783 .expect("add_memory");
784 let _ = build_or_load_digest(&dir, true);
785 assert!(
786 !is_stale(&dir),
787 "must NOT be stale immediately after a fresh build"
788 );
789 });
790 std::fs::remove_dir_all(dir).ok();
791 }
792
793 #[test]
797 fn load_cached_digest_serves_stale_text() {
798 let dir = tmp_workspace("cached-stale");
799 git_init_boundary(&dir);
800 user_brain::with_user_brain_disabled(|| {
801 project::init_project(&dir, true).expect("init");
802 assert!(
803 load_cached_digest(&dir).is_none(),
804 "nothing cached yet on a cold brain"
805 );
806
807 project::add_memory(
808 &dir,
809 kimetsu_core::memory::MemoryScope::Project,
810 kimetsu_core::memory::MemoryKind::Fact,
811 "Cached digest fact",
812 )
813 .expect("add_memory");
814 let built = build_or_load_digest(&dir, true).expect("first build");
815 assert_eq!(load_cached_digest(&dir).as_deref(), Some(built.as_str()));
816
817 project::add_memory(
819 &dir,
820 kimetsu_core::memory::MemoryScope::Project,
821 kimetsu_core::memory::MemoryKind::Convention,
822 "A second memory that invalidates the digest hash",
823 )
824 .expect("add_memory");
825 assert!(is_stale(&dir), "corpus moved — cache must read as stale");
826 assert_eq!(
827 load_cached_digest(&dir).as_deref(),
828 Some(built.as_str()),
829 "stale cache is still served verbatim"
830 );
831 });
832 std::fs::remove_dir_all(dir).ok();
833 }
834
835 #[test]
838 fn warm_start_block_respects_gate_and_renders_digest() {
839 let dir = tmp_workspace("warm-block");
840 git_init_boundary(&dir);
841 user_brain::with_user_brain_disabled(|| {
842 project::init_project(&dir, true).expect("init");
843 project::add_memory(
844 &dir,
845 kimetsu_core::memory::MemoryScope::Project,
846 kimetsu_core::memory::MemoryKind::Convention,
847 "Warm start block convention",
848 )
849 .expect("add_memory");
850
851 let block = warm_start_block(&dir).expect("warm start must have content");
852 assert!(
853 block.contains("## Repo context"),
854 "warm start must carry the repo digest: {block}"
855 );
856
857 let paths = kimetsu_core::paths::ProjectPaths::discover(&dir).expect("paths");
859 let mut config = crate::project::load_config(&paths).expect("config");
860 config.broker.warm_start = false;
861 std::fs::write(&paths.project_toml, config.to_toml().expect("to_toml"))
862 .expect("write project.toml");
863
864 assert!(
865 warm_start_block(&dir).is_none(),
866 "[broker] warm_start = false must silence the warm start"
867 );
868 });
869 std::fs::remove_dir_all(dir).ok();
870 }
871
872 #[test]
875 fn digest_size_within_400_token_budget() {
876 let inputs = DigestInputs {
879 top_memories: (0..10)
880 .map(|i| {
881 (
882 "convention".to_string(),
883 "A".repeat(MEMORY_SNIPPET_CHARS) + &format!(" #{i}"),
884 )
885 })
886 .collect(),
887 manifests: (0..5)
888 .map(|i| ("cargo".to_string(), format!("Cargo{i}.toml")))
889 .collect(),
890 };
891 let config = kimetsu_core::config::ProjectConfig::default_for_project("test");
892 let digest = assemble_rule_based(&inputs, &config).expect("assemble");
893 let char_count = digest.chars().count();
894 assert!(
895 char_count <= DIGEST_CHAR_BUDGET + 3,
896 "digest must fit in budget: got {char_count} chars (budget={DIGEST_CHAR_BUDGET})"
897 );
898 let approx_tokens = char_count / 4;
900 assert!(
901 approx_tokens <= 420,
902 "approx token count {approx_tokens} must be ≤ 420"
903 );
904 }
905
906 #[test]
908 fn record_warmstart_served_is_best_effort() {
909 let tmp = std::env::temp_dir().join("kimetsu-digest-roi-besteffort");
910 record_warmstart_served(&tmp, 500, 100);
912 }
913}