Skip to main content

tldr_cli/commands/bugbot/l2/
context.rs

1//! L2Context -- shared context for all L2 analysis engines.
2//!
3//! Provides function-level change data (changed, inserted, deleted functions),
4//! file contents for both baseline and current revisions, and project-wide
5//! configuration. Includes DashMap-based caches for CFG, DFG, SSA, and
6//! contracts data, plus OnceLock-backed call graph and change impact fields.
7//!
8//! # Daemon Integration (Phase 8.4)
9//!
10//! L2Context carries an optional daemon client that routes IR queries through
11//! the daemon's QueryCache when available, falling back to on-the-fly
12//! construction when no daemon is running. The daemon field is populated via
13//! the `with_daemon` builder method.
14
15use 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/// A function that changed between baseline and current revisions.
30#[derive(Debug, Clone)]
31pub struct FunctionChange {
32    /// Unique identifier for this function.
33    pub id: FunctionId,
34    /// Human-readable function name.
35    pub name: String,
36    /// Source code in the baseline revision.
37    pub old_source: String,
38    /// Source code in the current revision.
39    pub new_source: String,
40}
41
42/// A function that was inserted (no baseline equivalent).
43#[derive(Debug, Clone)]
44pub struct InsertedFunction {
45    /// Unique identifier for this function.
46    pub id: FunctionId,
47    /// Human-readable function name.
48    pub name: String,
49    /// Source code of the inserted function.
50    pub source: String,
51}
52
53/// A function present in baseline but absent in current revision.
54#[derive(Debug, Clone)]
55pub struct DeletedFunction {
56    /// Unique identifier for this function.
57    pub id: FunctionId,
58    /// Human-readable function name.
59    pub name: String,
60}
61
62/// Version discriminator for contracts cache.
63///
64/// Used to distinguish between baseline and current versions when caching
65/// analysis results (e.g., pre-/post-conditions).
66#[derive(Debug, Clone, Hash, Eq, PartialEq)]
67pub enum ContractVersion {
68    /// The baseline (pre-change) revision.
69    Baseline,
70    /// The current (post-change) revision.
71    Current,
72}
73
74/// The function-level diff between baseline and current revisions.
75///
76/// Groups the three categories of function changes: modified, inserted, and
77/// deleted. Extracted as a separate struct to keep `L2Context::new` under
78/// clippy's argument limit while maintaining a flat public API on the context.
79#[derive(Debug, Clone)]
80pub struct FunctionDiff {
81    /// Functions whose bodies changed between revisions.
82    pub changed: Vec<FunctionChange>,
83    /// Functions present in current but not in baseline.
84    pub inserted: Vec<InsertedFunction>,
85    /// Functions present in baseline but not in current.
86    pub deleted: Vec<DeletedFunction>,
87}
88
89/// Shared context for all L2 analysis engines.
90///
91/// Carries the project root, detected language, lists of changed/inserted/deleted
92/// functions, and the full file contents for both revisions. Includes lazy-initialized
93/// DashMap caches for per-function CFG, DFG, SSA, and contracts data, plus
94/// OnceLock-backed project-level call graph and change impact report.
95///
96/// The daemon client routes IR queries through the daemon's QueryCache when
97/// available, falling back to on-the-fly construction via the local caches.
98pub struct L2Context {
99    /// Absolute path to the project root.
100    pub project: PathBuf,
101    /// Detected (or user-specified) programming language.
102    pub language: Language,
103    /// Files that have changes between baseline and current.
104    pub changed_files: Vec<PathBuf>,
105    /// Function-level diff between baseline and current revisions.
106    pub function_diff: FunctionDiff,
107    /// Full file contents for the baseline revision, keyed by path.
108    pub baseline_contents: HashMap<PathBuf, String>,
109    /// Full file contents for the current revision, keyed by path.
110    pub current_contents: HashMap<PathBuf, String>,
111    /// AST-level changes per file from the diff phase.
112    ///
113    /// Maps each changed file to its list of `ASTChange` entries (Insert, Update,
114    /// Delete). Used by DeltaEngine for finding extractors that need node-level
115    /// diff data (e.g., `param-renamed`, `signature-regression`).
116    pub ast_changes: HashMap<PathBuf, Vec<ASTChange>>,
117    /// Per-function CFG cache (Sync-safe via DashMap).
118    cfg_cache: DashMap<FunctionId, CfgInfo>,
119    /// Per-function DFG cache (Sync-safe via DashMap).
120    dfg_cache: DashMap<FunctionId, DfgInfo>,
121    /// Per-function SSA cache.
122    ssa_cache: DashMap<FunctionId, SsaFunction>,
123    /// Per-function contracts cache keyed by (FunctionId, version).
124    contracts_cache: DashMap<(FunctionId, ContractVersion), ContractsReport>,
125    /// Project-level call graph (computed once, shared).
126    call_graph: OnceLock<ProjectCallGraph>,
127    /// Change impact report (computed once, shared).
128    change_impact: OnceLock<ChangeImpactReport>,
129    /// Whether this is the first run (no prior `.bugbot/state.db`).
130    ///
131    /// When true, delta engines that require prior state (guard-removed,
132    /// contract-regression) should suppress their findings because there
133    /// is no baseline to compare against (PM-34 baseline policy).
134    pub is_first_run: bool,
135    /// Git base reference for baseline comparison (e.g. "HEAD", "main").
136    /// Used by flow engines to create baseline worktrees for project-wide diffing.
137    pub base_ref: String,
138    /// Optional daemon client for routing IR queries through the daemon's
139    /// QueryCache. When `is_available()` returns true, cache methods check
140    /// the daemon first before falling back to local computation.
141    daemon: Box<dyn DaemonClient>,
142}
143
144impl L2Context {
145    /// Create a new L2Context with the provided data.
146    ///
147    /// All cache fields (CFG, DFG, SSA, contracts, call graph, change impact)
148    /// are initialized empty and populated lazily on first access. The daemon
149    /// client defaults to `NoDaemon` (local-only computation).
150    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    /// Set whether this context represents a first-run analysis.
180    ///
181    /// When `is_first_run` is true, delta engines that require prior state
182    /// (guard-removed, contract-regression) suppress their findings because
183    /// there is no baseline to compare against (PM-34 baseline policy).
184    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    /// Set the git base reference for baseline comparison.
190    ///
191    /// Used by flow engines (e.g. TldrDifferentialEngine) to create
192    /// baseline worktrees for project-wide diffing of call graphs,
193    /// dependencies, coupling, and cohesion.
194    pub fn with_base_ref(mut self, base_ref: String) -> Self {
195        self.base_ref = base_ref;
196        self
197    }
198
199    /// Attach a daemon client to this context.
200    ///
201    /// When the daemon client reports `is_available() == true`, IR cache
202    /// methods (cfg_for, dfg_for, ssa_for, call_graph) will check the daemon
203    /// first before falling back to local computation. The daemon is also
204    /// notified of `changed_files` for cache invalidation.
205    pub fn with_daemon(mut self, daemon: Box<dyn DaemonClient>) -> Self {
206        // Notify daemon of changed files so it can invalidate stale caches
207        // before any queries are made in this analysis session.
208        daemon.notify_changed_files(&self.changed_files);
209        self.daemon = daemon;
210        self
211    }
212
213    /// Check whether a daemon is available for this context.
214    pub fn daemon_available(&self) -> bool {
215        self.daemon.is_available()
216    }
217
218    /// Get a reference to the daemon client.
219    pub fn daemon(&self) -> &dyn DaemonClient {
220        self.daemon.as_ref()
221    }
222
223    /// Convenience accessor: functions whose bodies changed between revisions.
224    pub fn changed_functions(&self) -> &[FunctionChange] {
225        &self.function_diff.changed
226    }
227
228    /// Convenience accessor: functions present in current but not in baseline.
229    pub fn inserted_functions(&self) -> &[InsertedFunction] {
230        &self.function_diff.inserted
231    }
232
233    /// Convenience accessor: functions present in baseline but not in current.
234    pub fn deleted_functions(&self) -> &[DeletedFunction] {
235        &self.function_diff.deleted
236    }
237
238    /// Get or build the CFG for a function.
239    ///
240    /// Checks the local cache first, then queries the daemon if available.
241    /// On miss, builds via `ir::build_cfg_for_function()` and stores the result.
242    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        // Check daemon cache before computing locally
252        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    /// Get or build the DFG for a function.
262    ///
263    /// Checks the local cache first, then queries the daemon if available.
264    /// On miss, builds via `ir::build_dfg_for_function()` and stores the result.
265    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        // Check daemon cache before computing locally
275        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    /// Get or build the SSA for a function.
285    ///
286    /// Checks the local cache first, then queries the daemon if available.
287    /// On miss, builds via `ir::build_ssa_for_function()` and stores the result.
288    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        // Check daemon cache before computing locally
298        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    /// Get or insert a contracts report for a (function, version) pair.
308    ///
309    /// Checks the cache first; on miss, calls `build_fn` to produce the report
310    /// and stores it.
311    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    /// Get the cached call graph, if available.
328    ///
329    /// Checks the local OnceLock cache first. If empty and a daemon is
330    /// available, queries the daemon for a cached call graph and stores
331    /// it locally for subsequent accesses.
332    pub fn call_graph(&self) -> Option<&ProjectCallGraph> {
333        if let Some(cg) = self.call_graph.get() {
334            return Some(cg);
335        }
336        // Check daemon cache before giving up
337        if let Some(cached) = self.daemon.query_call_graph() {
338            // OnceLock::set may fail if another thread set it concurrently
339            let _ = self.call_graph.set(cached);
340            return self.call_graph.get();
341        }
342        None
343    }
344
345    /// Set the call graph (can only be set once).
346    pub fn set_call_graph(&self, cg: ProjectCallGraph) -> Result<(), ProjectCallGraph> {
347        self.call_graph.set(cg)
348    }
349
350    /// Get the cached change impact report, if available.
351    pub fn change_impact(&self) -> Option<&ChangeImpactReport> {
352        self.change_impact.get()
353    }
354
355    /// Set the change impact report (can only be set once).
356    ///
357    /// Returns `Err` with the boxed report if the value was already set.
358    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    /// Create a minimal L2Context for testing purposes.
366    #[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); // duplicate
489        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        // Verify specific data integrity
562        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    // =========================================================================
631    // Cache field tests
632    // =========================================================================
633
634    /// Simple Python function used for CFG/DFG/SSA cache tests.
635    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        // First call: cache miss, builds the CFG.
643        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        // Second call: cache hit (same result, no rebuild).
654        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        // First call: cache miss, builds the DFG.
666        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        // Second call: cache hit.
677        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        // First call: cache miss, builds SSA.
689        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        // Second call: cache hit.
700        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        // First call: cache miss, build_fn invoked.
723        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        // Second call: cache hit, build_fn is NOT invoked (would panic if called).
730        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        // Initially empty.
742        assert!(ctx.call_graph().is_none());
743
744        // Set the call graph.
745        let cg = ProjectCallGraph::default();
746        assert!(ctx.set_call_graph(cg).is_ok());
747
748        // Now available.
749        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        // Second set returns Err (OnceLock semantics).
760        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        // Initially empty.
769        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        // Build CFG for function.
793        let cfg_result = ctx.cfg_for(PYTHON_ADD, &fid, Language::Python);
794        assert!(cfg_result.is_ok());
795        drop(cfg_result);
796
797        // DFG cache for same function should still be empty (independent caches).
798        assert!(
799            ctx.dfg_cache.get(&fid).is_none(),
800            "DFG cache should be empty when only CFG was built"
801        );
802
803        // Build DFG independently.
804        let dfg_result = ctx.dfg_for(PYTHON_ADD, &fid, Language::Python);
805        assert!(dfg_result.is_ok());
806        drop(dfg_result);
807
808        // Both caches now populated.
809        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        // Cache foo.
822        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        // Cache bar independently.
829        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        // Both cached independently.
836        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    // =========================================================================
842    // PM-34: First-run field tests
843    // =========================================================================
844
845    #[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        // Verify that with_first_run is chainable (returns Self)
875        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    // =========================================================================
896    // Phase 8.4: Daemon integration tests
897    // =========================================================================
898
899    use super::super::daemon_client::DaemonClient;
900
901    /// Mock daemon that provides a cached call graph.
902    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    /// Mock daemon that is always unavailable (same as NoDaemon but verifiable).
946    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    /// Default L2Context should have NoDaemon (not available).
968    #[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    /// with_daemon should replace the default NoDaemon.
978    #[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    /// with_daemon should notify daemon of changed_files during construction.
988    #[test]
989    fn test_l2_context_with_daemon_notifies_changed_files() {
990        use std::sync::Arc;
991
992        // Shared notification tracker accessible after daemon is moved
993        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(&notifications),
1037        };
1038        let ctx = ctx.with_daemon(Box::new(daemon));
1039
1040        assert!(ctx.daemon_available());
1041
1042        // Verify the daemon was notified of changed files during with_daemon
1043        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    /// When daemon is unavailable, call_graph() should return None.
1056    #[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    /// When daemon is available and has a cached call graph, call_graph()
1067    /// should return it even though no local set_call_graph was called.
1068    #[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        // No set_call_graph was called, but daemon provides one
1073        let cg = ctx.call_graph();
1074        assert!(
1075            cg.is_some(),
1076            "Available daemon should provide cached call graph"
1077        );
1078    }
1079
1080    /// When daemon is not available, cfg_for falls back to local computation.
1081    #[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        // cfg_for should compute locally even without daemon
1087        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    /// When daemon is not available, dfg_for falls back to local computation.
1098    #[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    /// When daemon is not available, ssa_for falls back to local computation.
1112    #[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    /// with_daemon is chainable with with_first_run.
1126    #[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    /// daemon() accessor returns a reference to the daemon client.
1137    #[test]
1138    fn test_l2_context_daemon_accessor() {
1139        let ctx = L2Context::test_fixture();
1140        // Default daemon should not be available
1141        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    /// Local call_graph set takes precedence over daemon query.
1148    #[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        // Set a local call graph
1153        let local_cg = ProjectCallGraph::default();
1154        assert!(ctx.set_call_graph(local_cg).is_ok());
1155
1156        // call_graph() should return the local one (from OnceLock)
1157        let cg = ctx.call_graph();
1158        assert!(cg.is_some(), "Local call graph should take precedence");
1159    }
1160}