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<
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    /// Get the cached call graph, if available.
329    ///
330    /// Checks the local OnceLock cache first. If empty and a daemon is
331    /// available, queries the daemon for a cached call graph and stores
332    /// it locally for subsequent accesses.
333    pub fn call_graph(&self) -> Option<&ProjectCallGraph> {
334        if let Some(cg) = self.call_graph.get() {
335            return Some(cg);
336        }
337        // Check daemon cache before giving up
338        if let Some(cached) = self.daemon.query_call_graph() {
339            // OnceLock::set may fail if another thread set it concurrently
340            let _ = self.call_graph.set(cached);
341            return self.call_graph.get();
342        }
343        None
344    }
345
346    /// Set the call graph (can only be set once).
347    pub fn set_call_graph(&self, cg: ProjectCallGraph) -> Result<(), ProjectCallGraph> {
348        self.call_graph.set(cg)
349    }
350
351    /// Get the cached change impact report, if available.
352    pub fn change_impact(&self) -> Option<&ChangeImpactReport> {
353        self.change_impact.get()
354    }
355
356    /// Set the change impact report (can only be set once).
357    ///
358    /// Returns `Err` with the boxed report if the value was already set.
359    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    /// Create a minimal L2Context for testing purposes.
367    #[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); // duplicate
490        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        // Verify specific data integrity
559        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    // =========================================================================
628    // Cache field tests
629    // =========================================================================
630
631    /// Simple Python function used for CFG/DFG/SSA cache tests.
632    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        // First call: cache miss, builds the CFG.
640        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        // Second call: cache hit (same result, no rebuild).
647        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        // First call: cache miss, builds the DFG.
659        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        // Second call: cache hit.
666        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        // First call: cache miss, builds SSA.
678        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        // Second call: cache hit.
685        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        // First call: cache miss, build_fn invoked.
708        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        // Second call: cache hit, build_fn is NOT invoked (would panic if called).
715        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        // Initially empty.
727        assert!(ctx.call_graph().is_none());
728
729        // Set the call graph.
730        let cg = ProjectCallGraph::default();
731        assert!(ctx.set_call_graph(cg).is_ok());
732
733        // Now available.
734        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        // Second set returns Err (OnceLock semantics).
745        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        // Initially empty.
754        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        // Build CFG for function.
777        let cfg_result = ctx.cfg_for(PYTHON_ADD, &fid, Language::Python);
778        assert!(cfg_result.is_ok());
779        drop(cfg_result);
780
781        // DFG cache for same function should still be empty (independent caches).
782        assert!(
783            ctx.dfg_cache.get(&fid).is_none(),
784            "DFG cache should be empty when only CFG was built"
785        );
786
787        // Build DFG independently.
788        let dfg_result = ctx.dfg_for(PYTHON_ADD, &fid, Language::Python);
789        assert!(dfg_result.is_ok());
790        drop(dfg_result);
791
792        // Both caches now populated.
793        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        // Cache foo.
806        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        // Cache bar independently.
813        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        // Both cached independently.
820        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    // =========================================================================
826    // PM-34: First-run field tests
827    // =========================================================================
828
829    #[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        // Verify that with_first_run is chainable (returns Self)
850        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    // =========================================================================
871    // Phase 8.4: Daemon integration tests
872    // =========================================================================
873
874    use super::super::daemon_client::DaemonClient;
875
876    /// Mock daemon that provides a cached call graph.
877    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    /// Mock daemon that is always unavailable (same as NoDaemon but verifiable).
930    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    /// Default L2Context should have NoDaemon (not available).
952    #[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    /// with_daemon should replace the default NoDaemon.
962    #[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    /// with_daemon should notify daemon of changed_files during construction.
973    #[test]
974    fn test_l2_context_with_daemon_notifies_changed_files() {
975        use std::sync::Arc;
976
977        // Shared notification tracker accessible after daemon is moved
978        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(&notifications),
1012        };
1013        let ctx = ctx.with_daemon(Box::new(daemon));
1014
1015        assert!(ctx.daemon_available());
1016
1017        // Verify the daemon was notified of changed files during with_daemon
1018        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    /// When daemon is unavailable, call_graph() should return None.
1031    #[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    /// When daemon is available and has a cached call graph, call_graph()
1043    /// should return it even though no local set_call_graph was called.
1044    #[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        // No set_call_graph was called, but daemon provides one
1050        let cg = ctx.call_graph();
1051        assert!(
1052            cg.is_some(),
1053            "Available daemon should provide cached call graph"
1054        );
1055    }
1056
1057    /// When daemon is not available, cfg_for falls back to local computation.
1058    #[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        // cfg_for should compute locally even without daemon
1065        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    /// When daemon is not available, dfg_for falls back to local computation.
1076    #[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    /// When daemon is not available, ssa_for falls back to local computation.
1091    #[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    /// with_daemon is chainable with with_first_run.
1106    #[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    /// daemon() accessor returns a reference to the daemon client.
1117    #[test]
1118    fn test_l2_context_daemon_accessor() {
1119        let ctx = L2Context::test_fixture();
1120        // Default daemon should not be available
1121        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    /// Local call_graph set takes precedence over daemon query.
1129    #[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        // Set a local call graph
1135        let local_cg = ProjectCallGraph::default();
1136        assert!(ctx.set_call_graph(local_cg).is_ok());
1137
1138        // call_graph() should return the local one (from OnceLock)
1139        let cg = ctx.call_graph();
1140        assert!(cg.is_some(), "Local call graph should take precedence");
1141    }
1142}