/ src / resolution / mod.rs
mod.rs
  1  use crate::error::AbundantisError;
  2  use crate::Result;
  3  use compact_str::CompactString;
  4  use dashmap::DashMap;
  5  use lru::LruCache;
  6  use parking_lot::RwLock;
  7  use std::collections::{HashMap, HashSet};
  8  use std::num::NonZeroUsize;
  9  use std::sync::atomic::{AtomicU64, Ordering};
 10  use std::sync::Arc;
 11  use std::time::{Duration, Instant};
 12  
 13  #[cfg(feature = "async")]
 14  use maybe_async::must_be_async;
 15  #[cfg(not(feature = "async"))]
 16  use maybe_async::must_be_sync;
 17  
 18  #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
 19  pub struct CacheKey {
 20      pub key: CompactString,
 21      pub context_hash: u64,
 22  }
 23  
 24  impl CacheKey {
 25      pub fn new(key: impl Into<CompactString>, context_hash: u64) -> Self {
 26          Self {
 27              key: key.into(),
 28              context_hash,
 29          }
 30      }
 31  }
 32  
 33  #[derive(Debug, Clone)]
 34  pub struct CachedValue {
 35      pub value: Arc<ResolvedVariable>,
 36      pub cached_at: Instant,
 37  }
 38  
 39  #[derive(Debug, Clone)]
 40  pub struct ResolvedVariable {
 41      pub key: CompactString,
 42      pub raw_value: CompactString,
 43      pub resolved_value: CompactString,
 44      pub source: super::source::VariableSource,
 45      pub description: Option<CompactString>,
 46      pub has_warnings: bool,
 47      pub interpolation_depth: u32,
 48  }
 49  
 50  #[derive(Debug, Clone)]
 51  pub struct DependencyEdge {
 52      pub from: CompactString,
 53      pub to: CompactString,
 54      pub span: Option<(u32, u32)>,
 55  }
 56  
 57  #[derive(Debug, Clone)]
 58  pub struct DependencyGraph {
 59      edges: Vec<DependencyEdge>,
 60      nodes: HashMap<CompactString, Vec<DependencyEdge>>,
 61  }
 62  
 63  impl DependencyGraph {
 64      pub fn new() -> Self {
 65          Self {
 66              edges: Vec::new(),
 67              nodes: HashMap::new(),
 68          }
 69      }
 70  
 71      pub fn add_edge(&mut self, from: CompactString, to: CompactString, span: Option<(u32, u32)>) {
 72          let edge = DependencyEdge {
 73              from: from.clone(),
 74              to: to.clone(),
 75              span,
 76          };
 77          self.edges.push(edge.clone());
 78          self.nodes.entry(from).or_default().push(edge);
 79      }
 80  
 81      pub fn detect_cycle(&self, start: &str) -> Vec<CompactString> {
 82          let mut visited = HashMap::new();
 83          let mut path = Vec::new();
 84          self.detect_cycle_with_state(start, &mut visited, &mut path)
 85      }
 86  
 87      pub fn detect_cycle_with_state(
 88          &self,
 89          start: &str,
 90          visited: &mut HashMap<CompactString, bool>,
 91          path: &mut Vec<CompactString>,
 92      ) -> Vec<CompactString> {
 93          visited.clear();
 94          path.clear();
 95  
 96          if self.dfs_detect_cycle(start, visited, path) {
 97              std::mem::take(path)
 98          } else {
 99              Vec::new()
100          }
101      }
102  
103      fn dfs_detect_cycle(
104          &self,
105          current: &str,
106          visited: &mut HashMap<CompactString, bool>,
107          path: &mut Vec<CompactString>,
108      ) -> bool {
109          if let Some(&in_path) = visited.get(current) {
110              if in_path {
111                  return true;
112              }
113              return false;
114          }
115  
116          visited.insert(CompactString::new(current), true);
117          path.push(CompactString::new(current));
118  
119          if let Some(edges) = self.nodes.get(current) {
120              for edge in edges {
121                  if self.dfs_detect_cycle(&edge.to, visited, path) {
122                      return true;
123                  }
124              }
125          }
126  
127          path.pop();
128          visited.insert(CompactString::new(current), false);
129          false
130      }
131  
132      pub fn get_dependencies(&self, key: &str) -> Vec<CompactString> {
133          self.nodes
134              .get(key)
135              .map(|edges| edges.iter().map(|e| e.to.clone()).collect())
136              .unwrap_or_default()
137      }
138  
139      pub fn clear(&mut self) {
140          self.edges.clear();
141          self.nodes.clear();
142      }
143  }
144  
145  impl Default for DependencyGraph {
146      fn default() -> Self {
147          Self::new()
148      }
149  }
150  
151  pub struct ResolutionCache {
152      hot_cache: Arc<RwLock<LruCache<CacheKey, CachedValue>>>,
153      ttl_cache: Arc<DashMap<CacheKey, CachedValue>>,
154      ttl: Duration,
155      enabled: bool,
156  }
157  
158  impl ResolutionCache {
159      pub fn new(config: &super::config::CacheConfig) -> Self {
160          let hot_size = NonZeroUsize::new(config.hot_cache_size.max(1))
161              .unwrap_or(NonZeroUsize::new(1000).unwrap());
162  
163          Self {
164              hot_cache: Arc::new(RwLock::new(LruCache::new(hot_size))),
165              ttl_cache: Arc::new(DashMap::new()),
166              ttl: config.ttl,
167              enabled: config.enabled,
168          }
169      }
170  
171      pub fn get(&self, key: &CacheKey) -> Option<Arc<ResolvedVariable>> {
172          if !self.enabled {
173              return None;
174          }
175  
176          let now = Instant::now();
177          let ttl = self.ttl;
178  
179          if let Some(cached) = self.ttl_cache.get(key) {
180              if now.duration_since(cached.cached_at) < ttl {
181                  return Some(Arc::clone(&cached.value));
182              }
183          }
184  
185          self.ttl_cache
186              .remove_if(key, |_, cached| now.duration_since(cached.cached_at) >= ttl);
187  
188          let mut hot = self.hot_cache.write();
189          if let Some(cached) = hot.get(key) {
190              if now.duration_since(cached.cached_at) < ttl {
191                  return Some(Arc::clone(&cached.value));
192              }
193          }
194  
195          None
196      }
197  
198      pub fn insert(&self, key: CacheKey, value: Arc<ResolvedVariable>) {
199          if !self.enabled {
200              return;
201          }
202  
203          let cached = CachedValue {
204              value,
205              cached_at: Instant::now(),
206          };
207  
208          self.ttl_cache.insert(key.clone(), cached.clone());
209  
210          let mut hot = self.hot_cache.write();
211          hot.put(key, cached);
212      }
213  
214      pub fn invalidate(&self, key: &CacheKey) {
215          if !self.enabled {
216              return;
217          }
218  
219          self.ttl_cache.remove(key);
220          let mut hot = self.hot_cache.write();
221          hot.pop(key);
222      }
223  
224      pub fn clear(&self) {
225          self.ttl_cache.clear();
226          let mut hot = self.hot_cache.write();
227          hot.clear();
228      }
229  
230      pub fn len(&self) -> usize {
231          if !self.enabled {
232              return 0;
233          }
234          self.ttl_cache.len() + self.hot_cache.read().len()
235      }
236  
237      pub fn is_empty(&self) -> bool {
238          !self.enabled || self.len() == 0
239      }
240  
241      pub fn cleanup_expired(&self) {
242          if !self.enabled {
243              return;
244          }
245  
246          let now = Instant::now();
247          self.ttl_cache
248              .retain(|_, cached| now.duration_since(cached.cached_at) < self.ttl);
249  
250          let mut hot = self.hot_cache.write();
251          let keys_to_remove: Vec<CacheKey> = hot
252              .iter()
253              .filter(|(_, cached)| now.duration_since(cached.cached_at) >= self.ttl)
254              .map(|(k, _)| k.clone())
255              .collect();
256  
257          for key in keys_to_remove {
258              hot.pop(&key);
259          }
260      }
261  }
262  
263  pub struct ResolutionEngine {
264      resolution_config: parking_lot::RwLock<super::config::ResolutionConfig>,
265      interpolation_config: parking_lot::RwLock<super::config::InterpolationConfig>,
266      cache: Arc<ResolutionCache>,
267      graph: Arc<parking_lot::RwLock<DependencyGraph>>,
268      graph_version: Arc<AtomicU64>,
269  }
270  
271  impl ResolutionEngine {
272      pub fn new(
273          resolution: &super::config::ResolutionConfig,
274          interpolation: &super::config::InterpolationConfig,
275          cache: &super::config::CacheConfig,
276      ) -> Self {
277          Self {
278              resolution_config: parking_lot::RwLock::new(resolution.clone()),
279              interpolation_config: parking_lot::RwLock::new(interpolation.clone()),
280              cache: Arc::new(ResolutionCache::new(cache)),
281              graph: Arc::new(parking_lot::RwLock::new(DependencyGraph::new())),
282              graph_version: Arc::new(AtomicU64::new(0)),
283          }
284      }
285  
286      pub fn update_resolution_config(&self, config: super::config::ResolutionConfig) {
287          *self.resolution_config.write() = config;
288          self.cache.clear();
289          tracing::info!("Resolution config updated at runtime");
290      }
291  
292      pub fn update_interpolation_config(&self, config: super::config::InterpolationConfig) {
293          *self.interpolation_config.write() = config;
294          self.cache.clear();
295          tracing::info!("Interpolation config updated at runtime");
296      }
297  
298      pub fn interpolation_enabled(&self) -> bool {
299          self.interpolation_config.read().enabled
300      }
301  
302      pub fn precedence(&self) -> Vec<super::config::SourcePrecedence> {
303          self.resolution_config.read().precedence.clone()
304      }
305  
306      fn snapshots_version(&self, snapshots: &[crate::source::SourceSnapshot]) -> u64 {
307          snapshots.iter().filter_map(|s| s.version).sum()
308      }
309  
310      fn maybe_rebuild_graph(&self, snapshots: &[crate::source::SourceSnapshot]) -> Result<()> {
311          let current_version = self.snapshots_version(snapshots);
312          let last_version = self.graph_version.load(Ordering::SeqCst);
313  
314          if current_version != last_version {
315              self.build_dependency_graph(snapshots)?;
316              self.graph_version.store(current_version, Ordering::SeqCst);
317          }
318          Ok(())
319      }
320  
321      fn filter_snapshots_ref<'a>(
322          &self,
323          snapshots: &'a [crate::source::SourceSnapshot],
324          file_source_filter: Option<&HashSet<crate::source::SourceId>>,
325      ) -> Vec<&'a crate::source::SourceSnapshot> {
326          match file_source_filter {
327              Some(filter) if !filter.is_empty() => snapshots
328                  .iter()
329                  .filter(|snapshot| {
330                      let source_id_str = snapshot.source_id.as_str();
331                      let is_file_source =
332                          source_id_str.starts_with("file:") && source_id_str.len() > 5;
333                      if is_file_source {
334                          filter.contains(&snapshot.source_id)
335                      } else {
336                          true
337                      }
338                  })
339                  .collect(),
340  
341              Some(_) => snapshots
342                  .iter()
343                  .filter(|snapshot| {
344                      let source_id_str = snapshot.source_id.as_str();
345                      let is_file_source =
346                          source_id_str.starts_with("file:") && source_id_str.len() > 5;
347                      !is_file_source
348                  })
349                  .collect(),
350  
351              None => snapshots.iter().collect(),
352          }
353      }
354  
355      fn filter_by_source_type<'a>(
356          &self,
357          snapshots: &[&'a crate::source::SourceSnapshot],
358      ) -> Vec<&'a crate::source::SourceSnapshot> {
359          let config = self.resolution_config.read();
360          let precedence = &config.precedence;
361  
362          if precedence.is_empty() {
363              return Vec::new();
364          }
365  
366          snapshots
367              .iter()
368              .filter(|snapshot| {
369                  let source_id_str = snapshot.source_id.as_str();
370  
371                  let source_type = if source_id_str.starts_with("file:") {
372                      crate::config::SourcePrecedence::File
373                  } else if source_id_str == "shell" || source_id_str.starts_with("shell:") {
374                      crate::config::SourcePrecedence::Shell
375                  } else if source_id_str.starts_with("external:") {
376                      crate::config::SourcePrecedence::Remote
377                  } else {
378                      return true;
379                  };
380  
381                  precedence.contains(&source_type)
382              })
383              .copied()
384              .collect()
385      }
386  
387      fn resolve_inner(
388          &self,
389          key: &str,
390          context: &super::workspace::WorkspaceContext,
391          snapshots: &[crate::source::SourceSnapshot],
392      ) -> Result<Option<Arc<ResolvedVariable>>> {
393          let sorted_snapshots = self.sort_snapshots_by_file_order(snapshots);
394  
395          let mut resolved = None;
396  
397          for snapshot in &sorted_snapshots {
398              if let Some(variable) = snapshot.variables.iter().find(|v| v.key.as_str() == key) {
399                  resolved = Some(self.resolve_variable(
400                      variable,
401                      snapshots,
402                      context,
403                      0,
404                      &mut Vec::new(),
405                  )?);
406              }
407          }
408  
409          if let Some(ref var) = resolved {
410              let context_hash = self.hash_context(context);
411              let cache_key = CacheKey {
412                  key: CompactString::new(key),
413                  context_hash,
414              };
415              self.cache.insert(cache_key, Arc::clone(var));
416          }
417  
418          Ok(resolved)
419      }
420  
421      fn sort_snapshots_by_file_order<'a>(
422          &self,
423          snapshots: &'a [crate::source::SourceSnapshot],
424      ) -> Vec<&'a crate::source::SourceSnapshot> {
425          let config = self.resolution_config.read();
426          let file_order = &config.files.order;
427  
428          let mut sorted: Vec<_> = snapshots.iter().collect();
429          sorted.sort_by(|a, b| {
430              let a_order = self.get_file_order_index(&a.source_id, file_order);
431              let b_order = self.get_file_order_index(&b.source_id, file_order);
432              a_order.cmp(&b_order)
433          });
434  
435          sorted
436      }
437  
438      fn get_file_order_index(
439          &self,
440          source_id: &crate::source::SourceId,
441          file_order: &[CompactString],
442      ) -> usize {
443          let source_str = source_id.as_str();
444          if !source_str.starts_with("file:") {
445              return 0;
446          }
447  
448          let path = &source_str[5..];
449          let filename = std::path::Path::new(path)
450              .file_name()
451              .and_then(|n| n.to_str())
452              .unwrap_or("");
453  
454          for (i, pattern) in file_order.iter().enumerate() {
455              if filename == pattern.as_str() || path.ends_with(pattern.as_str()) {
456                  return i + 1;
457              }
458          }
459  
460          file_order.len() + 1
461      }
462  
463      fn sort_snapshot_refs_by_file_order<'a>(
464          &self,
465          snapshots: &[&'a crate::source::SourceSnapshot],
466      ) -> Vec<&'a crate::source::SourceSnapshot> {
467          let config = self.resolution_config.read();
468          let file_order = &config.files.order;
469  
470          let mut sorted: Vec<_> = snapshots.to_vec();
471          sorted.sort_by(|a, b| {
472              let a_order = self.get_file_order_index(&a.source_id, file_order);
473              let b_order = self.get_file_order_index(&b.source_id, file_order);
474              a_order.cmp(&b_order)
475          });
476  
477          sorted
478      }
479  
480      fn all_variables_inner(
481          &self,
482          context: &super::workspace::WorkspaceContext,
483          all_snapshots: &[crate::source::SourceSnapshot],
484          filtered_snapshots: &[&crate::source::SourceSnapshot],
485      ) -> Result<Vec<Arc<ResolvedVariable>>> {
486          let type_filtered = self.filter_by_source_type(filtered_snapshots);
487  
488          let sorted = self.sort_snapshot_refs_by_file_order(&type_filtered);
489  
490          let mut seen_keys = std::collections::HashSet::new();
491          let mut results = Vec::new();
492  
493          for snapshot in sorted {
494              for variable in snapshot.variables.iter() {
495                  if !seen_keys.contains(&variable.key) {
496                      let resolved = self.resolve_variable(
497                          variable,
498                          all_snapshots,
499                          context,
500                          0,
501                          &mut Vec::new(),
502                      )?;
503                      results.push(resolved);
504                      seen_keys.insert(variable.key.clone());
505                  }
506              }
507          }
508  
509          Ok(results)
510      }
511  
512      #[cfg_attr(feature = "async", must_be_async)]
513      #[cfg_attr(not(feature = "async"), must_be_sync)]
514      pub async fn resolve(
515          &self,
516          key: &str,
517          context: &super::workspace::WorkspaceContext,
518          registry: &super::source::SourceRegistry,
519      ) -> Result<Option<Arc<ResolvedVariable>>> {
520          let context_hash = self.hash_context(context);
521          let cache_key = CacheKey {
522              key: CompactString::new(key),
523              context_hash,
524          };
525  
526          if let Some(cached) = self.cache.get(&cache_key) {
527              return Ok(Some(cached));
528          }
529  
530          let snapshots = registry.load_all().await.map_err(AbundantisError::Source)?;
531  
532          if self.resolution_config.read().type_check {
533              self.maybe_rebuild_graph(&snapshots)?;
534          }
535  
536          self.resolve_inner(key, context, &snapshots)
537      }
538  
539      fn resolve_variable(
540          &self,
541          variable: &super::source::ParsedVariable,
542          all_snapshots: &[crate::source::SourceSnapshot],
543          context: &super::workspace::WorkspaceContext,
544          depth: u32,
545          visited: &mut Vec<CompactString>,
546      ) -> Result<Arc<ResolvedVariable>> {
547          let key = variable.key.clone();
548          let interpolation_config = self.interpolation_config.read();
549          let max_depth = interpolation_config.max_depth;
550  
551          if !interpolation_config.enabled {
552              return Ok(Arc::new(ResolvedVariable {
553                  key: key.clone(),
554                  raw_value: variable.raw_value.clone(),
555                  resolved_value: variable.raw_value.clone(),
556                  source: variable.source.clone(),
557                  description: variable.description.clone(),
558                  has_warnings: false,
559                  interpolation_depth: 0,
560              }));
561          }
562  
563          if depth >= max_depth {
564              return Err(AbundantisError::MaxDepthExceeded {
565                  key: key.as_str().to_string(),
566                  depth,
567              });
568          }
569  
570          if visited.contains(&key) {
571              return Err(AbundantisError::CircularDependency {
572                  chain: visited
573                      .iter()
574                      .map(|k| k.as_str())
575                      .collect::<Vec<_>>()
576                      .join(" -> "),
577              });
578          }
579  
580          visited.push(key.clone());
581  
582          let resolved_value = self.interpolate_value_lazy(
583              &variable.raw_value,
584              all_snapshots,
585              context,
586              depth + 1,
587              visited,
588          );
589  
590          visited.pop();
591  
592          Ok(Arc::new(ResolvedVariable {
593              key,
594              raw_value: variable.raw_value.clone(),
595              resolved_value,
596              source: variable.source.clone(),
597              description: variable.description.clone(),
598              has_warnings: false,
599              interpolation_depth: depth,
600          }))
601      }
602  
603      fn interpolate_value_lazy(
604          &self,
605          value: &str,
606          all_snapshots: &[crate::source::SourceSnapshot],
607          _context: &super::workspace::WorkspaceContext,
608          depth: u32,
609          visited: &mut Vec<CompactString>,
610      ) -> CompactString {
611          let interpolation_config = self.interpolation_config.read();
612          let max_depth = interpolation_config.max_depth;
613  
614          if depth >= max_depth || !interpolation_config.enabled {
615              return CompactString::new(value);
616          }
617  
618          let mut germi = germi::Germi::with_config(germi::Config {
619              max_depth: (max_depth - depth) as usize,
620              ..Default::default()
621          });
622  
623          let references = self.find_variable_references(value);
624          for ref_key in references {
625              if visited.contains(&ref_key) {
626                  continue;
627              }
628  
629              for snapshot in all_snapshots {
630                  if let Some(variable) = snapshot.variables.iter().find(|v| v.key == ref_key) {
631                      let resolved_value = self.interpolate_value_lazy(
632                          &variable.raw_value,
633                          all_snapshots,
634                          _context,
635                          depth + 1,
636                          visited,
637                      );
638                      germi.add_variable(variable.key.as_str(), resolved_value.as_str());
639                      break;
640                  }
641              }
642          }
643  
644          match germi.interpolate(value) {
645              Ok(interpolated) => CompactString::new(interpolated.as_ref()),
646              Err(e) => {
647                  tracing::warn!(
648                      value = %value,
649                      depth = %depth,
650                      error = %e,
651                      "Interpolation failed, returning original value"
652                  );
653                  CompactString::new(value)
654              }
655          }
656      }
657  
658      #[cfg_attr(feature = "async", must_be_async)]
659      #[cfg_attr(not(feature = "async"), must_be_sync)]
660      pub async fn all_variables(
661          &self,
662          context: &super::workspace::WorkspaceContext,
663          registry: &super::source::SourceRegistry,
664      ) -> Result<Vec<Arc<ResolvedVariable>>> {
665          let snapshots = registry.load_all().await.map_err(AbundantisError::Source)?;
666  
667          if self.resolution_config.read().type_check {
668              self.maybe_rebuild_graph(&snapshots)?;
669          }
670  
671          self.all_variables_inner(context, &snapshots, &snapshots.iter().collect::<Vec<_>>())
672      }
673  
674      #[cfg_attr(feature = "async", must_be_async)]
675      #[cfg_attr(not(feature = "async"), must_be_sync)]
676      pub async fn resolve_with_filter(
677          &self,
678          key: &str,
679          context: &super::workspace::WorkspaceContext,
680          registry: &super::source::SourceRegistry,
681          file_source_filter: Option<&HashSet<super::source::SourceId>>,
682      ) -> Result<Option<Arc<ResolvedVariable>>> {
683          let context_hash = self.hash_context(context);
684          let cache_key = CacheKey {
685              key: CompactString::new(key),
686              context_hash,
687          };
688  
689          if let Some(cached) = self.cache.get(&cache_key) {
690              return Ok(Some(cached));
691          }
692  
693          let snapshots = registry.load_all().await.map_err(AbundantisError::Source)?;
694          let filtered_refs = self.filter_snapshots_ref(&snapshots, file_source_filter);
695  
696          let type_filtered = self.filter_by_source_type(&filtered_refs);
697  
698          if self.resolution_config.read().type_check {
699              self.maybe_rebuild_graph(&snapshots)?;
700          }
701  
702          let sorted_filtered = self.sort_snapshot_refs_by_file_order(&type_filtered);
703  
704          let mut resolved = None;
705  
706          for snapshot in sorted_filtered {
707              if let Some(variable) = snapshot.variables.iter().find(|v| v.key.as_str() == key) {
708                  resolved = Some(self.resolve_variable(
709                      variable,
710                      &snapshots,
711                      context,
712                      0,
713                      &mut Vec::new(),
714                  )?);
715              }
716          }
717  
718          if let Some(ref var) = resolved {
719              self.cache.insert(cache_key, Arc::clone(var));
720          }
721  
722          Ok(resolved)
723      }
724  
725      #[cfg_attr(feature = "async", must_be_async)]
726      #[cfg_attr(not(feature = "async"), must_be_sync)]
727      pub async fn all_variables_with_filter(
728          &self,
729          context: &super::workspace::WorkspaceContext,
730          registry: &super::source::SourceRegistry,
731          file_source_filter: Option<&HashSet<super::source::SourceId>>,
732      ) -> Result<Vec<Arc<ResolvedVariable>>> {
733          let snapshots = registry.load_all().await.map_err(AbundantisError::Source)?;
734  
735          let filtered_refs = self.filter_snapshots_ref(&snapshots, file_source_filter);
736  
737          if self.resolution_config.read().type_check {
738              self.maybe_rebuild_graph(&snapshots)?;
739          }
740  
741          self.all_variables_inner(context, &snapshots, &filtered_refs)
742      }
743  
744      fn hash_context(&self, context: &super::workspace::WorkspaceContext) -> u64 {
745          use ahash::AHasher;
746          use std::hash::{Hash, Hasher};
747  
748          let mut hasher = AHasher::default();
749          context.workspace_root.hash(&mut hasher);
750          context.package_root.hash(&mut hasher);
751          context.package_name.hash(&mut hasher);
752          for env_file in &context.env_files {
753              env_file.hash(&mut hasher);
754          }
755          hasher.finish()
756      }
757  
758      fn build_dependency_graph(&self, snapshots: &[crate::source::SourceSnapshot]) -> Result<()> {
759          let mut graph = self.graph.write();
760          graph.clear();
761  
762          for snapshot in snapshots {
763              for variable in snapshot.variables.iter() {
764                  let references = self.find_variable_references(&variable.raw_value);
765                  for ref_key in references {
766                      graph.add_edge(variable.key.clone(), ref_key, Some((0, 0)));
767                  }
768              }
769          }
770  
771          let mut visited = HashMap::new();
772          let mut path = Vec::new();
773          for snapshot in snapshots {
774              for variable in snapshot.variables.iter() {
775                  let cycle =
776                      graph.detect_cycle_with_state(variable.key.as_str(), &mut visited, &mut path);
777                  if !cycle.is_empty() {
778                      let chain = cycle
779                          .iter()
780                          .map(|k| k.as_str())
781                          .collect::<Vec<_>>()
782                          .join(" -> ");
783                      return Err(AbundantisError::CircularDependency {
784                          chain: format!("{} -> {}", chain, variable.key),
785                      });
786                  }
787              }
788          }
789  
790          Ok(())
791      }
792  
793      fn find_variable_references(&self, value: &str) -> Vec<CompactString> {
794          germi::find_variable_references(value)
795              .into_iter()
796              .map(CompactString::new)
797              .collect()
798      }
799  
800      pub fn cache(&self) -> &Arc<ResolutionCache> {
801          &self.cache
802      }
803  
804      pub fn graph(&self) -> &Arc<parking_lot::RwLock<DependencyGraph>> {
805          &self.graph
806      }
807  }
808  
809  #[cfg(test)]
810  mod tests {
811      use super::*;
812  
813      #[test]
814      fn test_cache_basics() {
815          let config = super::super::config::CacheConfig {
816              enabled: true,
817              hot_cache_size: 100,
818              ttl: Duration::from_secs(60),
819          };
820  
821          let cache = ResolutionCache::new(&config);
822          assert!(cache.is_empty());
823  
824          let key = CacheKey {
825              key: CompactString::new("TEST"),
826              context_hash: 123,
827          };
828  
829          let var = Arc::new(ResolvedVariable {
830              key: CompactString::new("TEST"),
831              raw_value: CompactString::new("value"),
832              resolved_value: CompactString::new("value"),
833              source: super::super::source::VariableSource::Memory,
834              description: None,
835              has_warnings: false,
836              interpolation_depth: 0,
837          });
838  
839          cache.insert(key.clone(), var.clone());
840          assert!(!cache.is_empty());
841          assert_eq!(cache.len(), 2);
842  
843          let retrieved = cache.get(&key).unwrap();
844          assert_eq!(retrieved.key.as_str(), "TEST");
845      }
846  
847      #[test]
848      fn test_dependency_cycle_detection() {
849          let mut graph = DependencyGraph::new();
850  
851          graph.add_edge(CompactString::new("A"), CompactString::new("B"), None);
852          graph.add_edge(CompactString::new("B"), CompactString::new("C"), None);
853          graph.add_edge(CompactString::new("C"), CompactString::new("A"), None);
854  
855          let cycle = graph.detect_cycle("A");
856          assert!(!cycle.is_empty());
857          assert!(cycle.contains(&CompactString::new("A")));
858      }
859  }