Skip to main content

chainsaw/
session.rs

1//! Session: owns a loaded dependency graph and exposes query methods.
2//!
3//! A [`Session`] is the primary interface for library consumers (CLI, REPL,
4//! language server). It wraps graph loading, entry resolution, and keeps the
5//! background cache-write handle alive for the duration of the session.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11
12use notify::{RecommendedWatcher, RecursiveMode, Watcher};
13
14use crate::cache::{CacheWriteHandle, LOCKFILES};
15use crate::error::Error;
16use crate::graph::{EdgeId, EdgeKind, ModuleGraph, ModuleId, PackageInfo};
17use crate::loader;
18use crate::query::{self, ChainTarget, CutModule, DiffResult, TraceOptions, TraceResult};
19use crate::report::{
20    self, ChainReport, CutEntry, CutReport, DiffReport, ModuleEntry, PackageEntry,
21    PackageListEntry, PackagesReport, TraceReport,
22};
23
24/// The result of resolving a `--chain`/`--cut` argument against the graph.
25///
26/// The argument might be a file path (resolved to a [`ChainTarget::Module`])
27/// or a package name (resolved to a [`ChainTarget::Package`]).
28pub struct ResolvedTarget {
29    pub target: ChainTarget,
30    pub label: String,
31    pub exists: bool,
32}
33
34/// An open dependency-graph session.
35///
36/// Created via [`Session::open`], which loads (or builds) the graph and
37/// resolves the entry module. The background cache writer is joined on drop.
38pub struct Session {
39    graph: ModuleGraph,
40    reverse_adj: Vec<Vec<EdgeId>>,
41    root: PathBuf,
42    entry: PathBuf,
43    entry_id: ModuleId,
44    valid_extensions: &'static [&'static str],
45    from_cache: bool,
46    unresolvable_dynamic_count: usize,
47    unresolvable_dynamic_files: Vec<(PathBuf, usize)>,
48    file_warnings: Vec<String>,
49    _cache_handle: CacheWriteHandle,
50    dirty: Arc<AtomicBool>,
51    watcher: Option<RecommendedWatcher>,
52    cached_trace: Option<CachedTrace>,
53    cached_weights: Option<CachedWeights>,
54}
55
56/// Cached entry trace result, keyed on `(entry_id, include_dynamic)`.
57///
58/// The cache intentionally ignores `TraceOptions::top_n` and `ignore` because
59/// those fields only affect `heavy_packages` filtering — they don't change the
60/// underlying traversal (`static_weight`, `modules_by_cost`, `all_packages`).
61/// This is safe as long as callers use consistent options across cached calls
62/// (the REPL always uses `TraceOptions::default()`).
63struct CachedTrace {
64    entry_id: ModuleId,
65    include_dynamic: bool,
66    result: TraceResult,
67}
68
69struct CachedWeights {
70    entry_id: ModuleId,
71    include_dynamic: bool,
72    weights: Vec<u64>,
73}
74
75fn build_reverse_adj(graph: &ModuleGraph) -> Vec<Vec<EdgeId>> {
76    let mut rev = vec![Vec::new(); graph.module_count()];
77    for edge in &graph.edges {
78        rev[edge.to.0 as usize].push(edge.id);
79    }
80    rev
81}
82
83impl Session {
84    /// Load a dependency graph from `entry` and resolve the entry module.
85    ///
86    /// When `no_cache` is true the on-disk cache is bypassed entirely.
87    pub fn open(entry: &Path, no_cache: bool) -> Result<Self, Error> {
88        let (loaded, cache_handle) = loader::load_graph(entry, no_cache)?;
89
90        let entry_id = *loaded
91            .graph
92            .path_to_id
93            .get(&loaded.entry)
94            .ok_or_else(|| Error::EntryNotInGraph(loaded.entry.clone()))?;
95
96        let reverse_adj = build_reverse_adj(&loaded.graph);
97
98        Ok(Self {
99            graph: loaded.graph,
100            reverse_adj,
101            root: loaded.root,
102            entry: loaded.entry,
103            entry_id,
104            valid_extensions: loaded.valid_extensions,
105            from_cache: loaded.from_cache,
106            unresolvable_dynamic_count: loaded.unresolvable_dynamic_count,
107            unresolvable_dynamic_files: loaded.unresolvable_dynamic_files,
108            file_warnings: loaded.file_warnings,
109            _cache_handle: cache_handle,
110            dirty: Arc::new(AtomicBool::new(false)),
111            watcher: None,
112            cached_trace: None,
113            cached_weights: None,
114        })
115    }
116
117    /// Trace transitive import weight from the entry module.
118    pub fn trace(&self, opts: &TraceOptions) -> TraceResult {
119        query::trace(&self.graph, self.entry_id, opts)
120    }
121
122    /// Trace transitive import weight from an arbitrary file in the graph.
123    pub fn trace_from(
124        &self,
125        file: &Path,
126        opts: &TraceOptions,
127    ) -> Result<(TraceResult, PathBuf), Error> {
128        let canon = file
129            .canonicalize()
130            .or_else(|_| self.root.join(file).canonicalize())
131            .map_err(|e| Error::EntryNotFound(file.to_path_buf(), e))?;
132        let Some(&id) = self.graph.path_to_id.get(&canon) else {
133            return Err(Error::EntryNotInGraph(canon));
134        };
135        Ok((query::trace(&self.graph, id, opts), canon))
136    }
137
138    /// Resolve a chain/cut argument to a [`ChainTarget`].
139    ///
140    /// If the argument looks like a file path and resolves to a module in
141    /// the graph, returns `ChainTarget::Module`. Otherwise falls through
142    /// to a package name lookup.
143    pub fn resolve_target(&self, arg: &str) -> ResolvedTarget {
144        if looks_like_path(arg, self.valid_extensions)
145            && let Ok(target_path) = self.root.join(arg).canonicalize()
146            && let Some(&id) = self.graph.path_to_id.get(&target_path)
147        {
148            let p = &self.graph.module(id).path;
149            let label = p
150                .strip_prefix(&self.root)
151                .unwrap_or(p)
152                .to_string_lossy()
153                .into_owned();
154            return ResolvedTarget {
155                target: ChainTarget::Module(id),
156                label,
157                exists: true,
158            };
159        }
160        // File doesn't exist or isn't in the graph -- fall through to
161        // package name lookup. Handles packages like "six.py" or
162        // "highlight.js" whose names match file extensions.
163        let name = arg.to_string();
164        let exists = self.graph.package_map.contains_key(arg);
165        let label = name.clone();
166        ResolvedTarget {
167            target: ChainTarget::Package(name),
168            label,
169            exists,
170        }
171    }
172
173    /// Find all shortest import chains from the entry to a target.
174    pub fn chain(
175        &self,
176        target_arg: &str,
177        include_dynamic: bool,
178    ) -> (ResolvedTarget, Vec<Vec<ModuleId>>) {
179        let resolved = self.resolve_target(target_arg);
180        let chains = query::find_all_chains(
181            &self.graph,
182            self.entry_id,
183            &resolved.target,
184            include_dynamic,
185        );
186        (resolved, chains)
187    }
188
189    /// Find import chains and optimal cut points to sever them.
190    pub fn cut(
191        &mut self,
192        target_arg: &str,
193        top: i32,
194        include_dynamic: bool,
195    ) -> (ResolvedTarget, Vec<Vec<ModuleId>>, Vec<CutModule>) {
196        let resolved = self.resolve_target(target_arg);
197        let chains = query::find_all_chains(
198            &self.graph,
199            self.entry_id,
200            &resolved.target,
201            include_dynamic,
202        );
203        self.ensure_weights(include_dynamic);
204        let weights = &self.cached_weights.as_ref().unwrap().weights;
205        let cuts = query::find_cut_modules(
206            &self.graph,
207            &chains,
208            self.entry_id,
209            &resolved.target,
210            top,
211            weights,
212        );
213        (resolved, chains, cuts)
214    }
215
216    /// Trace from a different entry point in the same graph and diff
217    /// against the current entry. Returns the diff and the canonical
218    /// path of the other entry (avoids redundant canonicalization by
219    /// the caller).
220    pub fn diff_entry(
221        &mut self,
222        other: &Path,
223        opts: &TraceOptions,
224    ) -> Result<(DiffResult, PathBuf), Error> {
225        let other_canon = other
226            .canonicalize()
227            .or_else(|_| self.root.join(other).canonicalize())
228            .map_err(|e| Error::EntryNotFound(other.to_path_buf(), e))?;
229        let Some(&other_id) = self.graph.path_to_id.get(&other_canon) else {
230            return Err(Error::EntryNotInGraph(other_canon.clone()));
231        };
232        self.ensure_trace(opts);
233        let snap_a = self
234            .cached_trace
235            .as_ref()
236            .unwrap()
237            .result
238            .to_snapshot(&self.entry_label());
239        let snap_b = query::trace(&self.graph, other_id, opts)
240            .to_snapshot(&self.entry_label_for(&other_canon));
241        Ok((query::diff_snapshots(&snap_a, &snap_b), other_canon))
242    }
243
244    /// All third-party packages in the dependency graph.
245    pub fn packages(&self) -> &HashMap<String, PackageInfo> {
246        &self.graph.package_map
247    }
248
249    /// List direct imports of a file (outgoing edges).
250    pub fn imports(&self, file: &Path) -> Result<Vec<(PathBuf, EdgeKind)>, Error> {
251        let canon = file
252            .canonicalize()
253            .or_else(|_| self.root.join(file).canonicalize())
254            .map_err(|e| Error::EntryNotFound(file.to_path_buf(), e))?;
255        let Some(&id) = self.graph.path_to_id.get(&canon) else {
256            return Err(Error::EntryNotInGraph(canon));
257        };
258        let result = self
259            .graph
260            .outgoing_edges(id)
261            .iter()
262            .map(|&eid| {
263                let edge = self.graph.edge(eid);
264                (self.graph.module(edge.to).path.clone(), edge.kind)
265            })
266            .collect();
267        Ok(result)
268    }
269
270    /// List files that import a given file (reverse edge lookup).
271    pub fn importers(&self, file: &Path) -> Result<Vec<(PathBuf, EdgeKind)>, Error> {
272        let canon = file
273            .canonicalize()
274            .or_else(|_| self.root.join(file).canonicalize())
275            .map_err(|e| Error::EntryNotFound(file.to_path_buf(), e))?;
276        let Some(&id) = self.graph.path_to_id.get(&canon) else {
277            return Err(Error::EntryNotInGraph(canon));
278        };
279        let result = self.reverse_adj[id.0 as usize]
280            .iter()
281            .map(|&eid| {
282                let edge = self.graph.edge(eid);
283                (self.graph.module(edge.from).path.clone(), edge.kind)
284            })
285            .collect();
286        Ok(result)
287    }
288
289    /// Look up package info by name.
290    pub fn info(&self, package_name: &str) -> Option<&PackageInfo> {
291        self.graph.package_map.get(package_name)
292    }
293
294    /// Display label for the current entry point, including the project
295    /// directory name for disambiguation (e.g. `wrangler/src/index.ts`).
296    pub fn entry_label(&self) -> String {
297        self.entry_label_for(&self.entry)
298    }
299
300    /// Display label for an arbitrary path, relative to the project root.
301    pub fn entry_label_for(&self, path: &Path) -> String {
302        entry_label(path, &self.root)
303    }
304
305    /// Switch the default entry point to a different file in the graph.
306    ///
307    /// The file must already be in the graph (no rebuild). Accepts both
308    /// absolute paths and paths relative to the project root.
309    pub fn set_entry(&mut self, path: &Path) -> Result<(), Error> {
310        let canon = path
311            .canonicalize()
312            .or_else(|_| self.root.join(path).canonicalize())
313            .map_err(|e| Error::EntryNotFound(path.to_path_buf(), e))?;
314        let Some(&id) = self.graph.path_to_id.get(&canon) else {
315            return Err(Error::EntryNotInGraph(canon));
316        };
317        self.entry = canon;
318        self.entry_id = id;
319        self.invalidate_cache();
320        Ok(())
321    }
322
323    /// Start watching the project root for file changes.
324    ///
325    /// After calling this, `refresh()` will short-circuit when no relevant
326    /// files have changed since the last refresh. Idempotent: calling
327    /// `watch()` again replaces the existing watcher.
328    pub fn watch(&mut self) {
329        let dirty = Arc::clone(&self.dirty);
330        let extensions: Vec<String> = self
331            .valid_extensions
332            .iter()
333            .map(|&e| e.to_string())
334            .collect();
335
336        let handler = move |event: notify::Result<notify::Event>| {
337            if dirty.load(Ordering::Relaxed) {
338                return;
339            }
340            let Ok(event) = event else { return };
341            match event.kind {
342                notify::EventKind::Create(_)
343                | notify::EventKind::Modify(_)
344                | notify::EventKind::Remove(_) => {}
345                _ => return,
346            }
347            if event.paths.iter().any(|p| is_relevant_path(p, &extensions)) {
348                dirty.store(true, Ordering::Release);
349            }
350        };
351
352        if let Ok(mut watcher) = RecommendedWatcher::new(handler, notify::Config::default())
353            && watcher.watch(&self.root, RecursiveMode::Recursive).is_ok()
354        {
355            self.watcher = Some(watcher);
356        }
357    }
358
359    /// Whether the watcher has detected changes since the last refresh.
360    pub fn is_dirty(&self) -> bool {
361        self.dirty.load(Ordering::Acquire)
362    }
363
364    /// Check for file changes and rebuild the graph if needed.
365    ///
366    /// Returns `true` if the graph was updated (cold build or module count
367    /// changed since the last load).
368    #[allow(clippy::used_underscore_binding)] // _cache_handle held for drop
369    pub fn refresh(&mut self) -> Result<bool, Error> {
370        // Fast path: if a watcher is active and no relevant files changed,
371        // skip the full cache-hit path entirely.
372        if self.watcher.is_some() && !self.dirty.swap(false, Ordering::AcqRel) {
373            return Ok(false);
374        }
375
376        let (loaded, handle) = loader::load_graph(&self.entry, false)?;
377        let Some(&entry_id) = loaded.graph.path_to_id.get(&loaded.entry) else {
378            return Err(Error::EntryNotInGraph(loaded.entry));
379        };
380        // Detect structural change: cold build (not from cache) or module count
381        // changed. When from_cache is true and module count matches, edges are
382        // guaranteed identical (tier 1.5 only returns from_cache when imports
383        // are unchanged), so we can reuse the existing reverse adjacency index.
384        let changed =
385            !loaded.from_cache || loaded.graph.module_count() != self.graph.module_count();
386        if changed {
387            self.reverse_adj = build_reverse_adj(&loaded.graph);
388            self.invalidate_cache();
389        } else {
390            debug_assert_eq!(
391                self.reverse_adj,
392                build_reverse_adj(&loaded.graph),
393                "reverse_adj out of sync: cache reported unchanged but edges differ"
394            );
395        }
396        self.graph = loaded.graph;
397        self.root = loaded.root;
398        self.entry = loaded.entry;
399        self.entry_id = entry_id;
400        self.valid_extensions = loaded.valid_extensions;
401        self.from_cache = loaded.from_cache;
402        self.unresolvable_dynamic_count = loaded.unresolvable_dynamic_count;
403        self.unresolvable_dynamic_files = loaded.unresolvable_dynamic_files;
404        self.file_warnings = loaded.file_warnings;
405        self._cache_handle = handle;
406        Ok(changed)
407    }
408
409    // -- query cache --
410
411    fn invalidate_cache(&mut self) {
412        self.cached_trace = None;
413        self.cached_weights = None;
414    }
415
416    fn ensure_trace(&mut self, opts: &TraceOptions) {
417        let valid = self.cached_trace.as_ref().is_some_and(|c| {
418            c.entry_id == self.entry_id && c.include_dynamic == opts.include_dynamic
419        });
420        if !valid {
421            let result = query::trace(&self.graph, self.entry_id, opts);
422            self.cached_trace = Some(CachedTrace {
423                entry_id: self.entry_id,
424                include_dynamic: opts.include_dynamic,
425                result,
426            });
427        }
428    }
429
430    fn ensure_weights(&mut self, include_dynamic: bool) {
431        let valid = self
432            .cached_weights
433            .as_ref()
434            .is_some_and(|c| c.entry_id == self.entry_id && c.include_dynamic == include_dynamic);
435        if !valid {
436            let weights =
437                query::compute_exclusive_weights(&self.graph, self.entry_id, include_dynamic);
438            self.cached_weights = Some(CachedWeights {
439                entry_id: self.entry_id,
440                include_dynamic,
441                weights,
442            });
443        }
444    }
445
446    // -- report builders --
447
448    /// Trace and produce a display-ready report.
449    pub fn trace_report(&mut self, opts: &TraceOptions, top_modules: i32) -> TraceReport {
450        self.ensure_trace(opts);
451        let result = &self.cached_trace.as_ref().unwrap().result;
452        build_trace_report(
453            result,
454            &self.entry,
455            &self.graph,
456            &self.root,
457            opts,
458            top_modules,
459        )
460    }
461
462    /// Trace from a different file and produce a display-ready report.
463    pub fn trace_from_report(
464        &self,
465        file: &Path,
466        opts: &TraceOptions,
467        top_modules: i32,
468    ) -> Result<(TraceReport, PathBuf), Error> {
469        let (result, canon) = self.trace_from(file, opts)?;
470        Ok((
471            build_trace_report(&result, &canon, &self.graph, &self.root, opts, top_modules),
472            canon,
473        ))
474    }
475
476    /// Find import chains and produce a display-ready report.
477    pub fn chain_report(&self, target_arg: &str, include_dynamic: bool) -> ChainReport {
478        let (resolved, chains) = self.chain(target_arg, include_dynamic);
479        ChainReport {
480            target: resolved.label,
481            found_in_graph: resolved.exists,
482            chain_count: chains.len(),
483            hop_count: chains.first().map_or(0, |c| c.len().saturating_sub(1)),
484            chains: chains
485                .iter()
486                .map(|chain| report::chain_display_names(&self.graph, chain, &self.root))
487                .collect(),
488        }
489    }
490
491    /// Find cut points and produce a display-ready report.
492    pub fn cut_report(&mut self, target_arg: &str, top: i32, include_dynamic: bool) -> CutReport {
493        let (resolved, chains, cuts) = self.cut(target_arg, top, include_dynamic);
494        CutReport {
495            target: resolved.label,
496            found_in_graph: resolved.exists,
497            chain_count: chains.len(),
498            direct_import: !chains.is_empty()
499                && cuts.is_empty()
500                && chains.iter().all(|c| c.len() == 2),
501            cut_points: cuts
502                .iter()
503                .map(|c| CutEntry {
504                    module: report::display_name(&self.graph, c.module_id, &self.root),
505                    exclusive_size_bytes: c.exclusive_size,
506                    chains_broken: c.chains_broken,
507                })
508                .collect(),
509        }
510    }
511
512    /// Diff two entry points and produce a display-ready report.
513    pub fn diff_report(
514        &mut self,
515        other: &Path,
516        opts: &TraceOptions,
517        limit: i32,
518    ) -> Result<DiffReport, Error> {
519        let (diff, other_canon) = self.diff_entry(other, opts)?;
520        let entry_a = self.entry_label();
521        let entry_b = self.entry_label_for(&other_canon);
522        Ok(DiffReport::from_diff(&diff, &entry_a, &entry_b, limit))
523    }
524
525    /// List packages and produce a display-ready report.
526    #[allow(clippy::cast_sign_loss)]
527    pub fn packages_report(&self, top: i32) -> PackagesReport {
528        let mut packages: Vec<_> = self.graph.package_map.values().collect();
529        packages.sort_by(|a, b| b.total_reachable_size.cmp(&a.total_reachable_size));
530        let total = packages.len();
531        let display_count = if top < 0 {
532            total
533        } else {
534            total.min(top as usize)
535        };
536
537        PackagesReport {
538            package_count: total,
539            packages: packages[..display_count]
540                .iter()
541                .map(|pkg| PackageListEntry {
542                    name: pkg.name.clone(),
543                    total_size_bytes: pkg.total_reachable_size,
544                    file_count: pkg.total_reachable_files,
545                })
546                .collect(),
547        }
548    }
549
550    // -- accessors --
551
552    pub fn graph(&self) -> &ModuleGraph {
553        &self.graph
554    }
555
556    pub fn root(&self) -> &Path {
557        &self.root
558    }
559
560    pub fn entry(&self) -> &Path {
561        &self.entry
562    }
563
564    pub fn entry_id(&self) -> ModuleId {
565        self.entry_id
566    }
567
568    pub fn valid_extensions(&self) -> &'static [&'static str] {
569        self.valid_extensions
570    }
571
572    pub fn from_cache(&self) -> bool {
573        self.from_cache
574    }
575
576    pub fn unresolvable_dynamic_count(&self) -> usize {
577        self.unresolvable_dynamic_count
578    }
579
580    pub fn unresolvable_dynamic_files(&self) -> &[(PathBuf, usize)] {
581        &self.unresolvable_dynamic_files
582    }
583
584    pub fn file_warnings(&self) -> &[String] {
585        &self.file_warnings
586    }
587}
588
589/// Build a display label for an entry point that includes the project
590/// directory name for disambiguation (e.g. `wrangler/src/index.ts`
591/// instead of just `src/index.ts`).
592pub fn entry_label(path: &Path, root: &Path) -> String {
593    let rel = path.strip_prefix(root).unwrap_or(path);
594    root.file_name().map_or_else(
595        || rel.to_string_lossy().into_owned(),
596        |name| Path::new(name).join(rel).to_string_lossy().into_owned(),
597    )
598}
599
600#[allow(clippy::cast_sign_loss)]
601fn build_trace_report(
602    result: &TraceResult,
603    entry_path: &Path,
604    graph: &ModuleGraph,
605    root: &Path,
606    opts: &TraceOptions,
607    top_modules: i32,
608) -> TraceReport {
609    let heavy_packages = result
610        .heavy_packages
611        .iter()
612        .map(|pkg| PackageEntry {
613            name: pkg.name.clone(),
614            total_size_bytes: pkg.total_size,
615            file_count: pkg.file_count,
616            chain: report::chain_display_names(graph, &pkg.chain, root),
617        })
618        .collect();
619
620    let display_count = if top_modules < 0 {
621        result.modules_by_cost.len()
622    } else {
623        result.modules_by_cost.len().min(top_modules as usize)
624    };
625    let modules_by_cost = result.modules_by_cost[..display_count]
626        .iter()
627        .map(|mc| ModuleEntry {
628            path: report::relative_path(&graph.module(mc.module_id).path, root),
629            exclusive_size_bytes: mc.exclusive_size,
630        })
631        .collect();
632
633    TraceReport {
634        entry: report::relative_path(entry_path, root),
635        static_weight_bytes: result.static_weight,
636        static_module_count: result.static_module_count,
637        dynamic_only_weight_bytes: result.dynamic_only_weight,
638        dynamic_only_module_count: result.dynamic_only_module_count,
639        heavy_packages,
640        modules_by_cost,
641        total_modules_with_cost: result.modules_by_cost.len(),
642        include_dynamic: opts.include_dynamic,
643        top: opts.top_n,
644    }
645}
646
647/// Determine whether a chain/cut argument looks like a file path
648/// (as opposed to a package name).
649pub fn looks_like_path(arg: &str, extensions: &[&str]) -> bool {
650    !arg.starts_with('@')
651        && (arg.contains('/')
652            || arg.contains(std::path::MAIN_SEPARATOR)
653            || arg
654                .rsplit_once('.')
655                .is_some_and(|(_, suffix)| extensions.contains(&suffix)))
656}
657
658/// Directories whose contents are never relevant to the dependency graph.
659const EXCLUDED_DIRS: &[&str] = &["node_modules", ".git", "__pycache__", ".chainsaw", "target"];
660
661/// Check whether a filesystem event path is relevant to the dependency graph.
662///
663/// Returns true for source files with matching extensions and lockfiles.
664/// Returns false for files inside excluded directories or with unrelated extensions.
665fn is_relevant_path<S: AsRef<str>>(path: &Path, valid_extensions: &[S]) -> bool {
666    // Reject paths inside excluded directories.
667    for component in path.components() {
668        if let std::path::Component::Normal(s) = component
669            && let Some(s) = s.to_str()
670            && EXCLUDED_DIRS.contains(&s)
671        {
672            return false;
673        }
674    }
675
676    // Accept lockfiles by filename.
677    if let Some(name) = path.file_name().and_then(|n| n.to_str())
678        && LOCKFILES.contains(&name)
679    {
680        return true;
681    }
682
683    // Accept source files by extension.
684    path.extension()
685        .and_then(|e| e.to_str())
686        .is_some_and(|ext| valid_extensions.iter().any(|e| e.as_ref() == ext))
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    fn test_project() -> (tempfile::TempDir, PathBuf) {
694        let tmp = tempfile::tempdir().unwrap();
695        let root = tmp.path().canonicalize().unwrap();
696        std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
697        let entry = root.join("index.ts");
698        std::fs::write(&entry, r#"import { x } from "./a";"#).unwrap();
699        std::fs::write(root.join("a.ts"), "export const x = 1;").unwrap();
700        (tmp, entry)
701    }
702
703    #[test]
704    fn open_and_trace() {
705        let (_tmp, entry) = test_project();
706        let session = Session::open(&entry, true).unwrap();
707        assert_eq!(session.graph().module_count(), 2);
708        let opts = TraceOptions::default();
709        let result = session.trace(&opts);
710        assert!(result.static_weight > 0);
711    }
712
713    #[test]
714    fn chain_finds_dependency() {
715        let (_tmp, entry) = test_project();
716        let session = Session::open(&entry, true).unwrap();
717        let (resolved, chains) = session.chain("a.ts", false);
718        assert!(resolved.exists);
719        assert!(!chains.is_empty());
720    }
721
722    #[test]
723    fn cut_finds_no_intermediate_on_direct_import() {
724        let (_tmp, entry) = test_project();
725        let mut session = Session::open(&entry, true).unwrap();
726        // index.ts -> a.ts is a 1-hop chain, no intermediate to cut
727        let (resolved, chains, cuts) = session.cut("a.ts", 10, false);
728        assert!(resolved.exists);
729        assert!(!chains.is_empty());
730        assert!(cuts.is_empty());
731    }
732
733    #[test]
734    fn diff_two_entries() {
735        let tmp = tempfile::tempdir().unwrap();
736        let root = tmp.path().canonicalize().unwrap();
737        std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
738        // a.ts imports b.ts (so both are in the graph when built from a.ts)
739        // b.ts imports extra.ts (so tracing from b.ts has more weight)
740        let a = root.join("a.ts");
741        std::fs::write(&a, r#"import { foo } from "./b";"#).unwrap();
742        let b = root.join("b.ts");
743        std::fs::write(&b, r#"import { bar } from "./extra";"#).unwrap();
744        std::fs::write(root.join("extra.ts"), "export const y = 2;").unwrap();
745
746        let mut session = Session::open(&a, true).unwrap();
747        let (diff, _) = session.diff_entry(&b, &TraceOptions::default()).unwrap();
748        // b.ts trace (b + extra) should have less weight than a.ts trace (a + b + extra)
749        assert!(diff.entry_a_weight >= diff.entry_b_weight);
750    }
751
752    #[test]
753    fn packages_returns_package_map() {
754        let (_tmp, entry) = test_project();
755        let session = Session::open(&entry, true).unwrap();
756        // Test project has no third-party packages
757        assert!(session.packages().is_empty());
758    }
759
760    #[test]
761    fn resolve_target_file_path() {
762        let (_tmp, entry) = test_project();
763        let session = Session::open(&entry, true).unwrap();
764        let resolved = session.resolve_target("a.ts");
765        assert!(resolved.exists);
766        assert!(matches!(resolved.target, ChainTarget::Module(_)));
767    }
768
769    #[test]
770    fn resolve_target_missing_package() {
771        let (_tmp, entry) = test_project();
772        let session = Session::open(&entry, true).unwrap();
773        let resolved = session.resolve_target("nonexistent-pkg");
774        assert!(!resolved.exists);
775        assert!(matches!(resolved.target, ChainTarget::Package(_)));
776    }
777
778    #[test]
779    fn scoped_npm_package_is_not_path() {
780        let exts = &["ts", "tsx", "js", "jsx"];
781        assert!(!looks_like_path("@slack/web-api", exts));
782        assert!(!looks_like_path("@aws-sdk/client-s3", exts));
783        assert!(!looks_like_path("@anthropic-ai/sdk", exts));
784    }
785
786    #[test]
787    fn relative_file_path_is_path() {
788        let exts = &["ts", "tsx", "js", "jsx"];
789        assert!(looks_like_path("src/index.ts", exts));
790        assert!(looks_like_path("lib/utils.js", exts));
791    }
792
793    #[test]
794    fn bare_package_name_is_not_path() {
795        let exts = &["ts", "tsx", "js", "jsx"];
796        assert!(!looks_like_path("zod", exts));
797        assert!(!looks_like_path("express", exts));
798        // highlight.js is ambiguous — .js extension triggers path heuristic.
799        // resolve_target tries as file path first, falls back to package lookup.
800        assert!(looks_like_path("highlight.js", exts));
801    }
802
803    #[test]
804    fn file_with_extension_is_path() {
805        let exts = &["ts", "tsx", "js", "jsx", "py"];
806        assert!(looks_like_path("utils.ts", exts));
807        assert!(looks_like_path("main.py", exts));
808        assert!(!looks_like_path("utils.txt", exts));
809    }
810
811    #[test]
812    fn resolve_target_falls_back_to_package_for_extension_name() {
813        let (_tmp, entry) = test_project();
814        let session = Session::open(&entry, true).unwrap();
815        // "six.py" looks like a file (.py extension) but no such file exists,
816        // so it falls back to package name lookup.
817        let resolved = session.resolve_target("six.py");
818        assert!(!resolved.exists);
819        assert!(matches!(resolved.target, ChainTarget::Package(ref name) if name == "six.py"));
820    }
821
822    #[test]
823    fn imports_lists_direct_dependencies() {
824        let (_tmp, entry) = test_project();
825        let session = Session::open(&entry, true).unwrap();
826        let imports = session.imports(session.entry()).unwrap();
827        assert_eq!(imports.len(), 1);
828        assert!(imports[0].0.ends_with("a.ts"));
829        assert!(matches!(imports[0].1, EdgeKind::Static));
830    }
831
832    #[test]
833    fn importers_lists_reverse_dependencies() {
834        let (_tmp, entry) = test_project();
835        let session = Session::open(&entry, true).unwrap();
836        let a_path = session.root().join("a.ts");
837        let importers = session.importers(&a_path).unwrap();
838        assert_eq!(importers.len(), 1);
839        assert!(importers[0].0.ends_with("index.ts"));
840    }
841
842    #[test]
843    fn set_entry_switches_entry_point() {
844        let tmp = tempfile::tempdir().unwrap();
845        let root = tmp.path().canonicalize().unwrap();
846        std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
847        let a = root.join("a.ts");
848        std::fs::write(&a, r#"import { x } from "./b";"#).unwrap();
849        let b = root.join("b.ts");
850        std::fs::write(&b, "export const x = 1;").unwrap();
851
852        let mut session = Session::open(&a, true).unwrap();
853        assert!(session.entry().ends_with("a.ts"));
854        session.set_entry(&b).unwrap();
855        assert!(session.entry().ends_with("b.ts"));
856        // Tracing from b: only b itself (no imports).
857        let result = session.trace(&crate::query::TraceOptions::default());
858        assert_eq!(result.static_module_count, 1);
859    }
860
861    #[test]
862    fn refresh_detects_file_change() {
863        let tmp = tempfile::tempdir().unwrap();
864        let root = tmp.path().canonicalize().unwrap();
865        std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
866        let entry = root.join("index.ts");
867        std::fs::write(&entry, r#"import { x } from "./a";"#).unwrap();
868        std::fs::write(root.join("a.ts"), "export const x = 1;").unwrap();
869
870        let mut session = Session::open(&entry, true).unwrap();
871        assert_eq!(session.graph().module_count(), 2);
872
873        // Modify entry to add a new import; sleep for mtime granularity.
874        std::thread::sleep(std::time::Duration::from_millis(50));
875        std::fs::write(
876            &entry,
877            r#"import { x } from "./a"; import { y } from "./b";"#,
878        )
879        .unwrap();
880        std::fs::write(root.join("b.ts"), "export const y = 2;").unwrap();
881
882        let changed = session.refresh().unwrap();
883        assert!(changed);
884        assert_eq!(session.graph().module_count(), 3);
885    }
886
887    #[test]
888    fn event_filter_accepts_ts_source() {
889        let exts = &["ts", "tsx", "js", "jsx"];
890        assert!(is_relevant_path(Path::new("/project/src/index.ts"), exts));
891        assert!(is_relevant_path(Path::new("/project/lib/utils.jsx"), exts));
892    }
893
894    #[test]
895    fn event_filter_accepts_py_source() {
896        let exts = &["py"];
897        assert!(is_relevant_path(Path::new("/project/app/main.py"), exts));
898    }
899
900    #[test]
901    fn event_filter_rejects_wrong_extension() {
902        let exts = &["ts", "tsx", "js", "jsx"];
903        assert!(!is_relevant_path(Path::new("/project/README.md"), exts));
904        assert!(!is_relevant_path(Path::new("/project/image.png"), exts));
905        assert!(!is_relevant_path(Path::new("/project/Makefile"), exts));
906    }
907
908    #[test]
909    fn event_filter_rejects_excluded_dirs() {
910        let exts = &["ts", "tsx", "js", "jsx"];
911        assert!(!is_relevant_path(
912            Path::new("/project/node_modules/zod/index.ts"),
913            exts
914        ));
915        assert!(!is_relevant_path(
916            Path::new("/project/.git/objects/abc"),
917            exts
918        ));
919        assert!(!is_relevant_path(
920            Path::new("/project/__pycache__/mod.py"),
921            exts
922        ));
923        assert!(!is_relevant_path(
924            Path::new("/project/.chainsaw/cache"),
925            exts
926        ));
927        assert!(!is_relevant_path(
928            Path::new("/project/target/debug/build.rs"),
929            exts
930        ));
931    }
932
933    #[test]
934    fn event_filter_accepts_lockfiles() {
935        let exts = &["ts", "tsx", "js", "jsx"];
936        assert!(is_relevant_path(
937            Path::new("/project/package-lock.json"),
938            exts
939        ));
940        assert!(is_relevant_path(Path::new("/project/pnpm-lock.yaml"), exts));
941        assert!(is_relevant_path(Path::new("/project/yarn.lock"), exts));
942        assert!(is_relevant_path(Path::new("/project/bun.lockb"), exts));
943        assert!(is_relevant_path(Path::new("/project/poetry.lock"), exts));
944        assert!(is_relevant_path(Path::new("/project/Pipfile.lock"), exts));
945        assert!(is_relevant_path(Path::new("/project/uv.lock"), exts));
946        assert!(is_relevant_path(
947            Path::new("/project/requirements.txt"),
948            exts
949        ));
950    }
951
952    #[test]
953    fn event_filter_rejects_no_extension_non_lockfile() {
954        let exts = &["ts", "tsx", "js", "jsx"];
955        assert!(!is_relevant_path(Path::new("/project/Dockerfile"), exts));
956    }
957
958    #[test]
959    fn entry_label_includes_project_dir() {
960        let (_tmp, entry) = test_project();
961        let session = Session::open(&entry, true).unwrap();
962        let label = session.entry_label();
963        // The temp dir has a name, so label should be "dirname/index.ts"
964        assert!(label.ends_with("index.ts"));
965        assert!(label.contains('/'));
966    }
967
968    #[test]
969    fn trace_report_has_display_ready_fields() {
970        let (_tmp, entry) = test_project();
971        let mut session = Session::open(&entry, true).unwrap();
972        let opts = TraceOptions::default();
973        let report = session.trace_report(&opts, report::DEFAULT_TOP_MODULES);
974        assert!(report.entry.contains("index.ts"));
975        assert!(report.static_weight_bytes > 0);
976        assert_eq!(report.static_module_count, 2);
977        // No ModuleIds -- paths are strings
978        assert!(
979            report
980                .modules_by_cost
981                .iter()
982                .all(|m| m.path.contains(".ts"))
983        );
984    }
985
986    #[test]
987    fn chain_report_resolves_to_strings() {
988        let (_tmp, entry) = test_project();
989        let session = Session::open(&entry, true).unwrap();
990        let report = session.chain_report("a.ts", false);
991        assert!(report.found_in_graph);
992        assert_eq!(report.chain_count, 1);
993        assert!(report.chains[0].iter().any(|s| s.contains("a.ts")));
994    }
995
996    #[test]
997    fn cut_report_direct_import() {
998        let (_tmp, entry) = test_project();
999        let mut session = Session::open(&entry, true).unwrap();
1000        let report = session.cut_report("a.ts", 10, false);
1001        assert!(report.found_in_graph);
1002        assert_eq!(report.chain_count, 1);
1003        assert!(report.direct_import);
1004        assert!(report.cut_points.is_empty());
1005    }
1006
1007    #[test]
1008    fn cut_report_nonexistent_target() {
1009        let (_tmp, entry) = test_project();
1010        let mut session = Session::open(&entry, true).unwrap();
1011        let report = session.cut_report("nonexistent-pkg", 10, false);
1012        assert!(!report.found_in_graph);
1013        assert_eq!(report.chain_count, 0);
1014        assert!(!report.direct_import);
1015    }
1016
1017    #[test]
1018    fn packages_report_empty_for_first_party() {
1019        let (_tmp, entry) = test_project();
1020        let session = Session::open(&entry, true).unwrap();
1021        let report = session.packages_report(report::DEFAULT_TOP);
1022        assert_eq!(report.package_count, 0);
1023        assert!(report.packages.is_empty());
1024    }
1025
1026    #[test]
1027    fn watch_then_refresh_returns_false_when_clean() {
1028        let (_tmp, entry) = test_project();
1029        let mut session = Session::open(&entry, true).unwrap();
1030        session.watch();
1031        // No files changed — refresh should be instant and return false.
1032        let changed = session.refresh().unwrap();
1033        assert!(!changed);
1034    }
1035
1036    #[test]
1037    fn refresh_without_watch_still_works() {
1038        // Backward compat: no watch() call, refresh runs the full path.
1039        let (_tmp, entry) = test_project();
1040        let mut session = Session::open(&entry, false).unwrap();
1041        // Wait for cache write to complete, then refresh hits the cache.
1042        std::thread::sleep(std::time::Duration::from_millis(50));
1043        let changed = session.refresh().unwrap();
1044        assert!(!changed); // cache hit, nothing changed
1045    }
1046
1047    #[test]
1048    fn watch_detects_file_modification() {
1049        let tmp = tempfile::tempdir().unwrap();
1050        let root = tmp.path().canonicalize().unwrap();
1051        std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
1052        let entry = root.join("index.ts");
1053        std::fs::write(&entry, r#"import { x } from "./a";"#).unwrap();
1054        std::fs::write(root.join("a.ts"), "export const x = 1;").unwrap();
1055
1056        let mut session = Session::open(&entry, true).unwrap();
1057        session.watch();
1058
1059        // Modify a source file.
1060        std::thread::sleep(std::time::Duration::from_millis(100));
1061        std::fs::write(root.join("a.ts"), "export const x = 2;").unwrap();
1062
1063        // Give the watcher time to deliver the event.
1064        std::thread::sleep(std::time::Duration::from_millis(200));
1065
1066        assert!(session.is_dirty());
1067        let _changed = session.refresh().unwrap();
1068        // After refresh, the flag is cleared regardless of changed return value.
1069        assert!(!session.is_dirty());
1070    }
1071
1072    #[test]
1073    fn cached_trace_invalidated_on_set_entry() {
1074        let tmp = tempfile::tempdir().unwrap();
1075        let root = tmp.path().canonicalize().unwrap();
1076        std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
1077        let a = root.join("a.ts");
1078        std::fs::write(&a, r#"import { x } from "./b";"#).unwrap();
1079        let b = root.join("b.ts");
1080        std::fs::write(&b, "export const x = 1;").unwrap();
1081
1082        let mut session = Session::open(&a, true).unwrap();
1083        let opts = crate::query::TraceOptions::default();
1084
1085        let r1 = session.trace_report(&opts, 10);
1086        assert_eq!(r1.static_module_count, 2);
1087
1088        session.set_entry(&b).unwrap();
1089
1090        let r2 = session.trace_report(&opts, 10);
1091        assert_eq!(r2.static_module_count, 1);
1092    }
1093
1094    #[test]
1095    fn cached_trace_invalidated_on_refresh() {
1096        let tmp = tempfile::tempdir().unwrap();
1097        let root = tmp.path().canonicalize().unwrap();
1098        std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
1099        let entry = root.join("index.ts");
1100        std::fs::write(&entry, r#"import { x } from "./a";"#).unwrap();
1101        std::fs::write(root.join("a.ts"), "export const x = 1;").unwrap();
1102
1103        let mut session = Session::open(&entry, true).unwrap();
1104        let opts = crate::query::TraceOptions::default();
1105
1106        let r1 = session.trace_report(&opts, 10);
1107        assert_eq!(r1.static_module_count, 2);
1108
1109        std::thread::sleep(std::time::Duration::from_millis(50));
1110        std::fs::write(
1111            &entry,
1112            r#"import { x } from "./a"; import { y } from "./b";"#,
1113        )
1114        .unwrap();
1115        std::fs::write(root.join("b.ts"), "export const y = 2;").unwrap();
1116
1117        let changed = session.refresh().unwrap();
1118        assert!(changed);
1119
1120        let r2 = session.trace_report(&opts, 10);
1121        assert_eq!(r2.static_module_count, 3);
1122    }
1123
1124    #[test]
1125    fn cut_uses_cached_exclusive_weights() {
1126        let tmp = tempfile::tempdir().unwrap();
1127        let root = tmp.path().canonicalize().unwrap();
1128        std::fs::write(root.join("package.json"), r#"{"name":"test"}"#).unwrap();
1129        let entry = root.join("entry.ts");
1130        std::fs::write(
1131            &entry,
1132            r#"import { a } from "./a"; import { b } from "./b";"#,
1133        )
1134        .unwrap();
1135        std::fs::write(
1136            root.join("a.ts"),
1137            r#"import { c } from "./c"; export const a = 1;"#,
1138        )
1139        .unwrap();
1140        std::fs::write(
1141            root.join("b.ts"),
1142            r#"import { c } from "./c"; export const b = 1;"#,
1143        )
1144        .unwrap();
1145        std::fs::write(
1146            root.join("c.ts"),
1147            r#"import { z } from "./node_modules/zod/index.js"; export const c = 1;"#,
1148        )
1149        .unwrap();
1150        std::fs::create_dir_all(root.join("node_modules/zod")).unwrap();
1151        std::fs::write(
1152            root.join("node_modules/zod/index.js"),
1153            "export const z = 1;",
1154        )
1155        .unwrap();
1156        std::fs::write(
1157            root.join("node_modules/zod/package.json"),
1158            r#"{"name":"zod"}"#,
1159        )
1160        .unwrap();
1161
1162        let mut session = Session::open(&entry, true).unwrap();
1163
1164        let opts = crate::query::TraceOptions::default();
1165        session.trace_report(&opts, 10);
1166
1167        let (_, chains, cuts) = session.cut("zod", 10, false);
1168        assert!(!chains.is_empty());
1169        assert!(
1170            cuts.iter()
1171                .any(|c| session.graph().module(c.module_id).path.ends_with("c.ts"))
1172        );
1173    }
1174
1175    /// Verify query cache produces measurable speedup.
1176    /// Run: `cargo test --lib session::tests::verify_cache_speedup -- --ignored --nocapture`
1177    #[test]
1178    #[ignore = "requires local wrangler checkout"]
1179    fn verify_cache_speedup() {
1180        use std::time::Instant;
1181
1182        let wrangler =
1183            Path::new("/Users/hlal/dev/cloudflare/workers-sdk/packages/wrangler/src/index.ts");
1184        if !wrangler.exists() {
1185            eprintln!("SKIP: wrangler not found");
1186            return;
1187        }
1188        let mut session = Session::open(wrangler, true).unwrap();
1189        let opts = crate::query::TraceOptions::default();
1190
1191        let t1 = Instant::now();
1192        let r1 = session.trace_report(&opts, 10);
1193        let first = t1.elapsed();
1194
1195        let t2 = Instant::now();
1196        let r2 = session.trace_report(&opts, 10);
1197        let second = t2.elapsed();
1198
1199        assert_eq!(r1.static_weight_bytes, r2.static_weight_bytes);
1200        assert_eq!(r1.static_module_count, r2.static_module_count);
1201
1202        eprintln!(
1203            "  first trace_report:  {:.0}us",
1204            first.as_secs_f64() * 1_000_000.0
1205        );
1206        eprintln!(
1207            "  second trace_report: {:.0}us",
1208            second.as_secs_f64() * 1_000_000.0
1209        );
1210        eprintln!(
1211            "  speedup: {:.1}x",
1212            first.as_secs_f64() / second.as_secs_f64()
1213        );
1214
1215        assert!(
1216            second < first / 3,
1217            "expected cache hit to be at least 3x faster: first={first:?}, second={second:?}"
1218        );
1219    }
1220}