1use std::io::IsTerminal;
8use std::path::{Path, PathBuf};
9
10use rustyline::Helper;
11use rustyline::completion::{Completer, Pair};
12use rustyline::highlight::Highlighter;
13use rustyline::hint::Hinter;
14use rustyline::validate::Validator;
15
16use crate::error::Error;
17use crate::graph::EdgeKind;
18use crate::query::{self, ChainTarget};
19use crate::report::{self, StderrColor};
20use crate::session::Session;
21
22#[derive(Debug)]
24#[non_exhaustive]
25pub struct ReplSettings {
26 pub include_dynamic: bool,
27 pub top: i32,
28 pub top_modules: i32,
29 pub ignore: Vec<String>,
30}
31
32impl Default for ReplSettings {
33 fn default() -> Self {
34 Self {
35 include_dynamic: false,
36 top: report::DEFAULT_TOP,
37 top_modules: report::DEFAULT_TOP_MODULES,
38 ignore: Vec::new(),
39 }
40 }
41}
42
43#[non_exhaustive]
45#[derive(Default, Debug)]
46pub struct CommandOptions {
47 pub include_dynamic: Option<bool>,
48 pub top: Option<i32>,
49 pub top_modules: Option<i32>,
50 pub ignore: Option<Vec<String>>,
51 pub json: bool,
52}
53
54impl CommandOptions {
55 pub fn resolve(&self, settings: &ReplSettings) -> (query::TraceOptions, i32) {
58 let opts = query::TraceOptions {
59 include_dynamic: self.include_dynamic.unwrap_or(settings.include_dynamic),
60 top_n: self.top.unwrap_or(settings.top),
61 ignore: self
62 .ignore
63 .clone()
64 .unwrap_or_else(|| settings.ignore.clone()),
65 };
66 let top_modules = self.top_modules.unwrap_or(settings.top_modules);
67 (opts, top_modules)
68 }
69}
70
71fn parse_flags(tokens: &[&str]) -> Result<(CommandOptions, String), String> {
79 let mut opts = CommandOptions::default();
80 let mut positional = Vec::new();
81 let mut i = 0;
82 while i < tokens.len() {
83 match tokens[i] {
84 "--json" => opts.json = true,
85 "--include-dynamic" => opts.include_dynamic = Some(true),
86 "--no-include-dynamic" => opts.include_dynamic = Some(false),
87 "--top" => {
88 if let Some(val) = tokens.get(i + 1).and_then(|v| v.parse().ok()) {
89 i += 1;
90 if val >= -1 {
91 opts.top = Some(val);
92 }
93 }
94 }
95 "--top-modules" => {
96 if let Some(val) = tokens.get(i + 1).and_then(|v| v.parse().ok()) {
97 i += 1;
98 if val >= -1 {
99 opts.top_modules = Some(val);
100 }
101 }
102 }
103 "--ignore" => {
104 let mut pkgs = Vec::new();
105 i += 1;
106 while i < tokens.len() && !tokens[i].starts_with("--") {
107 pkgs.push(tokens[i].to_string());
108 i += 1;
109 }
110 if !pkgs.is_empty() {
111 opts.ignore = Some(pkgs);
112 }
113 continue; }
115 other if other.starts_with("--") => {
116 return Err(format!(
117 "unknown flag '{other}' (try: --json, --include-dynamic, --top, --top-modules, --ignore)"
118 ));
119 }
120 other => positional.push(other),
121 }
122 i += 1;
123 }
124 Ok((opts, positional.join(" ")))
125}
126
127#[derive(Debug)]
129pub enum Command {
130 Trace(Option<String>, CommandOptions),
132 Entry(String),
134 Chain(String, CommandOptions),
136 Cut(String, CommandOptions),
138 Diff(String, CommandOptions),
140 Packages(CommandOptions),
142 Imports(String, CommandOptions),
144 Importers(String, CommandOptions),
146 Info(String),
148 Set(String),
150 Unset(String),
152 Show,
154 Help,
156 Quit,
158 Unknown(String),
160}
161
162impl Command {
163 #[allow(clippy::too_many_lines)]
165 pub fn parse(line: &str) -> Self {
166 fn require_positional(positional: &str, msg: &str) -> Result<String, String> {
168 if positional.is_empty() {
169 Err(msg.to_string())
170 } else {
171 Ok(positional.to_string())
172 }
173 }
174
175 fn require_arg(arg: Option<&str>, msg: &str) -> Result<String, String> {
177 match arg {
178 Some(a) if !a.is_empty() => Ok(a.to_string()),
179 _ => Err(msg.to_string()),
180 }
181 }
182
183 let line = line.trim();
184 if line.is_empty() {
185 return Self::Help;
186 }
187 let (cmd, arg) = line
188 .split_once(' ')
189 .map_or((line, None), |(c, a)| (c, Some(a.trim())));
190
191 let tokens: Vec<&str> = arg
193 .map(|a| a.split_whitespace().collect())
194 .unwrap_or_default();
195
196 match cmd {
197 "trace" => match parse_flags(&tokens) {
198 Ok((opts, positional)) => {
199 let file = if positional.is_empty() {
200 None
201 } else {
202 Some(positional)
203 };
204 Self::Trace(file, opts)
205 }
206 Err(e) => Self::Unknown(e),
207 },
208 "entry" => match require_arg(arg, "entry requires a file argument") {
209 Ok(a) => Self::Entry(a),
210 Err(e) => Self::Unknown(e),
211 },
212 "chain" => match parse_flags(&tokens) {
213 Ok((opts, positional)) => {
214 match require_positional(&positional, "chain requires a target argument") {
215 Ok(a) => Self::Chain(a, opts),
216 Err(e) => Self::Unknown(e),
217 }
218 }
219 Err(e) => Self::Unknown(e),
220 },
221 "cut" => match parse_flags(&tokens) {
222 Ok((opts, positional)) => {
223 match require_positional(&positional, "cut requires a target argument") {
224 Ok(a) => Self::Cut(a, opts),
225 Err(e) => Self::Unknown(e),
226 }
227 }
228 Err(e) => Self::Unknown(e),
229 },
230 "diff" => match parse_flags(&tokens) {
231 Ok((opts, positional)) => {
232 match require_positional(&positional, "diff requires a file argument") {
233 Ok(a) => Self::Diff(a, opts),
234 Err(e) => Self::Unknown(e),
235 }
236 }
237 Err(e) => Self::Unknown(e),
238 },
239 "packages" => match parse_flags(&tokens) {
240 Ok((opts, _)) => Self::Packages(opts),
241 Err(e) => Self::Unknown(e),
242 },
243 "imports" => match parse_flags(&tokens) {
244 Ok((opts, positional)) => {
245 match require_positional(&positional, "imports requires a file argument") {
246 Ok(a) => Self::Imports(a, opts),
247 Err(e) => Self::Unknown(e),
248 }
249 }
250 Err(e) => Self::Unknown(e),
251 },
252 "importers" => match parse_flags(&tokens) {
253 Ok((opts, positional)) => {
254 match require_positional(&positional, "importers requires a file argument") {
255 Ok(a) => Self::Importers(a, opts),
256 Err(e) => Self::Unknown(e),
257 }
258 }
259 Err(e) => Self::Unknown(e),
260 },
261 "info" => match require_arg(arg, "info requires a package name") {
262 Ok(a) => Self::Info(a),
263 Err(e) => Self::Unknown(e),
264 },
265 "set" => match require_arg(arg, "set requires an option name") {
266 Ok(a) => Self::Set(a),
267 Err(e) => Self::Unknown(e),
268 },
269 "unset" => match require_arg(arg, "unset requires an option name") {
270 Ok(a) => Self::Unset(a),
271 Err(e) => Self::Unknown(e),
272 },
273 "show" => Self::Show,
274 "help" | "?" => Self::Help,
275 "quit" | "exit" => Self::Quit,
276 _ => Self::Unknown(format!("unknown command: {cmd}")),
277 }
278 }
279}
280
281pub const COMMAND_NAMES: &[&str] = &[
283 "chain",
284 "cut",
285 "diff",
286 "entry",
287 "exit",
288 "help",
289 "importers",
290 "imports",
291 "info",
292 "packages",
293 "quit",
294 "set",
295 "show",
296 "trace",
297 "unset",
298];
299
300const MAX_COMPLETIONS: usize = 20;
305
306fn sorted_prefix_matches<'a>(sorted: &'a [String], prefix: &str, limit: usize) -> Vec<&'a str> {
309 if limit == 0 {
310 return Vec::new();
311 }
312 let start = sorted.partition_point(|s| s.as_str() < prefix);
313 sorted[start..]
314 .iter()
315 .take_while(|s| s.starts_with(prefix))
316 .take(limit)
317 .map(String::as_str)
318 .collect()
319}
320
321const OPTION_NAMES: &[&str] = &["dynamic", "ignore", "include-dynamic", "top", "top-modules"];
323
324struct ChainsawHelper {
325 file_paths: Vec<String>,
326 package_names: Vec<String>,
327}
328
329impl ChainsawHelper {
330 fn new() -> Self {
331 Self {
332 file_paths: Vec::new(),
333 package_names: Vec::new(),
334 }
335 }
336
337 fn update_from_session(&mut self, session: &Session) {
338 self.file_paths = session
339 .graph()
340 .modules
341 .iter()
342 .map(|m| report::relative_path(&m.path, session.root()))
343 .collect();
344 self.file_paths.sort_unstable();
345 self.package_names = session.graph().package_map.keys().cloned().collect();
346 self.package_names.sort_unstable();
347 }
348}
349
350impl Completer for ChainsawHelper {
351 type Candidate = Pair;
352
353 fn complete(
354 &self,
355 line: &str,
356 pos: usize,
357 _ctx: &rustyline::Context<'_>,
358 ) -> rustyline::Result<(usize, Vec<Pair>)> {
359 let line = &line[..pos];
360
361 if !line.contains(' ') {
363 let matches: Vec<Pair> = COMMAND_NAMES
364 .iter()
365 .filter(|c| c.starts_with(line))
366 .map(|&c| Pair {
367 display: c.to_string(),
368 replacement: c.to_string(),
369 })
370 .collect();
371 return Ok((0, matches));
372 }
373
374 let (cmd, partial) = line.split_once(' ').unwrap_or((line, ""));
376 let partial = partial.trim_start();
377 let start = pos - partial.len();
378
379 let matches: Vec<Pair> = match cmd {
380 "chain" | "cut" => {
381 let pkgs = sorted_prefix_matches(&self.package_names, partial, MAX_COMPLETIONS);
382 let remaining = MAX_COMPLETIONS - pkgs.len();
383 let files = sorted_prefix_matches(&self.file_paths, partial, remaining);
384 pkgs.into_iter()
385 .chain(files)
386 .map(|c| Pair {
387 display: c.to_string(),
388 replacement: c.to_string(),
389 })
390 .collect()
391 }
392 "info" => sorted_prefix_matches(&self.package_names, partial, MAX_COMPLETIONS)
393 .into_iter()
394 .map(|c| Pair {
395 display: c.to_string(),
396 replacement: c.to_string(),
397 })
398 .collect(),
399 "trace" | "entry" | "imports" | "importers" | "diff" => {
400 sorted_prefix_matches(&self.file_paths, partial, MAX_COMPLETIONS)
401 .into_iter()
402 .map(|c| Pair {
403 display: c.to_string(),
404 replacement: c.to_string(),
405 })
406 .collect()
407 }
408 "set" | "unset" => OPTION_NAMES
409 .iter()
410 .filter(|c| c.starts_with(partial))
411 .take(MAX_COMPLETIONS)
412 .map(|&c| Pair {
413 display: c.to_string(),
414 replacement: c.to_string(),
415 })
416 .collect(),
417 _ => return Ok((start, vec![])),
418 };
419
420 Ok((start, matches))
421 }
422}
423
424impl Hinter for ChainsawHelper {
425 type Hint = String;
426}
427impl Highlighter for ChainsawHelper {}
428impl Validator for ChainsawHelper {}
429impl Helper for ChainsawHelper {}
430
431pub fn run(entry: &Path, no_color: bool, sc: StderrColor) -> Result<(), Error> {
437 let start = std::time::Instant::now();
438 let mut session = Session::open(entry, false)?;
439
440 report::print_load_status(
441 session.from_cache(),
442 session.graph().module_count(),
443 start.elapsed().as_secs_f64() * 1000.0,
444 session.file_warnings(),
445 session.unresolvable_dynamic_count(),
446 session.unresolvable_dynamic_files(),
447 session.root(),
448 sc,
449 );
450 eprintln!("Type 'help' for commands, 'quit' to exit.\n");
451
452 session.watch();
453
454 let color = report::should_use_color(
455 std::io::stdout().is_terminal(),
456 no_color,
457 std::env::var_os("NO_COLOR").is_some(),
458 std::env::var("TERM").is_ok_and(|v| v == "dumb"),
459 );
460
461 let mut helper = ChainsawHelper::new();
462 helper.update_from_session(&session);
463
464 let mut rl =
465 rustyline::Editor::new().map_err(|e| Error::Readline(format!("init failed: {e}")))?;
466 rl.set_helper(Some(helper));
467
468 let history_path = history_file();
469 if let Some(ref path) = history_path {
470 let _ = rl.load_history(path);
471 }
472
473 let mut settings = ReplSettings::default();
474 let prompt = format!("{}> ", sc.status("chainsaw"));
475
476 loop {
477 match session.refresh() {
479 Ok(true) => {
480 eprintln!(
481 "{} graph refreshed ({} modules)",
482 sc.status("Reloaded:"),
483 session.graph().module_count()
484 );
485 if let Some(h) = rl.helper_mut() {
486 h.update_from_session(&session);
487 }
488 }
489 Ok(false) => {}
490 Err(e) => eprintln!("{} refresh failed: {e}", sc.warning("warning:")),
491 }
492
493 let line = match rl.readline(&prompt) {
494 Ok(line) => line,
495 Err(rustyline::error::ReadlineError::Interrupted) => continue,
496 Err(rustyline::error::ReadlineError::Eof) => break,
497 Err(e) => {
498 eprintln!("{} {e}", sc.error("error:"));
499 break;
500 }
501 };
502
503 let trimmed = line.trim();
504 if trimmed.is_empty() {
505 continue;
506 }
507 rl.add_history_entry(trimmed).ok();
508
509 match Command::parse(trimmed) {
510 Command::Trace(file, ref opts) => {
511 dispatch_trace(&mut session, file.as_deref(), opts, &settings, color, sc);
512 }
513 Command::Entry(path) => dispatch_entry(&mut session, &path, sc),
514 Command::Chain(target, ref opts) => {
515 dispatch_chain(&session, &target, opts, &settings, color, sc);
516 }
517 Command::Cut(target, ref opts) => {
518 dispatch_cut(&mut session, &target, opts, &settings, color, sc);
519 }
520 Command::Diff(path, ref opts) => {
521 dispatch_diff(&mut session, &path, opts, &settings, color, sc);
522 }
523 Command::Packages(ref opts) => {
524 dispatch_packages(&session, opts, &settings, color);
525 }
526 Command::Imports(path, ref opts) => dispatch_imports(&session, &path, opts, sc),
527 Command::Importers(path, ref opts) => {
528 dispatch_importers(&session, &path, opts, sc);
529 }
530 Command::Info(name) => dispatch_info(&session, &name, sc),
531 Command::Set(arg) => dispatch_set(&mut settings, &arg, sc),
532 Command::Unset(arg) => dispatch_unset(&mut settings, &arg, sc),
533 Command::Show => dispatch_show(&settings),
534 Command::Help => print_help(),
535 Command::Quit => break,
536 Command::Unknown(msg) => eprintln!("{} {msg}", sc.error("error:")),
537 }
538 }
539
540 if let Some(ref path) = history_path {
541 let _ = rl.save_history(path);
542 }
543 Ok(())
544}
545
546fn history_file() -> Option<PathBuf> {
547 let dir = std::env::var_os("XDG_DATA_HOME")
548 .map(PathBuf::from)
549 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
550 let dir = dir.join("chainsaw");
551 std::fs::create_dir_all(&dir).ok()?;
552 Some(dir.join("history"))
553}
554
555fn dispatch_trace(
560 session: &mut Session,
561 file: Option<&str>,
562 opts: &CommandOptions,
563 settings: &ReplSettings,
564 color: bool,
565 sc: StderrColor,
566) {
567 let (trace_opts, top_modules) = opts.resolve(settings);
568 let report = if let Some(f) = file {
569 match session.trace_from_report(Path::new(f), &trace_opts, top_modules) {
570 Ok((r, _)) => r,
571 Err(e) => {
572 eprintln!("{} {e}", sc.error("error:"));
573 return;
574 }
575 }
576 } else {
577 session.trace_report(&trace_opts, top_modules)
578 };
579 if opts.json {
580 println!("{}", report.to_json());
581 } else {
582 print!("{}", report.to_terminal(color));
583 }
584}
585
586fn dispatch_entry(session: &mut Session, path: &str, sc: StderrColor) {
587 if let Err(e) = session.set_entry(Path::new(path)) {
588 eprintln!("{} {e}", sc.error("error:"));
589 return;
590 }
591 let rel = report::relative_path(session.entry(), session.root());
592 eprintln!("{} entry point is now {rel}", sc.status("Switched:"));
593}
594
595fn dispatch_chain(
596 session: &Session,
597 target: &str,
598 opts: &CommandOptions,
599 settings: &ReplSettings,
600 color: bool,
601 sc: StderrColor,
602) {
603 let resolved = session.resolve_target(target);
604 if resolved.target == ChainTarget::Module(session.entry_id()) {
605 eprintln!("{} target is the entry point itself", sc.error("error:"));
606 return;
607 }
608 let (trace_opts, _) = opts.resolve(settings);
609 let report = session.chain_report(target, trace_opts.include_dynamic);
610 if opts.json {
611 println!("{}", report.to_json());
612 } else {
613 print!("{}", report.to_terminal(color));
614 }
615}
616
617fn dispatch_cut(
618 session: &mut Session,
619 target: &str,
620 opts: &CommandOptions,
621 settings: &ReplSettings,
622 color: bool,
623 sc: StderrColor,
624) {
625 let resolved = session.resolve_target(target);
626 if resolved.target == ChainTarget::Module(session.entry_id()) {
627 eprintln!("{} target is the entry point itself", sc.error("error:"));
628 return;
629 }
630 let (trace_opts, _) = opts.resolve(settings);
631 let report = session.cut_report(target, trace_opts.top_n, trace_opts.include_dynamic);
632 if opts.json {
633 println!("{}", report.to_json());
634 } else {
635 print!("{}", report.to_terminal(color));
636 }
637}
638
639fn dispatch_diff(
640 session: &mut Session,
641 path: &str,
642 opts: &CommandOptions,
643 settings: &ReplSettings,
644 color: bool,
645 sc: StderrColor,
646) {
647 let (trace_opts, _) = opts.resolve(settings);
648 match session.diff_report(Path::new(path), &trace_opts, trace_opts.top_n) {
649 Ok(report) => {
650 if opts.json {
651 println!("{}", report.to_json());
652 } else {
653 print!("{}", report.to_terminal(color));
654 }
655 }
656 Err(e) => eprintln!("{} {e}", sc.error("error:")),
657 }
658}
659
660fn dispatch_packages(
661 session: &Session,
662 opts: &CommandOptions,
663 settings: &ReplSettings,
664 color: bool,
665) {
666 let top = opts.top.unwrap_or(settings.top);
667 let report = session.packages_report(top);
668 if opts.json {
669 println!("{}", report.to_json());
670 } else {
671 print!("{}", report.to_terminal(color));
672 }
673}
674
675fn dispatch_imports(session: &Session, path: &str, opts: &CommandOptions, sc: StderrColor) {
676 match session.imports(Path::new(path)) {
677 Ok(imports) => {
678 if opts.json {
679 let entries: Vec<_> = imports
680 .iter()
681 .map(|(p, kind)| {
682 serde_json::json!({
683 "path": report::relative_path(p, session.root()),
684 "kind": match kind {
685 EdgeKind::Static => "static",
686 EdgeKind::Dynamic => "dynamic",
687 EdgeKind::TypeOnly => "type-only",
688 }
689 })
690 })
691 .collect();
692 println!(
693 "{}",
694 serde_json::to_string_pretty(&entries).expect("entries serialize to JSON")
695 );
696 return;
697 }
698 if imports.is_empty() {
699 println!(" (no imports)");
700 return;
701 }
702 for (p, kind) in &imports {
703 let rel = report::relative_path(p, session.root());
704 let suffix = match kind {
705 EdgeKind::Static => "",
706 EdgeKind::Dynamic => " (dynamic)",
707 EdgeKind::TypeOnly => " (type-only)",
708 };
709 println!(" {rel}{suffix}");
710 }
711 }
712 Err(e) => eprintln!("{} {e}", sc.error("error:")),
713 }
714}
715
716fn dispatch_importers(session: &Session, path: &str, opts: &CommandOptions, sc: StderrColor) {
717 match session.importers(Path::new(path)) {
718 Ok(importers) => {
719 if opts.json {
720 let entries: Vec<_> = importers
721 .iter()
722 .map(|(p, kind)| {
723 serde_json::json!({
724 "path": report::relative_path(p, session.root()),
725 "kind": match kind {
726 EdgeKind::Static => "static",
727 EdgeKind::Dynamic => "dynamic",
728 EdgeKind::TypeOnly => "type-only",
729 }
730 })
731 })
732 .collect();
733 println!(
734 "{}",
735 serde_json::to_string_pretty(&entries).expect("entries serialize to JSON")
736 );
737 return;
738 }
739 if importers.is_empty() {
740 println!(" (no importers)");
741 return;
742 }
743 for (p, kind) in &importers {
744 let rel = report::relative_path(p, session.root());
745 let suffix = match kind {
746 EdgeKind::Static => "",
747 EdgeKind::Dynamic => " (dynamic)",
748 EdgeKind::TypeOnly => " (type-only)",
749 };
750 println!(" {rel}{suffix}");
751 }
752 }
753 Err(e) => eprintln!("{} {e}", sc.error("error:")),
754 }
755}
756
757fn dispatch_info(session: &Session, name: &str, sc: StderrColor) {
758 match session.info(name) {
759 Some(info) => {
760 println!(
761 " {} ({} files, {})",
762 info.name,
763 info.total_reachable_files,
764 report::format_size(info.total_reachable_size)
765 );
766 }
767 None => eprintln!("{} package '{name}' not found", sc.error("error:")),
768 }
769}
770
771fn dispatch_set(settings: &mut ReplSettings, arg: &str, sc: StderrColor) {
772 let mut parts = arg.split_whitespace();
773 let Some(key) = parts.next() else {
774 eprintln!("{} set requires an option name", sc.error("error:"));
775 return;
776 };
777 match key {
778 "dynamic" | "include-dynamic" => {
779 let value = match parts.next() {
780 Some("true") => true,
781 Some("false") => false,
782 None => !settings.include_dynamic, Some(v) => {
784 eprintln!(
785 "{} invalid value '{v}' for dynamic (expected true/false)",
786 sc.error("error:")
787 );
788 return;
789 }
790 };
791 settings.include_dynamic = value;
792 eprintln!("{} dynamic = {value}", sc.status("Set:"));
793 }
794 "top" => {
795 let Some(val) = parts.next().and_then(|v| v.parse::<i32>().ok()) else {
796 eprintln!("{} top requires a number", sc.error("error:"));
797 return;
798 };
799 if val < -1 {
800 eprintln!(
801 "{} invalid value {val} for top: must be -1 (all) or 0+",
802 sc.error("error:")
803 );
804 return;
805 }
806 settings.top = val;
807 eprintln!("{} top = {val}", sc.status("Set:"));
808 }
809 "top-modules" => {
810 let Some(val) = parts.next().and_then(|v| v.parse::<i32>().ok()) else {
811 eprintln!("{} top-modules requires a number", sc.error("error:"));
812 return;
813 };
814 if val < -1 {
815 eprintln!(
816 "{} invalid value {val} for top-modules: must be -1 (all) or 0+",
817 sc.error("error:")
818 );
819 return;
820 }
821 settings.top_modules = val;
822 eprintln!("{} top-modules = {val}", sc.status("Set:"));
823 }
824 "ignore" => {
825 let pkgs: Vec<String> = parts.map(String::from).collect();
826 if pkgs.is_empty() {
827 eprintln!(
828 "{} ignore requires one or more package names",
829 sc.error("error:")
830 );
831 return;
832 }
833 eprintln!("{} ignore = [{}]", sc.status("Set:"), pkgs.join(", "));
834 settings.ignore = pkgs;
835 }
836 _ => eprintln!(
837 "{} unknown option '{key}' (try: dynamic, top, top-modules, ignore)",
838 sc.error("error:")
839 ),
840 }
841}
842
843fn dispatch_unset(settings: &mut ReplSettings, key: &str, sc: StderrColor) {
844 let key = key.trim();
845 match key {
846 "dynamic" | "include-dynamic" => {
847 settings.include_dynamic = false;
848 eprintln!("{} dynamic reset to false", sc.status("Unset:"));
849 }
850 "top" => {
851 settings.top = report::DEFAULT_TOP;
852 eprintln!(
853 "{} top reset to {}",
854 sc.status("Unset:"),
855 report::DEFAULT_TOP
856 );
857 }
858 "top-modules" => {
859 settings.top_modules = report::DEFAULT_TOP_MODULES;
860 eprintln!(
861 "{} top-modules reset to {}",
862 sc.status("Unset:"),
863 report::DEFAULT_TOP_MODULES
864 );
865 }
866 "ignore" => {
867 settings.ignore.clear();
868 eprintln!("{} ignore cleared", sc.status("Unset:"));
869 }
870 _ => eprintln!(
871 "{} unknown option '{key}' (try: dynamic, top, top-modules, ignore)",
872 sc.error("error:")
873 ),
874 }
875}
876
877fn dispatch_show(settings: &ReplSettings) {
878 println!("Settings:");
879 println!(" dynamic = {}", settings.include_dynamic);
880 println!(" top = {}", settings.top);
881 println!(" top-modules = {}", settings.top_modules);
882 if settings.ignore.is_empty() {
883 println!(" ignore = (none)");
884 } else {
885 println!(" ignore = [{}]", settings.ignore.join(", "));
886 }
887}
888
889fn print_help() {
890 println!("Commands:");
891 println!(" trace [file] Trace from entry point (or specified file)");
892 println!(" entry <file> Switch the default entry point");
893 println!(" chain <target> Show import chains to a package or file");
894 println!(" cut <target> Show where to cut to sever chains");
895 println!(" diff <file> Compare weight against another entry");
896 println!(" packages List third-party packages");
897 println!(" imports <file> Show what a file imports");
898 println!(" importers <file> Show what imports a file");
899 println!(" info <package> Show package details");
900 println!(" set <opt> [val] Set a session option (omit val to toggle booleans)");
901 println!(" unset <opt> Reset an option to its default");
902 println!(" show Display current settings");
903 println!(" help Show this help");
904 println!(" quit Exit");
905 println!();
906 println!("Inline flags (override session settings for one command):");
907 println!(" --json Output as JSON instead of terminal format");
908 println!(" --include-dynamic / --no-include-dynamic Include/exclude dynamic imports");
909 println!(" --top N Limit heavy deps / packages shown");
910 println!(" --top-modules N Limit modules by exclusive weight");
911 println!(" --ignore pkg1 pkg2 ... Exclude packages from heavy deps");
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917
918 #[test]
923 fn prefix_empty_list() {
924 let empty: Vec<String> = vec![];
925 assert!(sorted_prefix_matches(&empty, "foo", 10).is_empty());
926 }
927
928 #[test]
929 fn prefix_no_matches() {
930 let list = vec!["alpha".into(), "beta".into(), "gamma".into()];
931 assert!(sorted_prefix_matches(&list, "delta", 10).is_empty());
932 }
933
934 #[test]
935 fn prefix_exact_match() {
936 let list = vec!["alpha".into(), "beta".into(), "gamma".into()];
937 assert_eq!(sorted_prefix_matches(&list, "beta", 10), vec!["beta"]);
938 }
939
940 #[test]
941 fn prefix_multiple_matches() {
942 let list = vec![
943 "src/a.ts".into(),
944 "src/b.ts".into(),
945 "src/c.ts".into(),
946 "test/d.ts".into(),
947 ];
948 assert_eq!(
949 sorted_prefix_matches(&list, "src/", 10),
950 vec!["src/a.ts", "src/b.ts", "src/c.ts"]
951 );
952 }
953
954 #[test]
955 fn prefix_respects_limit() {
956 let list = vec![
957 "src/a.ts".into(),
958 "src/b.ts".into(),
959 "src/c.ts".into(),
960 "src/d.ts".into(),
961 ];
962 assert_eq!(
963 sorted_prefix_matches(&list, "src/", 2),
964 vec!["src/a.ts", "src/b.ts"]
965 );
966 }
967
968 #[test]
969 fn prefix_empty_prefix_matches_all_up_to_limit() {
970 let list = vec!["a".into(), "b".into(), "c".into()];
971 assert_eq!(sorted_prefix_matches(&list, "", 2), vec!["a", "b"]);
972 }
973
974 #[test]
975 fn prefix_zero_limit_returns_empty() {
976 let list = vec!["a".into(), "b".into()];
977 assert!(sorted_prefix_matches(&list, "", 0).is_empty());
978 }
979
980 fn helper_with(files: Vec<&str>, packages: Vec<&str>) -> ChainsawHelper {
985 let mut file_paths: Vec<String> = files.into_iter().map(String::from).collect();
986 let mut package_names: Vec<String> = packages.into_iter().map(String::from).collect();
987 file_paths.sort_unstable();
988 package_names.sort_unstable();
989 ChainsawHelper {
990 file_paths,
991 package_names,
992 }
993 }
994
995 fn complete_line(helper: &ChainsawHelper, line: &str) -> Vec<String> {
996 let history = rustyline::history::DefaultHistory::new();
997 let ctx = rustyline::Context::new(&history);
998 let (_, pairs) = helper.complete(line, line.len(), &ctx).unwrap();
999 pairs.into_iter().map(|p| p.replacement).collect()
1000 }
1001
1002 #[test]
1003 fn complete_command_names() {
1004 let h = helper_with(vec![], vec![]);
1005 let results = complete_line(&h, "tr");
1006 assert_eq!(results, vec!["trace"]);
1007 }
1008
1009 #[test]
1010 fn complete_trace_file_paths() {
1011 let h = helper_with(vec!["src/a.ts", "src/b.ts", "lib/c.ts"], vec![]);
1012 let results = complete_line(&h, "trace src/");
1013 assert_eq!(results, vec!["src/a.ts", "src/b.ts"]);
1014 }
1015
1016 #[test]
1017 fn complete_chain_packages_then_files() {
1018 let h = helper_with(vec!["zod-utils.ts"], vec!["zod", "zustand"]);
1019 let results = complete_line(&h, "chain z");
1020 assert_eq!(results, vec!["zod", "zustand", "zod-utils.ts"]);
1022 }
1023
1024 #[test]
1025 fn complete_info_packages_only() {
1026 let h = helper_with(vec!["src/react.ts"], vec!["react", "react-dom"]);
1027 let results = complete_line(&h, "info react");
1028 assert_eq!(results, vec!["react", "react-dom"]);
1029 }
1030
1031 #[test]
1032 fn complete_no_matches() {
1033 let h = helper_with(vec!["src/a.ts"], vec!["zod"]);
1034 let results = complete_line(&h, "trace zzz");
1035 assert!(results.is_empty());
1036 }
1037
1038 #[test]
1039 fn complete_unknown_command_returns_empty() {
1040 let h = helper_with(vec!["src/a.ts"], vec!["zod"]);
1041 let results = complete_line(&h, "bogus src/");
1042 assert!(results.is_empty());
1043 }
1044
1045 #[test]
1046 fn complete_max_completions_truncates() {
1047 let files: Vec<&str> = (0..30)
1048 .map(|i| {
1049 Box::leak(format!("src/{i:02}.ts").into_boxed_str()) as &str
1051 })
1052 .collect();
1053 let h = helper_with(files, vec![]);
1054 let results = complete_line(&h, "trace src/");
1055 assert_eq!(results.len(), MAX_COMPLETIONS);
1056 }
1057
1058 #[test]
1063 fn parse_trace_no_arg() {
1064 assert!(matches!(Command::parse("trace"), Command::Trace(None, _)));
1065 }
1066
1067 #[test]
1068 fn parse_trace_with_file() {
1069 assert!(
1070 matches!(Command::parse("trace src/index.ts"), Command::Trace(Some(ref f), _) if f == "src/index.ts")
1071 );
1072 }
1073
1074 #[test]
1075 fn parse_chain() {
1076 assert!(matches!(Command::parse("chain zod"), Command::Chain(ref t, _) if t == "zod"));
1077 }
1078
1079 #[test]
1080 fn parse_entry() {
1081 assert!(
1082 matches!(Command::parse("entry src/other.ts"), Command::Entry(ref f) if f == "src/other.ts")
1083 );
1084 }
1085
1086 #[test]
1087 fn parse_packages() {
1088 assert!(matches!(Command::parse("packages"), Command::Packages(_)));
1089 }
1090
1091 #[test]
1092 fn parse_imports() {
1093 assert!(
1094 matches!(Command::parse("imports src/foo.ts"), Command::Imports(ref f, _) if f == "src/foo.ts")
1095 );
1096 }
1097
1098 #[test]
1099 fn parse_importers() {
1100 assert!(
1101 matches!(Command::parse("importers lib/bar.py"), Command::Importers(ref f, _) if f == "lib/bar.py")
1102 );
1103 }
1104
1105 #[test]
1106 fn parse_info() {
1107 assert!(matches!(Command::parse("info zod"), Command::Info(ref p) if p == "zod"));
1108 }
1109
1110 #[test]
1111 fn parse_empty_is_help() {
1112 assert!(matches!(Command::parse(""), Command::Help));
1113 }
1114
1115 #[test]
1116 fn parse_question_mark_is_help() {
1117 assert!(matches!(Command::parse("?"), Command::Help));
1118 }
1119
1120 #[test]
1121 fn parse_quit() {
1122 assert!(matches!(Command::parse("quit"), Command::Quit));
1123 assert!(matches!(Command::parse("exit"), Command::Quit));
1124 }
1125
1126 #[test]
1127 fn parse_unknown() {
1128 assert!(matches!(Command::parse("blah"), Command::Unknown(_)));
1129 }
1130
1131 #[test]
1132 fn parse_missing_arg() {
1133 assert!(matches!(Command::parse("chain"), Command::Unknown(_)));
1134 assert!(matches!(Command::parse("entry"), Command::Unknown(_)));
1135 assert!(matches!(Command::parse("cut"), Command::Unknown(_)));
1136 assert!(matches!(Command::parse("diff"), Command::Unknown(_)));
1137 assert!(matches!(Command::parse("imports"), Command::Unknown(_)));
1138 assert!(matches!(Command::parse("importers"), Command::Unknown(_)));
1139 assert!(matches!(Command::parse("info"), Command::Unknown(_)));
1140 }
1141
1142 #[test]
1143 fn parse_preserves_arg_with_spaces() {
1144 assert!(
1145 matches!(Command::parse("chain @scope/pkg"), Command::Chain(ref t, _) if t == "@scope/pkg")
1146 );
1147 }
1148
1149 #[test]
1150 fn parse_trims_whitespace() {
1151 assert!(matches!(Command::parse(" quit "), Command::Quit));
1152 }
1153
1154 #[test]
1155 fn settings_defaults() {
1156 let s = ReplSettings::default();
1157 assert!(!s.include_dynamic);
1158 assert_eq!(s.top, report::DEFAULT_TOP);
1159 assert_eq!(s.top_modules, report::DEFAULT_TOP_MODULES);
1160 assert!(s.ignore.is_empty());
1161 }
1162
1163 #[test]
1164 fn command_options_resolve_uses_settings_when_none() {
1165 let settings = ReplSettings::default();
1166 let opts = CommandOptions::default();
1167 let (trace_opts, top_modules) = opts.resolve(&settings);
1168 assert!(!trace_opts.include_dynamic);
1169 assert_eq!(trace_opts.top_n, report::DEFAULT_TOP);
1170 assert!(trace_opts.ignore.is_empty());
1171 assert_eq!(top_modules, report::DEFAULT_TOP_MODULES);
1172 }
1173
1174 #[test]
1175 fn command_options_resolve_overrides_settings() {
1176 let settings = ReplSettings::default();
1177 let opts = CommandOptions {
1178 include_dynamic: Some(true),
1179 top: Some(5),
1180 top_modules: Some(50),
1181 ignore: Some(vec!["zod".into()]),
1182 json: false,
1183 };
1184 let (trace_opts, top_modules) = opts.resolve(&settings);
1185 assert!(trace_opts.include_dynamic);
1186 assert_eq!(trace_opts.top_n, 5);
1187 assert_eq!(trace_opts.ignore, vec!["zod".to_string()]);
1188 assert_eq!(top_modules, 50);
1189 }
1190
1191 #[test]
1192 fn parse_flags_no_flags() {
1193 let (opts, remaining) = parse_flags(&["src/index.ts"]).unwrap();
1194 assert!(opts.include_dynamic.is_none());
1195 assert!(opts.top.is_none());
1196 assert_eq!(remaining, "src/index.ts");
1197 }
1198
1199 #[test]
1200 fn parse_flags_dynamic() {
1201 let (opts, remaining) = parse_flags(&["--include-dynamic", "src/index.ts"]).unwrap();
1202 assert_eq!(opts.include_dynamic, Some(true));
1203 assert_eq!(remaining, "src/index.ts");
1204 }
1205
1206 #[test]
1207 fn parse_flags_no_dynamic() {
1208 let (opts, remaining) = parse_flags(&["--no-include-dynamic", "src/index.ts"]).unwrap();
1209 assert_eq!(opts.include_dynamic, Some(false));
1210 assert_eq!(remaining, "src/index.ts");
1211 }
1212
1213 #[test]
1214 fn parse_flags_top() {
1215 let (opts, remaining) = parse_flags(&["--top", "5", "src/index.ts"]).unwrap();
1216 assert_eq!(opts.top, Some(5));
1217 assert_eq!(remaining, "src/index.ts");
1218 }
1219
1220 #[test]
1221 fn parse_flags_top_modules() {
1222 let (opts, remaining) = parse_flags(&["--top-modules", "30", "src/index.ts"]).unwrap();
1223 assert_eq!(opts.top_modules, Some(30));
1224 assert_eq!(remaining, "src/index.ts");
1225 }
1226
1227 #[test]
1228 fn parse_flags_ignore() {
1229 let (opts, remaining) =
1232 parse_flags(&["src/index.ts", "--ignore", "zod", "lodash"]).unwrap();
1233 assert_eq!(opts.ignore, Some(vec!["zod".into(), "lodash".into()]));
1234 assert_eq!(remaining, "src/index.ts");
1235 }
1236
1237 #[test]
1238 fn parse_flags_ignore_stops_at_next_flag() {
1239 let (opts, remaining) =
1240 parse_flags(&["src/index.ts", "--ignore", "zod", "--include-dynamic"]).unwrap();
1241 assert_eq!(opts.ignore, Some(vec!["zod".to_string()]));
1242 assert_eq!(opts.include_dynamic, Some(true));
1243 assert_eq!(remaining, "src/index.ts");
1244 }
1245
1246 #[test]
1247 fn parse_flags_multiple() {
1248 let (opts, remaining) = parse_flags(&["--include-dynamic", "--top", "5", "zod"]).unwrap();
1249 assert_eq!(opts.include_dynamic, Some(true));
1250 assert_eq!(opts.top, Some(5));
1251 assert_eq!(remaining, "zod");
1252 }
1253
1254 #[test]
1255 fn parse_flags_empty() {
1256 let (opts, remaining) = parse_flags(&[]).unwrap();
1257 assert!(opts.include_dynamic.is_none());
1258 assert!(remaining.is_empty());
1259 }
1260
1261 #[test]
1262 fn parse_flags_only_flags_no_positional() {
1263 let (opts, remaining) = parse_flags(&["--include-dynamic"]).unwrap();
1264 assert_eq!(opts.include_dynamic, Some(true));
1265 assert!(remaining.is_empty());
1266 }
1267
1268 #[test]
1269 fn parse_flags_scoped_package_not_treated_as_flag() {
1270 let (opts, remaining) = parse_flags(&["@scope/pkg"]).unwrap();
1271 assert!(opts.include_dynamic.is_none());
1272 assert_eq!(remaining, "@scope/pkg");
1273 }
1274
1275 #[test]
1276 fn parse_flags_top_non_numeric_preserves_positional() {
1277 let (opts, remaining) = parse_flags(&["--top", "src/index.ts"]).unwrap();
1280 assert!(opts.top.is_none());
1281 assert_eq!(remaining, "src/index.ts");
1282 }
1283
1284 #[test]
1285 fn parse_flags_top_modules_non_numeric_preserves_positional() {
1286 let (opts, remaining) = parse_flags(&["--top-modules", "src/index.ts"]).unwrap();
1287 assert!(opts.top_modules.is_none());
1288 assert_eq!(remaining, "src/index.ts");
1289 }
1290
1291 #[test]
1292 fn parse_flags_top_rejects_negative_below_minus_one() {
1293 let (opts, _) = parse_flags(&["--top", "-5", "src/index.ts"]).unwrap();
1294 assert!(opts.top.is_none());
1295 }
1296
1297 #[test]
1298 fn parse_flags_top_accepts_negative_one() {
1299 let (opts, _) = parse_flags(&["--top", "-1", "src/index.ts"]).unwrap();
1300 assert_eq!(opts.top, Some(-1));
1301 }
1302
1303 #[test]
1304 fn parse_flags_top_modules_rejects_negative_below_minus_one() {
1305 let (opts, _) = parse_flags(&["--top-modules", "-5", "src/index.ts"]).unwrap();
1306 assert!(opts.top_modules.is_none());
1307 }
1308
1309 #[test]
1310 fn parse_flags_top_modules_accepts_negative_one() {
1311 let (opts, _) = parse_flags(&["--top-modules", "-1", "src/index.ts"]).unwrap();
1312 assert_eq!(opts.top_modules, Some(-1));
1313 }
1314
1315 #[test]
1316 fn parse_flags_json() {
1317 let (opts, remaining) = parse_flags(&["--json", "src/index.ts"]).unwrap();
1318 assert!(opts.json);
1319 assert_eq!(remaining, "src/index.ts");
1320 }
1321
1322 #[test]
1323 fn parse_flags_unknown_flag_returns_error() {
1324 let err = parse_flags(&["--bogus", "src/index.ts"]).unwrap_err();
1325 assert!(err.contains("unknown flag '--bogus'"));
1326 }
1327
1328 #[test]
1329 fn parse_trace_with_flags() {
1330 let cmd = Command::parse("trace --include-dynamic --top 5 src/index.ts");
1331 match cmd {
1332 Command::Trace(Some(ref f), ref opts) => {
1333 assert_eq!(f, "src/index.ts");
1334 assert_eq!(opts.include_dynamic, Some(true));
1335 assert_eq!(opts.top, Some(5));
1336 }
1337 other => panic!("expected Trace, got {other:?}"),
1338 }
1339 }
1340
1341 #[test]
1342 fn parse_trace_flags_no_file() {
1343 let cmd = Command::parse("trace --include-dynamic");
1344 match cmd {
1345 Command::Trace(None, ref opts) => {
1346 assert_eq!(opts.include_dynamic, Some(true));
1347 }
1348 other => panic!("expected Trace(None, _), got {other:?}"),
1349 }
1350 }
1351
1352 #[test]
1353 fn parse_chain_with_dynamic() {
1354 let cmd = Command::parse("chain --include-dynamic zod");
1355 match cmd {
1356 Command::Chain(ref target, ref opts) => {
1357 assert_eq!(target, "zod");
1358 assert_eq!(opts.include_dynamic, Some(true));
1359 }
1360 other => panic!("expected Chain, got {other:?}"),
1361 }
1362 }
1363
1364 #[test]
1365 fn parse_cut_with_top() {
1366 let cmd = Command::parse("cut --top 3 zod");
1367 match cmd {
1368 Command::Cut(ref target, ref opts) => {
1369 assert_eq!(target, "zod");
1370 assert_eq!(opts.top, Some(3));
1371 }
1372 other => panic!("expected Cut, got {other:?}"),
1373 }
1374 }
1375
1376 #[test]
1377 fn parse_packages_with_top() {
1378 let cmd = Command::parse("packages --top 20");
1379 match cmd {
1380 Command::Packages(ref opts) => {
1381 assert_eq!(opts.top, Some(20));
1382 }
1383 other => panic!("expected Packages, got {other:?}"),
1384 }
1385 }
1386
1387 #[test]
1388 fn parse_set() {
1389 assert!(matches!(
1390 Command::parse("set dynamic"),
1391 Command::Set(ref s) if s == "dynamic"
1392 ));
1393 assert!(matches!(
1394 Command::parse("set top 5"),
1395 Command::Set(ref s) if s == "top 5"
1396 ));
1397 }
1398
1399 #[test]
1400 fn parse_unset() {
1401 assert!(matches!(
1402 Command::parse("unset ignore"),
1403 Command::Unset(ref s) if s == "ignore"
1404 ));
1405 }
1406
1407 #[test]
1408 fn parse_show() {
1409 assert!(matches!(Command::parse("show"), Command::Show));
1410 }
1411
1412 #[test]
1413 fn parse_set_missing_arg() {
1414 assert!(matches!(Command::parse("set"), Command::Unknown(_)));
1415 }
1416
1417 #[test]
1418 fn parse_unset_missing_arg() {
1419 assert!(matches!(Command::parse("unset"), Command::Unknown(_)));
1420 }
1421
1422 #[test]
1423 fn set_dynamic_toggle() {
1424 let mut s = ReplSettings::default();
1425 dispatch_set(&mut s, "dynamic", StderrColor::new(true));
1426 assert!(s.include_dynamic);
1427 dispatch_set(&mut s, "dynamic", StderrColor::new(true));
1428 assert!(!s.include_dynamic);
1429 }
1430
1431 #[test]
1432 fn set_dynamic_explicit() {
1433 let mut s = ReplSettings::default();
1434 dispatch_set(&mut s, "dynamic true", StderrColor::new(true));
1435 assert!(s.include_dynamic);
1436 dispatch_set(&mut s, "dynamic false", StderrColor::new(true));
1437 assert!(!s.include_dynamic);
1438 }
1439
1440 #[test]
1441 fn set_include_dynamic_alias() {
1442 let mut s = ReplSettings::default();
1443 dispatch_set(&mut s, "include-dynamic true", StderrColor::new(true));
1444 assert!(s.include_dynamic);
1445 }
1446
1447 #[test]
1448 fn unset_include_dynamic_alias() {
1449 let mut s = ReplSettings {
1450 include_dynamic: true,
1451 ..ReplSettings::default()
1452 };
1453 dispatch_unset(&mut s, "include-dynamic", StderrColor::new(true));
1454 assert!(!s.include_dynamic);
1455 }
1456
1457 #[test]
1458 fn set_top() {
1459 let mut s = ReplSettings::default();
1460 dispatch_set(&mut s, "top 5", StderrColor::new(true));
1461 assert_eq!(s.top, 5);
1462 }
1463
1464 #[test]
1465 fn set_top_modules() {
1466 let mut s = ReplSettings::default();
1467 dispatch_set(&mut s, "top-modules 30", StderrColor::new(true));
1468 assert_eq!(s.top_modules, 30);
1469 }
1470
1471 #[test]
1472 fn set_top_rejects_invalid_value() {
1473 let mut s = ReplSettings::default();
1474 dispatch_set(&mut s, "top -5", StderrColor::new(true));
1475 assert_eq!(s.top, report::DEFAULT_TOP);
1477 }
1478
1479 #[test]
1480 fn set_top_modules_rejects_invalid_value() {
1481 let mut s = ReplSettings::default();
1482 dispatch_set(&mut s, "top-modules -2", StderrColor::new(true));
1483 assert_eq!(s.top_modules, report::DEFAULT_TOP_MODULES);
1484 }
1485
1486 #[test]
1487 fn set_top_accepts_negative_one() {
1488 let mut s = ReplSettings::default();
1489 dispatch_set(&mut s, "top -1", StderrColor::new(true));
1490 assert_eq!(s.top, -1);
1491 }
1492
1493 #[test]
1494 fn set_top_accepts_zero() {
1495 let mut s = ReplSettings::default();
1496 dispatch_set(&mut s, "top 0", StderrColor::new(true));
1497 assert_eq!(s.top, 0);
1498 }
1499
1500 #[test]
1501 fn set_ignore() {
1502 let mut s = ReplSettings::default();
1503 dispatch_set(&mut s, "ignore zod lodash", StderrColor::new(true));
1504 assert_eq!(s.ignore, vec!["zod".to_string(), "lodash".to_string()]);
1505 }
1506
1507 #[test]
1508 fn unset_dynamic() {
1509 let mut s = ReplSettings {
1510 include_dynamic: true,
1511 ..ReplSettings::default()
1512 };
1513 dispatch_unset(&mut s, "dynamic", StderrColor::new(true));
1514 assert!(!s.include_dynamic);
1515 }
1516
1517 #[test]
1518 fn unset_ignore() {
1519 let mut s = ReplSettings {
1520 ignore: vec!["zod".into()],
1521 ..ReplSettings::default()
1522 };
1523 dispatch_unset(&mut s, "ignore", StderrColor::new(true));
1524 assert!(s.ignore.is_empty());
1525 }
1526
1527 #[test]
1528 fn unset_top() {
1529 let mut s = ReplSettings {
1530 top: 99,
1531 ..ReplSettings::default()
1532 };
1533 dispatch_unset(&mut s, "top", StderrColor::new(true));
1534 assert_eq!(s.top, report::DEFAULT_TOP);
1535 }
1536
1537 #[test]
1538 fn unset_top_modules() {
1539 let mut s = ReplSettings {
1540 top_modules: 99,
1541 ..ReplSettings::default()
1542 };
1543 dispatch_unset(&mut s, "top-modules", StderrColor::new(true));
1544 assert_eq!(s.top_modules, report::DEFAULT_TOP_MODULES);
1545 }
1546
1547 #[test]
1548 fn parse_diff_with_dynamic() {
1549 let cmd = Command::parse("diff --include-dynamic src/other.ts");
1550 match cmd {
1551 Command::Diff(ref path, ref opts) => {
1552 assert_eq!(path, "src/other.ts");
1553 assert_eq!(opts.include_dynamic, Some(true));
1554 }
1555 other => panic!("expected Diff, got {other:?}"),
1556 }
1557 }
1558}