1use 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
24pub struct ResolvedTarget {
29 pub target: ChainTarget,
30 pub label: String,
31 pub exists: bool,
32}
33
34pub 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
56struct 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 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 pub fn trace(&self, opts: &TraceOptions) -> TraceResult {
119 query::trace(&self.graph, self.entry_id, opts)
120 }
121
122 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 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 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 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 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 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 pub fn packages(&self) -> &HashMap<String, PackageInfo> {
246 &self.graph.package_map
247 }
248
249 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 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 pub fn info(&self, package_name: &str) -> Option<&PackageInfo> {
291 self.graph.package_map.get(package_name)
292 }
293
294 pub fn entry_label(&self) -> String {
297 self.entry_label_for(&self.entry)
298 }
299
300 pub fn entry_label_for(&self, path: &Path) -> String {
302 entry_label(path, &self.root)
303 }
304
305 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 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 pub fn is_dirty(&self) -> bool {
361 self.dirty.load(Ordering::Acquire)
362 }
363
364 #[allow(clippy::used_underscore_binding)] pub fn refresh(&mut self) -> Result<bool, Error> {
370 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 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 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 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 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 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 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 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 #[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 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
589pub 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
647pub 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
658const EXCLUDED_DIRS: &[&str] = &["node_modules", ".git", "__pycache__", ".chainsaw", "target"];
660
661fn is_relevant_path<S: AsRef<str>>(path: &Path, valid_extensions: &[S]) -> bool {
666 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 if let Some(name) = path.file_name().and_then(|n| n.to_str())
678 && LOCKFILES.contains(&name)
679 {
680 return true;
681 }
682
683 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 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 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 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 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 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 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 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 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 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 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 let changed = session.refresh().unwrap();
1033 assert!(!changed);
1034 }
1035
1036 #[test]
1037 fn refresh_without_watch_still_works() {
1038 let (_tmp, entry) = test_project();
1040 let mut session = Session::open(&entry, false).unwrap();
1041 std::thread::sleep(std::time::Duration::from_millis(50));
1043 let changed = session.refresh().unwrap();
1044 assert!(!changed); }
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 std::thread::sleep(std::time::Duration::from_millis(100));
1061 std::fs::write(root.join("a.ts"), "export const x = 2;").unwrap();
1062
1063 std::thread::sleep(std::time::Duration::from_millis(200));
1065
1066 assert!(session.is_dirty());
1067 let _changed = session.refresh().unwrap();
1068 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 #[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}