1use std::collections::HashMap;
16use std::path::PathBuf;
17use std::sync::OnceLock;
18
19use dashmap::DashMap;
20
21use tldr_core::ssa::SsaFunction;
22use tldr_core::{CfgInfo, ChangeImpactReport, DfgInfo, Language, ProjectCallGraph};
23
24use super::daemon_client::{DaemonClient, NoDaemon};
25use super::types::FunctionId;
26use crate::commands::contracts::types::ContractsReport;
27use crate::commands::remaining::types::ASTChange;
28
29#[derive(Debug, Clone)]
31pub struct FunctionChange {
32 pub id: FunctionId,
34 pub name: String,
36 pub old_source: String,
38 pub new_source: String,
40}
41
42#[derive(Debug, Clone)]
44pub struct InsertedFunction {
45 pub id: FunctionId,
47 pub name: String,
49 pub source: String,
51}
52
53#[derive(Debug, Clone)]
55pub struct DeletedFunction {
56 pub id: FunctionId,
58 pub name: String,
60}
61
62#[derive(Debug, Clone, Hash, Eq, PartialEq)]
67pub enum ContractVersion {
68 Baseline,
70 Current,
72}
73
74#[derive(Debug, Clone)]
80pub struct FunctionDiff {
81 pub changed: Vec<FunctionChange>,
83 pub inserted: Vec<InsertedFunction>,
85 pub deleted: Vec<DeletedFunction>,
87}
88
89pub struct L2Context {
99 pub project: PathBuf,
101 pub language: Language,
103 pub changed_files: Vec<PathBuf>,
105 pub function_diff: FunctionDiff,
107 pub baseline_contents: HashMap<PathBuf, String>,
109 pub current_contents: HashMap<PathBuf, String>,
111 pub ast_changes: HashMap<PathBuf, Vec<ASTChange>>,
117 cfg_cache: DashMap<FunctionId, CfgInfo>,
119 dfg_cache: DashMap<FunctionId, DfgInfo>,
121 ssa_cache: DashMap<FunctionId, SsaFunction>,
123 contracts_cache: DashMap<(FunctionId, ContractVersion), ContractsReport>,
125 call_graph: OnceLock<ProjectCallGraph>,
127 change_impact: OnceLock<ChangeImpactReport>,
129 pub is_first_run: bool,
135 pub base_ref: String,
138 daemon: Box<dyn DaemonClient>,
142}
143
144impl L2Context {
145 pub fn new(
151 project: PathBuf,
152 language: Language,
153 changed_files: Vec<PathBuf>,
154 function_diff: FunctionDiff,
155 baseline_contents: HashMap<PathBuf, String>,
156 current_contents: HashMap<PathBuf, String>,
157 ast_changes: HashMap<PathBuf, Vec<ASTChange>>,
158 ) -> Self {
159 Self {
160 project,
161 language,
162 changed_files,
163 function_diff,
164 baseline_contents,
165 current_contents,
166 ast_changes,
167 cfg_cache: DashMap::new(),
168 dfg_cache: DashMap::new(),
169 ssa_cache: DashMap::new(),
170 contracts_cache: DashMap::new(),
171 call_graph: OnceLock::new(),
172 change_impact: OnceLock::new(),
173 is_first_run: false,
174 base_ref: String::from("HEAD"),
175 daemon: Box::new(NoDaemon),
176 }
177 }
178
179 pub fn with_first_run(mut self, is_first_run: bool) -> Self {
185 self.is_first_run = is_first_run;
186 self
187 }
188
189 pub fn with_base_ref(mut self, base_ref: String) -> Self {
195 self.base_ref = base_ref;
196 self
197 }
198
199 pub fn with_daemon(mut self, daemon: Box<dyn DaemonClient>) -> Self {
206 daemon.notify_changed_files(&self.changed_files);
209 self.daemon = daemon;
210 self
211 }
212
213 pub fn daemon_available(&self) -> bool {
215 self.daemon.is_available()
216 }
217
218 pub fn daemon(&self) -> &dyn DaemonClient {
220 self.daemon.as_ref()
221 }
222
223 pub fn changed_functions(&self) -> &[FunctionChange] {
225 &self.function_diff.changed
226 }
227
228 pub fn inserted_functions(&self) -> &[InsertedFunction] {
230 &self.function_diff.inserted
231 }
232
233 pub fn deleted_functions(&self) -> &[DeletedFunction] {
235 &self.function_diff.deleted
236 }
237
238 pub fn cfg_for(
243 &self,
244 file_contents: &str,
245 function_id: &FunctionId,
246 language: Language,
247 ) -> anyhow::Result<dashmap::mapref::one::Ref<'_, FunctionId, CfgInfo>> {
248 if let Some(entry) = self.cfg_cache.get(function_id) {
249 return Ok(entry);
250 }
251 if let Some(cached) = self.daemon.query_cfg(function_id) {
253 self.cfg_cache.insert(function_id.clone(), cached);
254 return Ok(self.cfg_cache.get(function_id).unwrap());
255 }
256 let cfg = super::ir::build_cfg_for_function(file_contents, function_id, language)?;
257 self.cfg_cache.insert(function_id.clone(), cfg);
258 Ok(self.cfg_cache.get(function_id).unwrap())
259 }
260
261 pub fn dfg_for(
266 &self,
267 file_contents: &str,
268 function_id: &FunctionId,
269 language: Language,
270 ) -> anyhow::Result<dashmap::mapref::one::Ref<'_, FunctionId, DfgInfo>> {
271 if let Some(entry) = self.dfg_cache.get(function_id) {
272 return Ok(entry);
273 }
274 if let Some(cached) = self.daemon.query_dfg(function_id) {
276 self.dfg_cache.insert(function_id.clone(), cached);
277 return Ok(self.dfg_cache.get(function_id).unwrap());
278 }
279 let dfg = super::ir::build_dfg_for_function(file_contents, function_id, language)?;
280 self.dfg_cache.insert(function_id.clone(), dfg);
281 Ok(self.dfg_cache.get(function_id).unwrap())
282 }
283
284 pub fn ssa_for(
289 &self,
290 file_contents: &str,
291 function_id: &FunctionId,
292 language: Language,
293 ) -> anyhow::Result<dashmap::mapref::one::Ref<'_, FunctionId, SsaFunction>> {
294 if let Some(entry) = self.ssa_cache.get(function_id) {
295 return Ok(entry);
296 }
297 if let Some(cached) = self.daemon.query_ssa(function_id) {
299 self.ssa_cache.insert(function_id.clone(), cached);
300 return Ok(self.ssa_cache.get(function_id).unwrap());
301 }
302 let ssa = super::ir::build_ssa_for_function(file_contents, function_id, language)?;
303 self.ssa_cache.insert(function_id.clone(), ssa);
304 Ok(self.ssa_cache.get(function_id).unwrap())
305 }
306
307 pub fn contracts_for(
312 &self,
313 function_id: &FunctionId,
314 version: ContractVersion,
315 build_fn: impl FnOnce() -> anyhow::Result<ContractsReport>,
316 ) -> anyhow::Result<dashmap::mapref::one::Ref<'_, (FunctionId, ContractVersion), ContractsReport>>
317 {
318 let key = (function_id.clone(), version);
319 if let Some(entry) = self.contracts_cache.get(&key) {
320 return Ok(entry);
321 }
322 let report = build_fn()?;
323 self.contracts_cache.insert(key.clone(), report);
324 Ok(self.contracts_cache.get(&key).unwrap())
325 }
326
327 pub fn call_graph(&self) -> Option<&ProjectCallGraph> {
333 if let Some(cg) = self.call_graph.get() {
334 return Some(cg);
335 }
336 if let Some(cached) = self.daemon.query_call_graph() {
338 let _ = self.call_graph.set(cached);
340 return self.call_graph.get();
341 }
342 None
343 }
344
345 pub fn set_call_graph(&self, cg: ProjectCallGraph) -> Result<(), ProjectCallGraph> {
347 self.call_graph.set(cg)
348 }
349
350 pub fn change_impact(&self) -> Option<&ChangeImpactReport> {
352 self.change_impact.get()
353 }
354
355 pub fn set_change_impact(
359 &self,
360 report: ChangeImpactReport,
361 ) -> Result<(), Box<ChangeImpactReport>> {
362 self.change_impact.set(report).map_err(Box::new)
363 }
364
365 #[cfg(test)]
367 pub fn test_fixture() -> Self {
368 Self::new(
369 PathBuf::from("/tmp/test-project"),
370 Language::Rust,
371 vec![],
372 FunctionDiff {
373 changed: vec![],
374 inserted: vec![],
375 deleted: vec![],
376 },
377 HashMap::new(),
378 HashMap::new(),
379 HashMap::new(),
380 )
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use std::path::PathBuf;
388
389 #[test]
390 fn test_l2_context_new() {
391 let ctx = L2Context::new(
392 PathBuf::from("/projects/myapp"),
393 Language::Python,
394 vec![PathBuf::from("src/main.py")],
395 FunctionDiff {
396 changed: vec![],
397 inserted: vec![],
398 deleted: vec![],
399 },
400 HashMap::new(),
401 HashMap::new(),
402 HashMap::new(),
403 );
404
405 assert_eq!(ctx.project, PathBuf::from("/projects/myapp"));
406 assert_eq!(ctx.language, Language::Python);
407 assert_eq!(ctx.changed_files.len(), 1);
408 assert_eq!(ctx.changed_files[0], PathBuf::from("src/main.py"));
409 assert!(ctx.changed_functions().is_empty());
410 assert!(ctx.inserted_functions().is_empty());
411 assert!(ctx.deleted_functions().is_empty());
412 assert!(ctx.baseline_contents.is_empty());
413 assert!(ctx.current_contents.is_empty());
414 assert!(ctx.ast_changes.is_empty());
415 }
416
417 #[test]
418 fn test_l2_context_test_fixture() {
419 let ctx = L2Context::test_fixture();
420
421 assert_eq!(ctx.project, PathBuf::from("/tmp/test-project"));
422 assert_eq!(ctx.language, Language::Rust);
423 assert!(ctx.changed_files.is_empty());
424 assert!(ctx.changed_functions().is_empty());
425 assert!(ctx.inserted_functions().is_empty());
426 assert!(ctx.deleted_functions().is_empty());
427 assert!(ctx.baseline_contents.is_empty());
428 assert!(ctx.current_contents.is_empty());
429 assert!(ctx.ast_changes.is_empty());
430 }
431
432 #[test]
433 fn test_function_change_fields() {
434 let change = FunctionChange {
435 id: FunctionId::new("src/lib.rs", "compute", 1),
436 name: "compute".to_string(),
437 old_source: "fn compute() { 1 + 1 }".to_string(),
438 new_source: "fn compute() { 2 + 2 }".to_string(),
439 };
440
441 assert_eq!(change.id.file, PathBuf::from("src/lib.rs"));
442 assert_eq!(change.id.qualified_name, "compute");
443 assert_eq!(change.name, "compute");
444 assert!(change.old_source.contains("1 + 1"));
445 assert!(change.new_source.contains("2 + 2"));
446 }
447
448 #[test]
449 fn test_inserted_function_fields() {
450 let inserted = InsertedFunction {
451 id: FunctionId::new("src/new.rs", "fresh_func", 1),
452 name: "fresh_func".to_string(),
453 source: "fn fresh_func() -> bool { true }".to_string(),
454 };
455
456 assert_eq!(inserted.id.file, PathBuf::from("src/new.rs"));
457 assert_eq!(inserted.id.qualified_name, "fresh_func");
458 assert_eq!(inserted.name, "fresh_func");
459 assert!(inserted.source.contains("true"));
460 }
461
462 #[test]
463 fn test_deleted_function_fields() {
464 let deleted = DeletedFunction {
465 id: FunctionId::new("src/old.rs", "stale_func", 1),
466 name: "stale_func".to_string(),
467 };
468
469 assert_eq!(deleted.id.file, PathBuf::from("src/old.rs"));
470 assert_eq!(deleted.id.qualified_name, "stale_func");
471 assert_eq!(deleted.name, "stale_func");
472 }
473
474 #[test]
475 fn test_contract_version_eq() {
476 assert_eq!(ContractVersion::Baseline, ContractVersion::Baseline);
477 assert_eq!(ContractVersion::Current, ContractVersion::Current);
478 assert_ne!(ContractVersion::Baseline, ContractVersion::Current);
479 assert_ne!(ContractVersion::Current, ContractVersion::Baseline);
480 }
481
482 #[test]
483 fn test_contract_version_hash_consistency() {
484 use std::collections::HashSet;
485
486 let mut set = HashSet::new();
487 set.insert(ContractVersion::Baseline);
488 set.insert(ContractVersion::Baseline); set.insert(ContractVersion::Current);
490
491 assert_eq!(
492 set.len(),
493 2,
494 "HashSet should deduplicate identical variants"
495 );
496 assert!(set.contains(&ContractVersion::Baseline));
497 assert!(set.contains(&ContractVersion::Current));
498 }
499
500 #[test]
501 fn test_l2_context_with_data() {
502 let file_a = PathBuf::from("src/alpha.rs");
503 let file_b = PathBuf::from("src/beta.rs");
504 let file_c = PathBuf::from("src/gamma.rs");
505
506 let changed = vec![
507 FunctionChange {
508 id: FunctionId::new("src/alpha.rs", "do_alpha", 1),
509 name: "do_alpha".to_string(),
510 old_source: "fn do_alpha() {}".to_string(),
511 new_source: "fn do_alpha() { todo!() }".to_string(),
512 },
513 FunctionChange {
514 id: FunctionId::new("src/alpha.rs", "do_alpha2", 5),
515 name: "do_alpha2".to_string(),
516 old_source: "fn do_alpha2() {}".to_string(),
517 new_source: "fn do_alpha2() { 42 }".to_string(),
518 },
519 ];
520
521 let inserted = vec![InsertedFunction {
522 id: FunctionId::new("src/beta.rs", "new_beta", 1),
523 name: "new_beta".to_string(),
524 source: "fn new_beta() -> u32 { 0 }".to_string(),
525 }];
526
527 let deleted = vec![DeletedFunction {
528 id: FunctionId::new("src/gamma.rs", "old_gamma", 1),
529 name: "old_gamma".to_string(),
530 }];
531
532 let mut baseline = HashMap::new();
533 baseline.insert(file_a.clone(), "// alpha baseline".to_string());
534 baseline.insert(file_c.clone(), "// gamma baseline".to_string());
535
536 let mut current = HashMap::new();
537 current.insert(file_a.clone(), "// alpha current".to_string());
538 current.insert(file_b.clone(), "// beta current".to_string());
539
540 let ctx = L2Context::new(
541 PathBuf::from("/workspace"),
542 Language::Rust,
543 vec![file_a.clone(), file_b.clone(), file_c.clone()],
544 FunctionDiff {
545 changed,
546 inserted,
547 deleted,
548 },
549 baseline,
550 current,
551 HashMap::new(),
552 );
553
554 assert_eq!(ctx.changed_files.len(), 3);
555 assert_eq!(ctx.changed_functions().len(), 2);
556 assert_eq!(ctx.inserted_functions().len(), 1);
557 assert_eq!(ctx.deleted_functions().len(), 1);
558 assert_eq!(ctx.baseline_contents.len(), 2);
559 assert_eq!(ctx.current_contents.len(), 2);
560
561 assert_eq!(ctx.changed_functions()[0].name, "do_alpha");
563 assert_eq!(ctx.changed_functions()[1].name, "do_alpha2");
564 assert_eq!(ctx.inserted_functions()[0].name, "new_beta");
565 assert_eq!(ctx.deleted_functions()[0].name, "old_gamma");
566
567 assert_eq!(
568 ctx.baseline_contents.get(&file_a).unwrap(),
569 "// alpha baseline"
570 );
571 assert_eq!(
572 ctx.current_contents.get(&file_b).unwrap(),
573 "// beta current"
574 );
575 }
576
577 #[test]
578 fn test_function_change_clone() {
579 let original = FunctionChange {
580 id: FunctionId::new("src/lib.rs", "my_fn", 1),
581 name: "my_fn".to_string(),
582 old_source: "old".to_string(),
583 new_source: "new".to_string(),
584 };
585 let cloned = original.clone();
586
587 assert_eq!(cloned.id, original.id);
588 assert_eq!(cloned.name, original.name);
589 assert_eq!(cloned.old_source, original.old_source);
590 assert_eq!(cloned.new_source, original.new_source);
591 }
592
593 #[test]
594 fn test_inserted_function_clone() {
595 let original = InsertedFunction {
596 id: FunctionId::new("src/lib.rs", "ins_fn", 1),
597 name: "ins_fn".to_string(),
598 source: "fn ins_fn() {}".to_string(),
599 };
600 let cloned = original.clone();
601
602 assert_eq!(cloned.id, original.id);
603 assert_eq!(cloned.name, original.name);
604 assert_eq!(cloned.source, original.source);
605 }
606
607 #[test]
608 fn test_deleted_function_clone() {
609 let original = DeletedFunction {
610 id: FunctionId::new("src/lib.rs", "del_fn", 1),
611 name: "del_fn".to_string(),
612 };
613 let cloned = original.clone();
614
615 assert_eq!(cloned.id, original.id);
616 assert_eq!(cloned.name, original.name);
617 }
618
619 #[test]
620 fn test_contract_version_clone() {
621 let v1 = ContractVersion::Baseline;
622 let v2 = v1.clone();
623 assert_eq!(v1, v2);
624
625 let v3 = ContractVersion::Current;
626 let v4 = v3.clone();
627 assert_eq!(v3, v4);
628 }
629
630 const PYTHON_ADD: &str = "def add(a, b):\n return a + b\n";
636
637 #[test]
638 fn test_cfg_cache_miss_then_hit() {
639 let ctx = L2Context::test_fixture();
640 let fid = FunctionId::new("test.py", "add", 1);
641
642 let result = ctx.cfg_for(PYTHON_ADD, &fid, Language::Python);
644 assert!(
645 result.is_ok(),
646 "CFG build should succeed: {:?}",
647 result.err()
648 );
649 let cfg = result.unwrap();
650 assert_eq!(cfg.function, "add");
651 drop(cfg);
652
653 let result2 = ctx.cfg_for(PYTHON_ADD, &fid, Language::Python);
655 assert!(result2.is_ok());
656 let cfg2 = result2.unwrap();
657 assert_eq!(cfg2.function, "add");
658 }
659
660 #[test]
661 fn test_dfg_cache_miss_then_hit() {
662 let ctx = L2Context::test_fixture();
663 let fid = FunctionId::new("test.py", "add", 1);
664
665 let result = ctx.dfg_for(PYTHON_ADD, &fid, Language::Python);
667 assert!(
668 result.is_ok(),
669 "DFG build should succeed: {:?}",
670 result.err()
671 );
672 let dfg = result.unwrap();
673 assert_eq!(dfg.function, "add");
674 drop(dfg);
675
676 let result2 = ctx.dfg_for(PYTHON_ADD, &fid, Language::Python);
678 assert!(result2.is_ok());
679 let dfg2 = result2.unwrap();
680 assert_eq!(dfg2.function, "add");
681 }
682
683 #[test]
684 fn test_ssa_cache_miss_then_hit() {
685 let ctx = L2Context::test_fixture();
686 let fid = FunctionId::new("test.py", "add", 1);
687
688 let result = ctx.ssa_for(PYTHON_ADD, &fid, Language::Python);
690 assert!(
691 result.is_ok(),
692 "SSA build should succeed: {:?}",
693 result.err()
694 );
695 let ssa = result.unwrap();
696 assert_eq!(ssa.function, "add");
697 drop(ssa);
698
699 let result2 = ctx.ssa_for(PYTHON_ADD, &fid, Language::Python);
701 assert!(result2.is_ok());
702 let ssa2 = result2.unwrap();
703 assert_eq!(ssa2.function, "add");
704 }
705
706 #[test]
707 fn test_contracts_cache_stores_and_retrieves() {
708 use crate::commands::contracts::types::ContractsReport;
709
710 let ctx = L2Context::test_fixture();
711 let fid = FunctionId::new("test.py", "add", 1);
712
713 let report = ContractsReport {
714 function: "add".to_string(),
715 file: PathBuf::from("test.py"),
716 preconditions: vec![],
717 postconditions: vec![],
718 invariants: vec![],
719 };
720 let report_clone = report.clone();
721
722 let result = ctx.contracts_for(&fid, ContractVersion::Baseline, || Ok(report));
724 assert!(result.is_ok());
725 let cached = result.unwrap();
726 assert_eq!(cached.function, "add");
727 drop(cached);
728
729 let result2 = ctx.contracts_for(&fid, ContractVersion::Baseline, || {
731 panic!("build_fn should not be called on cache hit");
732 });
733 assert!(result2.is_ok());
734 assert_eq!(result2.unwrap().function, report_clone.function);
735 }
736
737 #[test]
738 fn test_call_graph_set_and_get() {
739 let ctx = L2Context::test_fixture();
740
741 assert!(ctx.call_graph().is_none());
743
744 let cg = ProjectCallGraph::default();
746 assert!(ctx.set_call_graph(cg).is_ok());
747
748 assert!(ctx.call_graph().is_some());
750 }
751
752 #[test]
753 fn test_call_graph_double_set_fails() {
754 let ctx = L2Context::test_fixture();
755
756 let cg1 = ProjectCallGraph::default();
757 assert!(ctx.set_call_graph(cg1).is_ok());
758
759 let cg2 = ProjectCallGraph::default();
761 assert!(ctx.set_call_graph(cg2).is_err());
762 }
763
764 #[test]
765 fn test_change_impact_set_and_get() {
766 let ctx = L2Context::test_fixture();
767
768 assert!(ctx.change_impact().is_none());
770
771 let report = ChangeImpactReport {
772 changed_files: vec![PathBuf::from("src/main.rs")],
773 affected_tests: vec![],
774 affected_test_functions: vec![],
775 affected_functions: vec![],
776 detection_method: "call_graph".to_string(),
777 metadata: None,
778 status: tldr_core::ChangeImpactStatus::Completed,
779 };
780 assert!(ctx.set_change_impact(report).is_ok());
781
782 let stored = ctx.change_impact().unwrap();
783 assert_eq!(stored.changed_files.len(), 1);
784 assert_eq!(stored.detection_method, "call_graph");
785 }
786
787 #[test]
788 fn test_cache_fields_independent() {
789 let ctx = L2Context::test_fixture();
790 let fid = FunctionId::new("test.py", "add", 1);
791
792 let cfg_result = ctx.cfg_for(PYTHON_ADD, &fid, Language::Python);
794 assert!(cfg_result.is_ok());
795 drop(cfg_result);
796
797 assert!(
799 ctx.dfg_cache.get(&fid).is_none(),
800 "DFG cache should be empty when only CFG was built"
801 );
802
803 let dfg_result = ctx.dfg_for(PYTHON_ADD, &fid, Language::Python);
805 assert!(dfg_result.is_ok());
806 drop(dfg_result);
807
808 assert!(ctx.cfg_cache.get(&fid).is_some());
810 assert!(ctx.dfg_cache.get(&fid).is_some());
811 }
812
813 #[test]
814 fn test_cfg_cache_different_functions() {
815 let ctx = L2Context::test_fixture();
816
817 let source = "def foo(x):\n return x\n\ndef bar(y):\n return y + 1\n";
818 let fid_foo = FunctionId::new("multi.py", "foo", 1);
819 let fid_bar = FunctionId::new("multi.py", "bar", 4);
820
821 let r1 = ctx.cfg_for(source, &fid_foo, Language::Python);
823 assert!(r1.is_ok());
824 let cfg_foo = r1.unwrap();
825 assert_eq!(cfg_foo.function, "foo");
826 drop(cfg_foo);
827
828 let r2 = ctx.cfg_for(source, &fid_bar, Language::Python);
830 assert!(r2.is_ok());
831 let cfg_bar = r2.unwrap();
832 assert_eq!(cfg_bar.function, "bar");
833 drop(cfg_bar);
834
835 assert_eq!(ctx.cfg_cache.len(), 2);
837 assert_eq!(ctx.cfg_cache.get(&fid_foo).unwrap().function, "foo");
838 assert_eq!(ctx.cfg_cache.get(&fid_bar).unwrap().function, "bar");
839 }
840
841 #[test]
846 fn test_l2_context_default_is_not_first_run() {
847 let ctx = L2Context::test_fixture();
848 assert!(
849 !ctx.is_first_run,
850 "Default L2Context should not be first run"
851 );
852 }
853
854 #[test]
855 fn test_l2_context_with_first_run_true() {
856 let ctx = L2Context::test_fixture().with_first_run(true);
857 assert!(
858 ctx.is_first_run,
859 "with_first_run(true) should set is_first_run"
860 );
861 }
862
863 #[test]
864 fn test_l2_context_with_first_run_false() {
865 let ctx = L2Context::test_fixture().with_first_run(false);
866 assert!(
867 !ctx.is_first_run,
868 "with_first_run(false) should unset is_first_run"
869 );
870 }
871
872 #[test]
873 fn test_l2_context_with_first_run_chainable() {
874 let ctx = L2Context::new(
876 PathBuf::from("/tmp/test"),
877 Language::Python,
878 vec![],
879 FunctionDiff {
880 changed: vec![],
881 inserted: vec![],
882 deleted: vec![],
883 },
884 HashMap::new(),
885 HashMap::new(),
886 HashMap::new(),
887 )
888 .with_first_run(true);
889
890 assert!(ctx.is_first_run);
891 assert_eq!(ctx.project, PathBuf::from("/tmp/test"));
892 assert_eq!(ctx.language, Language::Python);
893 }
894
895 use super::super::daemon_client::DaemonClient;
900
901 struct MockDaemonWithCallGraph {
903 call_graph: ProjectCallGraph,
904 notifications: std::sync::Mutex<Vec<Vec<PathBuf>>>,
905 }
906
907 impl MockDaemonWithCallGraph {
908 fn new() -> Self {
909 Self {
910 call_graph: ProjectCallGraph::default(),
911 notifications: std::sync::Mutex::new(Vec::new()),
912 }
913 }
914 }
915
916 impl DaemonClient for MockDaemonWithCallGraph {
917 fn is_available(&self) -> bool {
918 true
919 }
920
921 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
922 Some(self.call_graph.clone())
923 }
924
925 fn query_cfg(&self, _function_id: &FunctionId) -> Option<tldr_core::CfgInfo> {
926 None
927 }
928
929 fn query_dfg(&self, _function_id: &FunctionId) -> Option<tldr_core::DfgInfo> {
930 None
931 }
932
933 fn query_ssa(&self, _function_id: &FunctionId) -> Option<tldr_core::ssa::SsaFunction> {
934 None
935 }
936
937 fn notify_changed_files(&self, changed_files: &[PathBuf]) {
938 self.notifications
939 .lock()
940 .unwrap()
941 .push(changed_files.to_vec());
942 }
943 }
944
945 struct MockUnavailableDaemon;
947
948 impl DaemonClient for MockUnavailableDaemon {
949 fn is_available(&self) -> bool {
950 false
951 }
952 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
953 None
954 }
955 fn query_cfg(&self, _fid: &FunctionId) -> Option<tldr_core::CfgInfo> {
956 None
957 }
958 fn query_dfg(&self, _fid: &FunctionId) -> Option<tldr_core::DfgInfo> {
959 None
960 }
961 fn query_ssa(&self, _fid: &FunctionId) -> Option<tldr_core::ssa::SsaFunction> {
962 None
963 }
964 fn notify_changed_files(&self, _files: &[PathBuf]) {}
965 }
966
967 #[test]
969 fn test_l2_context_default_daemon_not_available() {
970 let ctx = L2Context::test_fixture();
971 assert!(
972 !ctx.daemon_available(),
973 "Default L2Context should have NoDaemon (not available)"
974 );
975 }
976
977 #[test]
979 fn test_l2_context_with_daemon_sets_available() {
980 let ctx = L2Context::test_fixture().with_daemon(Box::new(MockDaemonWithCallGraph::new()));
981 assert!(
982 ctx.daemon_available(),
983 "L2Context with mock daemon should report available"
984 );
985 }
986
987 #[test]
989 fn test_l2_context_with_daemon_notifies_changed_files() {
990 use std::sync::Arc;
991
992 let notifications: Arc<std::sync::Mutex<Vec<Vec<PathBuf>>>> =
994 Arc::new(std::sync::Mutex::new(Vec::new()));
995
996 struct ArcTrackingDaemon {
997 notified: Arc<std::sync::Mutex<Vec<Vec<PathBuf>>>>,
998 }
999 impl DaemonClient for ArcTrackingDaemon {
1000 fn is_available(&self) -> bool {
1001 true
1002 }
1003 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
1004 None
1005 }
1006 fn query_cfg(&self, _fid: &FunctionId) -> Option<tldr_core::CfgInfo> {
1007 None
1008 }
1009 fn query_dfg(&self, _fid: &FunctionId) -> Option<tldr_core::DfgInfo> {
1010 None
1011 }
1012 fn query_ssa(&self, _fid: &FunctionId) -> Option<tldr_core::ssa::SsaFunction> {
1013 None
1014 }
1015 fn notify_changed_files(&self, files: &[PathBuf]) {
1016 self.notified.lock().unwrap().push(files.to_vec());
1017 }
1018 }
1019
1020 let changed = vec![PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")];
1021 let ctx = L2Context::new(
1022 PathBuf::from("/tmp/test"),
1023 Language::Rust,
1024 changed.clone(),
1025 FunctionDiff {
1026 changed: vec![],
1027 inserted: vec![],
1028 deleted: vec![],
1029 },
1030 HashMap::new(),
1031 HashMap::new(),
1032 HashMap::new(),
1033 );
1034
1035 let daemon = ArcTrackingDaemon {
1036 notified: Arc::clone(¬ifications),
1037 };
1038 let ctx = ctx.with_daemon(Box::new(daemon));
1039
1040 assert!(ctx.daemon_available());
1041
1042 let recorded = notifications.lock().unwrap();
1044 assert_eq!(
1045 recorded.len(),
1046 1,
1047 "with_daemon should have called notify_changed_files exactly once"
1048 );
1049 assert_eq!(
1050 recorded[0], changed,
1051 "notify_changed_files should receive the context's changed_files"
1052 );
1053 }
1054
1055 #[test]
1057 fn test_l2_context_daemon_not_available_call_graph_none() {
1058 let ctx = L2Context::test_fixture().with_daemon(Box::new(MockUnavailableDaemon));
1059
1060 assert!(
1061 ctx.call_graph().is_none(),
1062 "Unavailable daemon should not provide call graph"
1063 );
1064 }
1065
1066 #[test]
1069 fn test_l2_context_daemon_available_uses_cached_call_graph() {
1070 let ctx = L2Context::test_fixture().with_daemon(Box::new(MockDaemonWithCallGraph::new()));
1071
1072 let cg = ctx.call_graph();
1074 assert!(
1075 cg.is_some(),
1076 "Available daemon should provide cached call graph"
1077 );
1078 }
1079
1080 #[test]
1082 fn test_l2_context_daemon_not_available_cfg_uses_local() {
1083 let ctx = L2Context::test_fixture().with_daemon(Box::new(MockUnavailableDaemon));
1084 let fid = FunctionId::new("test.py", "add", 1);
1085
1086 let result = ctx.cfg_for(PYTHON_ADD, &fid, Language::Python);
1088 assert!(
1089 result.is_ok(),
1090 "cfg_for should fall back to local computation: {:?}",
1091 result.err()
1092 );
1093 let cfg = result.unwrap();
1094 assert_eq!(cfg.function, "add");
1095 }
1096
1097 #[test]
1099 fn test_l2_context_daemon_not_available_dfg_uses_local() {
1100 let ctx = L2Context::test_fixture().with_daemon(Box::new(MockUnavailableDaemon));
1101 let fid = FunctionId::new("test.py", "add", 1);
1102
1103 let result = ctx.dfg_for(PYTHON_ADD, &fid, Language::Python);
1104 assert!(
1105 result.is_ok(),
1106 "dfg_for should fall back to local computation: {:?}",
1107 result.err()
1108 );
1109 }
1110
1111 #[test]
1113 fn test_l2_context_daemon_not_available_ssa_uses_local() {
1114 let ctx = L2Context::test_fixture().with_daemon(Box::new(MockUnavailableDaemon));
1115 let fid = FunctionId::new("test.py", "add", 1);
1116
1117 let result = ctx.ssa_for(PYTHON_ADD, &fid, Language::Python);
1118 assert!(
1119 result.is_ok(),
1120 "ssa_for should fall back to local computation: {:?}",
1121 result.err()
1122 );
1123 }
1124
1125 #[test]
1127 fn test_l2_context_with_daemon_chainable() {
1128 let ctx = L2Context::test_fixture()
1129 .with_first_run(true)
1130 .with_daemon(Box::new(MockDaemonWithCallGraph::new()));
1131
1132 assert!(ctx.is_first_run);
1133 assert!(ctx.daemon_available());
1134 }
1135
1136 #[test]
1138 fn test_l2_context_daemon_accessor() {
1139 let ctx = L2Context::test_fixture();
1140 assert!(!ctx.daemon().is_available());
1142
1143 let ctx2 = L2Context::test_fixture().with_daemon(Box::new(MockDaemonWithCallGraph::new()));
1144 assert!(ctx2.daemon().is_available());
1145 }
1146
1147 #[test]
1149 fn test_l2_context_local_call_graph_takes_precedence() {
1150 let ctx = L2Context::test_fixture().with_daemon(Box::new(MockDaemonWithCallGraph::new()));
1151
1152 let local_cg = ProjectCallGraph::default();
1154 assert!(ctx.set_call_graph(local_cg).is_ok());
1155
1156 let cg = ctx.call_graph();
1158 assert!(cg.is_some(), "Local call graph should take precedence");
1159 }
1160}