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<
317 dashmap::mapref::one::Ref<'_, (FunctionId, ContractVersion), ContractsReport>,
318 > {
319 let key = (function_id.clone(), version);
320 if let Some(entry) = self.contracts_cache.get(&key) {
321 return Ok(entry);
322 }
323 let report = build_fn()?;
324 self.contracts_cache.insert(key.clone(), report);
325 Ok(self.contracts_cache.get(&key).unwrap())
326 }
327
328 pub fn call_graph(&self) -> Option<&ProjectCallGraph> {
334 if let Some(cg) = self.call_graph.get() {
335 return Some(cg);
336 }
337 if let Some(cached) = self.daemon.query_call_graph() {
339 let _ = self.call_graph.set(cached);
341 return self.call_graph.get();
342 }
343 None
344 }
345
346 pub fn set_call_graph(&self, cg: ProjectCallGraph) -> Result<(), ProjectCallGraph> {
348 self.call_graph.set(cg)
349 }
350
351 pub fn change_impact(&self) -> Option<&ChangeImpactReport> {
353 self.change_impact.get()
354 }
355
356 pub fn set_change_impact(
360 &self,
361 report: ChangeImpactReport,
362 ) -> Result<(), Box<ChangeImpactReport>> {
363 self.change_impact.set(report).map_err(Box::new)
364 }
365
366 #[cfg(test)]
368 pub fn test_fixture() -> Self {
369 Self::new(
370 PathBuf::from("/tmp/test-project"),
371 Language::Rust,
372 vec![],
373 FunctionDiff {
374 changed: vec![],
375 inserted: vec![],
376 deleted: vec![],
377 },
378 HashMap::new(),
379 HashMap::new(),
380 HashMap::new(),
381 )
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use std::path::PathBuf;
389
390 #[test]
391 fn test_l2_context_new() {
392 let ctx = L2Context::new(
393 PathBuf::from("/projects/myapp"),
394 Language::Python,
395 vec![PathBuf::from("src/main.py")],
396 FunctionDiff {
397 changed: vec![],
398 inserted: vec![],
399 deleted: vec![],
400 },
401 HashMap::new(),
402 HashMap::new(),
403 HashMap::new(),
404 );
405
406 assert_eq!(ctx.project, PathBuf::from("/projects/myapp"));
407 assert_eq!(ctx.language, Language::Python);
408 assert_eq!(ctx.changed_files.len(), 1);
409 assert_eq!(ctx.changed_files[0], PathBuf::from("src/main.py"));
410 assert!(ctx.changed_functions().is_empty());
411 assert!(ctx.inserted_functions().is_empty());
412 assert!(ctx.deleted_functions().is_empty());
413 assert!(ctx.baseline_contents.is_empty());
414 assert!(ctx.current_contents.is_empty());
415 assert!(ctx.ast_changes.is_empty());
416 }
417
418 #[test]
419 fn test_l2_context_test_fixture() {
420 let ctx = L2Context::test_fixture();
421
422 assert_eq!(ctx.project, PathBuf::from("/tmp/test-project"));
423 assert_eq!(ctx.language, Language::Rust);
424 assert!(ctx.changed_files.is_empty());
425 assert!(ctx.changed_functions().is_empty());
426 assert!(ctx.inserted_functions().is_empty());
427 assert!(ctx.deleted_functions().is_empty());
428 assert!(ctx.baseline_contents.is_empty());
429 assert!(ctx.current_contents.is_empty());
430 assert!(ctx.ast_changes.is_empty());
431 }
432
433 #[test]
434 fn test_function_change_fields() {
435 let change = FunctionChange {
436 id: FunctionId::new("src/lib.rs", "compute", 1),
437 name: "compute".to_string(),
438 old_source: "fn compute() { 1 + 1 }".to_string(),
439 new_source: "fn compute() { 2 + 2 }".to_string(),
440 };
441
442 assert_eq!(change.id.file, PathBuf::from("src/lib.rs"));
443 assert_eq!(change.id.qualified_name, "compute");
444 assert_eq!(change.name, "compute");
445 assert!(change.old_source.contains("1 + 1"));
446 assert!(change.new_source.contains("2 + 2"));
447 }
448
449 #[test]
450 fn test_inserted_function_fields() {
451 let inserted = InsertedFunction {
452 id: FunctionId::new("src/new.rs", "fresh_func", 1),
453 name: "fresh_func".to_string(),
454 source: "fn fresh_func() -> bool { true }".to_string(),
455 };
456
457 assert_eq!(inserted.id.file, PathBuf::from("src/new.rs"));
458 assert_eq!(inserted.id.qualified_name, "fresh_func");
459 assert_eq!(inserted.name, "fresh_func");
460 assert!(inserted.source.contains("true"));
461 }
462
463 #[test]
464 fn test_deleted_function_fields() {
465 let deleted = DeletedFunction {
466 id: FunctionId::new("src/old.rs", "stale_func", 1),
467 name: "stale_func".to_string(),
468 };
469
470 assert_eq!(deleted.id.file, PathBuf::from("src/old.rs"));
471 assert_eq!(deleted.id.qualified_name, "stale_func");
472 assert_eq!(deleted.name, "stale_func");
473 }
474
475 #[test]
476 fn test_contract_version_eq() {
477 assert_eq!(ContractVersion::Baseline, ContractVersion::Baseline);
478 assert_eq!(ContractVersion::Current, ContractVersion::Current);
479 assert_ne!(ContractVersion::Baseline, ContractVersion::Current);
480 assert_ne!(ContractVersion::Current, ContractVersion::Baseline);
481 }
482
483 #[test]
484 fn test_contract_version_hash_consistency() {
485 use std::collections::HashSet;
486
487 let mut set = HashSet::new();
488 set.insert(ContractVersion::Baseline);
489 set.insert(ContractVersion::Baseline); set.insert(ContractVersion::Current);
491
492 assert_eq!(set.len(), 2, "HashSet should deduplicate identical variants");
493 assert!(set.contains(&ContractVersion::Baseline));
494 assert!(set.contains(&ContractVersion::Current));
495 }
496
497 #[test]
498 fn test_l2_context_with_data() {
499 let file_a = PathBuf::from("src/alpha.rs");
500 let file_b = PathBuf::from("src/beta.rs");
501 let file_c = PathBuf::from("src/gamma.rs");
502
503 let changed = vec![
504 FunctionChange {
505 id: FunctionId::new("src/alpha.rs", "do_alpha", 1),
506 name: "do_alpha".to_string(),
507 old_source: "fn do_alpha() {}".to_string(),
508 new_source: "fn do_alpha() { todo!() }".to_string(),
509 },
510 FunctionChange {
511 id: FunctionId::new("src/alpha.rs", "do_alpha2", 5),
512 name: "do_alpha2".to_string(),
513 old_source: "fn do_alpha2() {}".to_string(),
514 new_source: "fn do_alpha2() { 42 }".to_string(),
515 },
516 ];
517
518 let inserted = vec![InsertedFunction {
519 id: FunctionId::new("src/beta.rs", "new_beta", 1),
520 name: "new_beta".to_string(),
521 source: "fn new_beta() -> u32 { 0 }".to_string(),
522 }];
523
524 let deleted = vec![DeletedFunction {
525 id: FunctionId::new("src/gamma.rs", "old_gamma", 1),
526 name: "old_gamma".to_string(),
527 }];
528
529 let mut baseline = HashMap::new();
530 baseline.insert(file_a.clone(), "// alpha baseline".to_string());
531 baseline.insert(file_c.clone(), "// gamma baseline".to_string());
532
533 let mut current = HashMap::new();
534 current.insert(file_a.clone(), "// alpha current".to_string());
535 current.insert(file_b.clone(), "// beta current".to_string());
536
537 let ctx = L2Context::new(
538 PathBuf::from("/workspace"),
539 Language::Rust,
540 vec![file_a.clone(), file_b.clone(), file_c.clone()],
541 FunctionDiff {
542 changed,
543 inserted,
544 deleted,
545 },
546 baseline,
547 current,
548 HashMap::new(),
549 );
550
551 assert_eq!(ctx.changed_files.len(), 3);
552 assert_eq!(ctx.changed_functions().len(), 2);
553 assert_eq!(ctx.inserted_functions().len(), 1);
554 assert_eq!(ctx.deleted_functions().len(), 1);
555 assert_eq!(ctx.baseline_contents.len(), 2);
556 assert_eq!(ctx.current_contents.len(), 2);
557
558 assert_eq!(ctx.changed_functions()[0].name, "do_alpha");
560 assert_eq!(ctx.changed_functions()[1].name, "do_alpha2");
561 assert_eq!(ctx.inserted_functions()[0].name, "new_beta");
562 assert_eq!(ctx.deleted_functions()[0].name, "old_gamma");
563
564 assert_eq!(
565 ctx.baseline_contents.get(&file_a).unwrap(),
566 "// alpha baseline"
567 );
568 assert_eq!(
569 ctx.current_contents.get(&file_b).unwrap(),
570 "// beta current"
571 );
572 }
573
574 #[test]
575 fn test_function_change_clone() {
576 let original = FunctionChange {
577 id: FunctionId::new("src/lib.rs", "my_fn", 1),
578 name: "my_fn".to_string(),
579 old_source: "old".to_string(),
580 new_source: "new".to_string(),
581 };
582 let cloned = original.clone();
583
584 assert_eq!(cloned.id, original.id);
585 assert_eq!(cloned.name, original.name);
586 assert_eq!(cloned.old_source, original.old_source);
587 assert_eq!(cloned.new_source, original.new_source);
588 }
589
590 #[test]
591 fn test_inserted_function_clone() {
592 let original = InsertedFunction {
593 id: FunctionId::new("src/lib.rs", "ins_fn", 1),
594 name: "ins_fn".to_string(),
595 source: "fn ins_fn() {}".to_string(),
596 };
597 let cloned = original.clone();
598
599 assert_eq!(cloned.id, original.id);
600 assert_eq!(cloned.name, original.name);
601 assert_eq!(cloned.source, original.source);
602 }
603
604 #[test]
605 fn test_deleted_function_clone() {
606 let original = DeletedFunction {
607 id: FunctionId::new("src/lib.rs", "del_fn", 1),
608 name: "del_fn".to_string(),
609 };
610 let cloned = original.clone();
611
612 assert_eq!(cloned.id, original.id);
613 assert_eq!(cloned.name, original.name);
614 }
615
616 #[test]
617 fn test_contract_version_clone() {
618 let v1 = ContractVersion::Baseline;
619 let v2 = v1.clone();
620 assert_eq!(v1, v2);
621
622 let v3 = ContractVersion::Current;
623 let v4 = v3.clone();
624 assert_eq!(v3, v4);
625 }
626
627 const PYTHON_ADD: &str = "def add(a, b):\n return a + b\n";
633
634 #[test]
635 fn test_cfg_cache_miss_then_hit() {
636 let ctx = L2Context::test_fixture();
637 let fid = FunctionId::new("test.py", "add", 1);
638
639 let result = ctx.cfg_for(PYTHON_ADD, &fid, Language::Python);
641 assert!(result.is_ok(), "CFG build should succeed: {:?}", result.err());
642 let cfg = result.unwrap();
643 assert_eq!(cfg.function, "add");
644 drop(cfg);
645
646 let result2 = ctx.cfg_for(PYTHON_ADD, &fid, Language::Python);
648 assert!(result2.is_ok());
649 let cfg2 = result2.unwrap();
650 assert_eq!(cfg2.function, "add");
651 }
652
653 #[test]
654 fn test_dfg_cache_miss_then_hit() {
655 let ctx = L2Context::test_fixture();
656 let fid = FunctionId::new("test.py", "add", 1);
657
658 let result = ctx.dfg_for(PYTHON_ADD, &fid, Language::Python);
660 assert!(result.is_ok(), "DFG build should succeed: {:?}", result.err());
661 let dfg = result.unwrap();
662 assert_eq!(dfg.function, "add");
663 drop(dfg);
664
665 let result2 = ctx.dfg_for(PYTHON_ADD, &fid, Language::Python);
667 assert!(result2.is_ok());
668 let dfg2 = result2.unwrap();
669 assert_eq!(dfg2.function, "add");
670 }
671
672 #[test]
673 fn test_ssa_cache_miss_then_hit() {
674 let ctx = L2Context::test_fixture();
675 let fid = FunctionId::new("test.py", "add", 1);
676
677 let result = ctx.ssa_for(PYTHON_ADD, &fid, Language::Python);
679 assert!(result.is_ok(), "SSA build should succeed: {:?}", result.err());
680 let ssa = result.unwrap();
681 assert_eq!(ssa.function, "add");
682 drop(ssa);
683
684 let result2 = ctx.ssa_for(PYTHON_ADD, &fid, Language::Python);
686 assert!(result2.is_ok());
687 let ssa2 = result2.unwrap();
688 assert_eq!(ssa2.function, "add");
689 }
690
691 #[test]
692 fn test_contracts_cache_stores_and_retrieves() {
693 use crate::commands::contracts::types::ContractsReport;
694
695 let ctx = L2Context::test_fixture();
696 let fid = FunctionId::new("test.py", "add", 1);
697
698 let report = ContractsReport {
699 function: "add".to_string(),
700 file: PathBuf::from("test.py"),
701 preconditions: vec![],
702 postconditions: vec![],
703 invariants: vec![],
704 };
705 let report_clone = report.clone();
706
707 let result = ctx.contracts_for(&fid, ContractVersion::Baseline, || Ok(report));
709 assert!(result.is_ok());
710 let cached = result.unwrap();
711 assert_eq!(cached.function, "add");
712 drop(cached);
713
714 let result2 = ctx.contracts_for(&fid, ContractVersion::Baseline, || {
716 panic!("build_fn should not be called on cache hit");
717 });
718 assert!(result2.is_ok());
719 assert_eq!(result2.unwrap().function, report_clone.function);
720 }
721
722 #[test]
723 fn test_call_graph_set_and_get() {
724 let ctx = L2Context::test_fixture();
725
726 assert!(ctx.call_graph().is_none());
728
729 let cg = ProjectCallGraph::default();
731 assert!(ctx.set_call_graph(cg).is_ok());
732
733 assert!(ctx.call_graph().is_some());
735 }
736
737 #[test]
738 fn test_call_graph_double_set_fails() {
739 let ctx = L2Context::test_fixture();
740
741 let cg1 = ProjectCallGraph::default();
742 assert!(ctx.set_call_graph(cg1).is_ok());
743
744 let cg2 = ProjectCallGraph::default();
746 assert!(ctx.set_call_graph(cg2).is_err());
747 }
748
749 #[test]
750 fn test_change_impact_set_and_get() {
751 let ctx = L2Context::test_fixture();
752
753 assert!(ctx.change_impact().is_none());
755
756 let report = ChangeImpactReport {
757 changed_files: vec![PathBuf::from("src/main.rs")],
758 affected_tests: vec![],
759 affected_test_functions: vec![],
760 affected_functions: vec![],
761 detection_method: "call_graph".to_string(),
762 metadata: None,
763 };
764 assert!(ctx.set_change_impact(report).is_ok());
765
766 let stored = ctx.change_impact().unwrap();
767 assert_eq!(stored.changed_files.len(), 1);
768 assert_eq!(stored.detection_method, "call_graph");
769 }
770
771 #[test]
772 fn test_cache_fields_independent() {
773 let ctx = L2Context::test_fixture();
774 let fid = FunctionId::new("test.py", "add", 1);
775
776 let cfg_result = ctx.cfg_for(PYTHON_ADD, &fid, Language::Python);
778 assert!(cfg_result.is_ok());
779 drop(cfg_result);
780
781 assert!(
783 ctx.dfg_cache.get(&fid).is_none(),
784 "DFG cache should be empty when only CFG was built"
785 );
786
787 let dfg_result = ctx.dfg_for(PYTHON_ADD, &fid, Language::Python);
789 assert!(dfg_result.is_ok());
790 drop(dfg_result);
791
792 assert!(ctx.cfg_cache.get(&fid).is_some());
794 assert!(ctx.dfg_cache.get(&fid).is_some());
795 }
796
797 #[test]
798 fn test_cfg_cache_different_functions() {
799 let ctx = L2Context::test_fixture();
800
801 let source = "def foo(x):\n return x\n\ndef bar(y):\n return y + 1\n";
802 let fid_foo = FunctionId::new("multi.py", "foo", 1);
803 let fid_bar = FunctionId::new("multi.py", "bar", 4);
804
805 let r1 = ctx.cfg_for(source, &fid_foo, Language::Python);
807 assert!(r1.is_ok());
808 let cfg_foo = r1.unwrap();
809 assert_eq!(cfg_foo.function, "foo");
810 drop(cfg_foo);
811
812 let r2 = ctx.cfg_for(source, &fid_bar, Language::Python);
814 assert!(r2.is_ok());
815 let cfg_bar = r2.unwrap();
816 assert_eq!(cfg_bar.function, "bar");
817 drop(cfg_bar);
818
819 assert_eq!(ctx.cfg_cache.len(), 2);
821 assert_eq!(ctx.cfg_cache.get(&fid_foo).unwrap().function, "foo");
822 assert_eq!(ctx.cfg_cache.get(&fid_bar).unwrap().function, "bar");
823 }
824
825 #[test]
830 fn test_l2_context_default_is_not_first_run() {
831 let ctx = L2Context::test_fixture();
832 assert!(!ctx.is_first_run, "Default L2Context should not be first run");
833 }
834
835 #[test]
836 fn test_l2_context_with_first_run_true() {
837 let ctx = L2Context::test_fixture().with_first_run(true);
838 assert!(ctx.is_first_run, "with_first_run(true) should set is_first_run");
839 }
840
841 #[test]
842 fn test_l2_context_with_first_run_false() {
843 let ctx = L2Context::test_fixture().with_first_run(false);
844 assert!(!ctx.is_first_run, "with_first_run(false) should unset is_first_run");
845 }
846
847 #[test]
848 fn test_l2_context_with_first_run_chainable() {
849 let ctx = L2Context::new(
851 PathBuf::from("/tmp/test"),
852 Language::Python,
853 vec![],
854 FunctionDiff {
855 changed: vec![],
856 inserted: vec![],
857 deleted: vec![],
858 },
859 HashMap::new(),
860 HashMap::new(),
861 HashMap::new(),
862 )
863 .with_first_run(true);
864
865 assert!(ctx.is_first_run);
866 assert_eq!(ctx.project, PathBuf::from("/tmp/test"));
867 assert_eq!(ctx.language, Language::Python);
868 }
869
870 use super::super::daemon_client::DaemonClient;
875
876 struct MockDaemonWithCallGraph {
878 call_graph: ProjectCallGraph,
879 notifications: std::sync::Mutex<Vec<Vec<PathBuf>>>,
880 }
881
882 impl MockDaemonWithCallGraph {
883 fn new() -> Self {
884 Self {
885 call_graph: ProjectCallGraph::default(),
886 notifications: std::sync::Mutex::new(Vec::new()),
887 }
888 }
889 }
890
891 impl DaemonClient for MockDaemonWithCallGraph {
892 fn is_available(&self) -> bool {
893 true
894 }
895
896 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
897 Some(self.call_graph.clone())
898 }
899
900 fn query_cfg(
901 &self,
902 _function_id: &FunctionId,
903 ) -> Option<tldr_core::CfgInfo> {
904 None
905 }
906
907 fn query_dfg(
908 &self,
909 _function_id: &FunctionId,
910 ) -> Option<tldr_core::DfgInfo> {
911 None
912 }
913
914 fn query_ssa(
915 &self,
916 _function_id: &FunctionId,
917 ) -> Option<tldr_core::ssa::SsaFunction> {
918 None
919 }
920
921 fn notify_changed_files(&self, changed_files: &[PathBuf]) {
922 self.notifications
923 .lock()
924 .unwrap()
925 .push(changed_files.to_vec());
926 }
927 }
928
929 struct MockUnavailableDaemon;
931
932 impl DaemonClient for MockUnavailableDaemon {
933 fn is_available(&self) -> bool {
934 false
935 }
936 fn query_call_graph(&self) -> Option<ProjectCallGraph> {
937 None
938 }
939 fn query_cfg(&self, _fid: &FunctionId) -> Option<tldr_core::CfgInfo> {
940 None
941 }
942 fn query_dfg(&self, _fid: &FunctionId) -> Option<tldr_core::DfgInfo> {
943 None
944 }
945 fn query_ssa(&self, _fid: &FunctionId) -> Option<tldr_core::ssa::SsaFunction> {
946 None
947 }
948 fn notify_changed_files(&self, _files: &[PathBuf]) {}
949 }
950
951 #[test]
953 fn test_l2_context_default_daemon_not_available() {
954 let ctx = L2Context::test_fixture();
955 assert!(
956 !ctx.daemon_available(),
957 "Default L2Context should have NoDaemon (not available)"
958 );
959 }
960
961 #[test]
963 fn test_l2_context_with_daemon_sets_available() {
964 let ctx = L2Context::test_fixture()
965 .with_daemon(Box::new(MockDaemonWithCallGraph::new()));
966 assert!(
967 ctx.daemon_available(),
968 "L2Context with mock daemon should report available"
969 );
970 }
971
972 #[test]
974 fn test_l2_context_with_daemon_notifies_changed_files() {
975 use std::sync::Arc;
976
977 let notifications: Arc<std::sync::Mutex<Vec<Vec<PathBuf>>>> =
979 Arc::new(std::sync::Mutex::new(Vec::new()));
980
981 struct ArcTrackingDaemon {
982 notified: Arc<std::sync::Mutex<Vec<Vec<PathBuf>>>>,
983 }
984 impl DaemonClient for ArcTrackingDaemon {
985 fn is_available(&self) -> bool { true }
986 fn query_call_graph(&self) -> Option<ProjectCallGraph> { None }
987 fn query_cfg(&self, _fid: &FunctionId) -> Option<tldr_core::CfgInfo> { None }
988 fn query_dfg(&self, _fid: &FunctionId) -> Option<tldr_core::DfgInfo> { None }
989 fn query_ssa(&self, _fid: &FunctionId) -> Option<tldr_core::ssa::SsaFunction> { None }
990 fn notify_changed_files(&self, files: &[PathBuf]) {
991 self.notified.lock().unwrap().push(files.to_vec());
992 }
993 }
994
995 let changed = vec![PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")];
996 let ctx = L2Context::new(
997 PathBuf::from("/tmp/test"),
998 Language::Rust,
999 changed.clone(),
1000 FunctionDiff {
1001 changed: vec![],
1002 inserted: vec![],
1003 deleted: vec![],
1004 },
1005 HashMap::new(),
1006 HashMap::new(),
1007 HashMap::new(),
1008 );
1009
1010 let daemon = ArcTrackingDaemon {
1011 notified: Arc::clone(¬ifications),
1012 };
1013 let ctx = ctx.with_daemon(Box::new(daemon));
1014
1015 assert!(ctx.daemon_available());
1016
1017 let recorded = notifications.lock().unwrap();
1019 assert_eq!(
1020 recorded.len(),
1021 1,
1022 "with_daemon should have called notify_changed_files exactly once"
1023 );
1024 assert_eq!(
1025 recorded[0], changed,
1026 "notify_changed_files should receive the context's changed_files"
1027 );
1028 }
1029
1030 #[test]
1032 fn test_l2_context_daemon_not_available_call_graph_none() {
1033 let ctx = L2Context::test_fixture()
1034 .with_daemon(Box::new(MockUnavailableDaemon));
1035
1036 assert!(
1037 ctx.call_graph().is_none(),
1038 "Unavailable daemon should not provide call graph"
1039 );
1040 }
1041
1042 #[test]
1045 fn test_l2_context_daemon_available_uses_cached_call_graph() {
1046 let ctx = L2Context::test_fixture()
1047 .with_daemon(Box::new(MockDaemonWithCallGraph::new()));
1048
1049 let cg = ctx.call_graph();
1051 assert!(
1052 cg.is_some(),
1053 "Available daemon should provide cached call graph"
1054 );
1055 }
1056
1057 #[test]
1059 fn test_l2_context_daemon_not_available_cfg_uses_local() {
1060 let ctx = L2Context::test_fixture()
1061 .with_daemon(Box::new(MockUnavailableDaemon));
1062 let fid = FunctionId::new("test.py", "add", 1);
1063
1064 let result = ctx.cfg_for(PYTHON_ADD, &fid, Language::Python);
1066 assert!(
1067 result.is_ok(),
1068 "cfg_for should fall back to local computation: {:?}",
1069 result.err()
1070 );
1071 let cfg = result.unwrap();
1072 assert_eq!(cfg.function, "add");
1073 }
1074
1075 #[test]
1077 fn test_l2_context_daemon_not_available_dfg_uses_local() {
1078 let ctx = L2Context::test_fixture()
1079 .with_daemon(Box::new(MockUnavailableDaemon));
1080 let fid = FunctionId::new("test.py", "add", 1);
1081
1082 let result = ctx.dfg_for(PYTHON_ADD, &fid, Language::Python);
1083 assert!(
1084 result.is_ok(),
1085 "dfg_for should fall back to local computation: {:?}",
1086 result.err()
1087 );
1088 }
1089
1090 #[test]
1092 fn test_l2_context_daemon_not_available_ssa_uses_local() {
1093 let ctx = L2Context::test_fixture()
1094 .with_daemon(Box::new(MockUnavailableDaemon));
1095 let fid = FunctionId::new("test.py", "add", 1);
1096
1097 let result = ctx.ssa_for(PYTHON_ADD, &fid, Language::Python);
1098 assert!(
1099 result.is_ok(),
1100 "ssa_for should fall back to local computation: {:?}",
1101 result.err()
1102 );
1103 }
1104
1105 #[test]
1107 fn test_l2_context_with_daemon_chainable() {
1108 let ctx = L2Context::test_fixture()
1109 .with_first_run(true)
1110 .with_daemon(Box::new(MockDaemonWithCallGraph::new()));
1111
1112 assert!(ctx.is_first_run);
1113 assert!(ctx.daemon_available());
1114 }
1115
1116 #[test]
1118 fn test_l2_context_daemon_accessor() {
1119 let ctx = L2Context::test_fixture();
1120 assert!(!ctx.daemon().is_available());
1122
1123 let ctx2 = L2Context::test_fixture()
1124 .with_daemon(Box::new(MockDaemonWithCallGraph::new()));
1125 assert!(ctx2.daemon().is_available());
1126 }
1127
1128 #[test]
1130 fn test_l2_context_local_call_graph_takes_precedence() {
1131 let ctx = L2Context::test_fixture()
1132 .with_daemon(Box::new(MockDaemonWithCallGraph::new()));
1133
1134 let local_cg = ProjectCallGraph::default();
1136 assert!(ctx.set_call_graph(local_cg).is_ok());
1137
1138 let cg = ctx.call_graph();
1140 assert!(cg.is_some(), "Local call graph should take precedence");
1141 }
1142}