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!("{}", serde_json::to_string_pretty(&entries).unwrap());
693 return;
694 }
695 if imports.is_empty() {
696 println!(" (no imports)");
697 return;
698 }
699 for (p, kind) in &imports {
700 let rel = report::relative_path(p, session.root());
701 let suffix = match kind {
702 EdgeKind::Static => "",
703 EdgeKind::Dynamic => " (dynamic)",
704 EdgeKind::TypeOnly => " (type-only)",
705 };
706 println!(" {rel}{suffix}");
707 }
708 }
709 Err(e) => eprintln!("{} {e}", sc.error("error:")),
710 }
711}
712
713fn dispatch_importers(session: &Session, path: &str, opts: &CommandOptions, sc: StderrColor) {
714 match session.importers(Path::new(path)) {
715 Ok(importers) => {
716 if opts.json {
717 let entries: Vec<_> = importers
718 .iter()
719 .map(|(p, kind)| {
720 serde_json::json!({
721 "path": report::relative_path(p, session.root()),
722 "kind": match kind {
723 EdgeKind::Static => "static",
724 EdgeKind::Dynamic => "dynamic",
725 EdgeKind::TypeOnly => "type-only",
726 }
727 })
728 })
729 .collect();
730 println!("{}", serde_json::to_string_pretty(&entries).unwrap());
731 return;
732 }
733 if importers.is_empty() {
734 println!(" (no importers)");
735 return;
736 }
737 for (p, kind) in &importers {
738 let rel = report::relative_path(p, session.root());
739 let suffix = match kind {
740 EdgeKind::Static => "",
741 EdgeKind::Dynamic => " (dynamic)",
742 EdgeKind::TypeOnly => " (type-only)",
743 };
744 println!(" {rel}{suffix}");
745 }
746 }
747 Err(e) => eprintln!("{} {e}", sc.error("error:")),
748 }
749}
750
751fn dispatch_info(session: &Session, name: &str, sc: StderrColor) {
752 match session.info(name) {
753 Some(info) => {
754 println!(
755 " {} ({} files, {})",
756 info.name,
757 info.total_reachable_files,
758 report::format_size(info.total_reachable_size)
759 );
760 }
761 None => eprintln!("{} package '{name}' not found", sc.error("error:")),
762 }
763}
764
765fn dispatch_set(settings: &mut ReplSettings, arg: &str, sc: StderrColor) {
766 let mut parts = arg.split_whitespace();
767 let Some(key) = parts.next() else {
768 eprintln!("{} set requires an option name", sc.error("error:"));
769 return;
770 };
771 match key {
772 "dynamic" | "include-dynamic" => {
773 let value = match parts.next() {
774 Some("true") => true,
775 Some("false") => false,
776 None => !settings.include_dynamic, Some(v) => {
778 eprintln!(
779 "{} invalid value '{v}' for dynamic (expected true/false)",
780 sc.error("error:")
781 );
782 return;
783 }
784 };
785 settings.include_dynamic = value;
786 eprintln!("{} dynamic = {value}", sc.status("Set:"));
787 }
788 "top" => {
789 let Some(val) = parts.next().and_then(|v| v.parse::<i32>().ok()) else {
790 eprintln!("{} top requires a number", sc.error("error:"));
791 return;
792 };
793 if val < -1 {
794 eprintln!(
795 "{} invalid value {val} for top: must be -1 (all) or 0+",
796 sc.error("error:")
797 );
798 return;
799 }
800 settings.top = val;
801 eprintln!("{} top = {val}", sc.status("Set:"));
802 }
803 "top-modules" => {
804 let Some(val) = parts.next().and_then(|v| v.parse::<i32>().ok()) else {
805 eprintln!("{} top-modules requires a number", sc.error("error:"));
806 return;
807 };
808 if val < -1 {
809 eprintln!(
810 "{} invalid value {val} for top-modules: must be -1 (all) or 0+",
811 sc.error("error:")
812 );
813 return;
814 }
815 settings.top_modules = val;
816 eprintln!("{} top-modules = {val}", sc.status("Set:"));
817 }
818 "ignore" => {
819 let pkgs: Vec<String> = parts.map(String::from).collect();
820 if pkgs.is_empty() {
821 eprintln!(
822 "{} ignore requires one or more package names",
823 sc.error("error:")
824 );
825 return;
826 }
827 eprintln!("{} ignore = [{}]", sc.status("Set:"), pkgs.join(", "));
828 settings.ignore = pkgs;
829 }
830 _ => eprintln!(
831 "{} unknown option '{key}' (try: dynamic, top, top-modules, ignore)",
832 sc.error("error:")
833 ),
834 }
835}
836
837fn dispatch_unset(settings: &mut ReplSettings, key: &str, sc: StderrColor) {
838 let key = key.trim();
839 match key {
840 "dynamic" | "include-dynamic" => {
841 settings.include_dynamic = false;
842 eprintln!("{} dynamic reset to false", sc.status("Unset:"));
843 }
844 "top" => {
845 settings.top = report::DEFAULT_TOP;
846 eprintln!(
847 "{} top reset to {}",
848 sc.status("Unset:"),
849 report::DEFAULT_TOP
850 );
851 }
852 "top-modules" => {
853 settings.top_modules = report::DEFAULT_TOP_MODULES;
854 eprintln!(
855 "{} top-modules reset to {}",
856 sc.status("Unset:"),
857 report::DEFAULT_TOP_MODULES
858 );
859 }
860 "ignore" => {
861 settings.ignore.clear();
862 eprintln!("{} ignore cleared", sc.status("Unset:"));
863 }
864 _ => eprintln!(
865 "{} unknown option '{key}' (try: dynamic, top, top-modules, ignore)",
866 sc.error("error:")
867 ),
868 }
869}
870
871fn dispatch_show(settings: &ReplSettings) {
872 println!("Settings:");
873 println!(" dynamic = {}", settings.include_dynamic);
874 println!(" top = {}", settings.top);
875 println!(" top-modules = {}", settings.top_modules);
876 if settings.ignore.is_empty() {
877 println!(" ignore = (none)");
878 } else {
879 println!(" ignore = [{}]", settings.ignore.join(", "));
880 }
881}
882
883fn print_help() {
884 println!("Commands:");
885 println!(" trace [file] Trace from entry point (or specified file)");
886 println!(" entry <file> Switch the default entry point");
887 println!(" chain <target> Show import chains to a package or file");
888 println!(" cut <target> Show where to cut to sever chains");
889 println!(" diff <file> Compare weight against another entry");
890 println!(" packages List third-party packages");
891 println!(" imports <file> Show what a file imports");
892 println!(" importers <file> Show what imports a file");
893 println!(" info <package> Show package details");
894 println!(" set <opt> [val] Set a session option (omit val to toggle booleans)");
895 println!(" unset <opt> Reset an option to its default");
896 println!(" show Display current settings");
897 println!(" help Show this help");
898 println!(" quit Exit");
899 println!();
900 println!("Inline flags (override session settings for one command):");
901 println!(" --json Output as JSON instead of terminal format");
902 println!(" --include-dynamic / --no-include-dynamic Include/exclude dynamic imports");
903 println!(" --top N Limit heavy deps / packages shown");
904 println!(" --top-modules N Limit modules by exclusive weight");
905 println!(" --ignore pkg1 pkg2 ... Exclude packages from heavy deps");
906}
907
908#[cfg(test)]
909mod tests {
910 use super::*;
911
912 #[test]
917 fn prefix_empty_list() {
918 let empty: Vec<String> = vec![];
919 assert!(sorted_prefix_matches(&empty, "foo", 10).is_empty());
920 }
921
922 #[test]
923 fn prefix_no_matches() {
924 let list = vec!["alpha".into(), "beta".into(), "gamma".into()];
925 assert!(sorted_prefix_matches(&list, "delta", 10).is_empty());
926 }
927
928 #[test]
929 fn prefix_exact_match() {
930 let list = vec!["alpha".into(), "beta".into(), "gamma".into()];
931 assert_eq!(sorted_prefix_matches(&list, "beta", 10), vec!["beta"]);
932 }
933
934 #[test]
935 fn prefix_multiple_matches() {
936 let list = vec![
937 "src/a.ts".into(),
938 "src/b.ts".into(),
939 "src/c.ts".into(),
940 "test/d.ts".into(),
941 ];
942 assert_eq!(
943 sorted_prefix_matches(&list, "src/", 10),
944 vec!["src/a.ts", "src/b.ts", "src/c.ts"]
945 );
946 }
947
948 #[test]
949 fn prefix_respects_limit() {
950 let list = vec![
951 "src/a.ts".into(),
952 "src/b.ts".into(),
953 "src/c.ts".into(),
954 "src/d.ts".into(),
955 ];
956 assert_eq!(
957 sorted_prefix_matches(&list, "src/", 2),
958 vec!["src/a.ts", "src/b.ts"]
959 );
960 }
961
962 #[test]
963 fn prefix_empty_prefix_matches_all_up_to_limit() {
964 let list = vec!["a".into(), "b".into(), "c".into()];
965 assert_eq!(sorted_prefix_matches(&list, "", 2), vec!["a", "b"]);
966 }
967
968 #[test]
969 fn prefix_zero_limit_returns_empty() {
970 let list = vec!["a".into(), "b".into()];
971 assert!(sorted_prefix_matches(&list, "", 0).is_empty());
972 }
973
974 fn helper_with(files: Vec<&str>, packages: Vec<&str>) -> ChainsawHelper {
979 let mut file_paths: Vec<String> = files.into_iter().map(String::from).collect();
980 let mut package_names: Vec<String> = packages.into_iter().map(String::from).collect();
981 file_paths.sort_unstable();
982 package_names.sort_unstable();
983 ChainsawHelper {
984 file_paths,
985 package_names,
986 }
987 }
988
989 fn complete_line(helper: &ChainsawHelper, line: &str) -> Vec<String> {
990 let history = rustyline::history::DefaultHistory::new();
991 let ctx = rustyline::Context::new(&history);
992 let (_, pairs) = helper.complete(line, line.len(), &ctx).unwrap();
993 pairs.into_iter().map(|p| p.replacement).collect()
994 }
995
996 #[test]
997 fn complete_command_names() {
998 let h = helper_with(vec![], vec![]);
999 let results = complete_line(&h, "tr");
1000 assert_eq!(results, vec!["trace"]);
1001 }
1002
1003 #[test]
1004 fn complete_trace_file_paths() {
1005 let h = helper_with(vec!["src/a.ts", "src/b.ts", "lib/c.ts"], vec![]);
1006 let results = complete_line(&h, "trace src/");
1007 assert_eq!(results, vec!["src/a.ts", "src/b.ts"]);
1008 }
1009
1010 #[test]
1011 fn complete_chain_packages_then_files() {
1012 let h = helper_with(vec!["zod-utils.ts"], vec!["zod", "zustand"]);
1013 let results = complete_line(&h, "chain z");
1014 assert_eq!(results, vec!["zod", "zustand", "zod-utils.ts"]);
1016 }
1017
1018 #[test]
1019 fn complete_info_packages_only() {
1020 let h = helper_with(vec!["src/react.ts"], vec!["react", "react-dom"]);
1021 let results = complete_line(&h, "info react");
1022 assert_eq!(results, vec!["react", "react-dom"]);
1023 }
1024
1025 #[test]
1026 fn complete_no_matches() {
1027 let h = helper_with(vec!["src/a.ts"], vec!["zod"]);
1028 let results = complete_line(&h, "trace zzz");
1029 assert!(results.is_empty());
1030 }
1031
1032 #[test]
1033 fn complete_unknown_command_returns_empty() {
1034 let h = helper_with(vec!["src/a.ts"], vec!["zod"]);
1035 let results = complete_line(&h, "bogus src/");
1036 assert!(results.is_empty());
1037 }
1038
1039 #[test]
1040 fn complete_max_completions_truncates() {
1041 let files: Vec<&str> = (0..30)
1042 .map(|i| {
1043 Box::leak(format!("src/{i:02}.ts").into_boxed_str()) as &str
1045 })
1046 .collect();
1047 let h = helper_with(files, vec![]);
1048 let results = complete_line(&h, "trace src/");
1049 assert_eq!(results.len(), MAX_COMPLETIONS);
1050 }
1051
1052 #[test]
1057 fn parse_trace_no_arg() {
1058 assert!(matches!(Command::parse("trace"), Command::Trace(None, _)));
1059 }
1060
1061 #[test]
1062 fn parse_trace_with_file() {
1063 assert!(
1064 matches!(Command::parse("trace src/index.ts"), Command::Trace(Some(ref f), _) if f == "src/index.ts")
1065 );
1066 }
1067
1068 #[test]
1069 fn parse_chain() {
1070 assert!(matches!(Command::parse("chain zod"), Command::Chain(ref t, _) if t == "zod"));
1071 }
1072
1073 #[test]
1074 fn parse_entry() {
1075 assert!(
1076 matches!(Command::parse("entry src/other.ts"), Command::Entry(ref f) if f == "src/other.ts")
1077 );
1078 }
1079
1080 #[test]
1081 fn parse_packages() {
1082 assert!(matches!(Command::parse("packages"), Command::Packages(_)));
1083 }
1084
1085 #[test]
1086 fn parse_imports() {
1087 assert!(
1088 matches!(Command::parse("imports src/foo.ts"), Command::Imports(ref f, _) if f == "src/foo.ts")
1089 );
1090 }
1091
1092 #[test]
1093 fn parse_importers() {
1094 assert!(
1095 matches!(Command::parse("importers lib/bar.py"), Command::Importers(ref f, _) if f == "lib/bar.py")
1096 );
1097 }
1098
1099 #[test]
1100 fn parse_info() {
1101 assert!(matches!(Command::parse("info zod"), Command::Info(ref p) if p == "zod"));
1102 }
1103
1104 #[test]
1105 fn parse_empty_is_help() {
1106 assert!(matches!(Command::parse(""), Command::Help));
1107 }
1108
1109 #[test]
1110 fn parse_question_mark_is_help() {
1111 assert!(matches!(Command::parse("?"), Command::Help));
1112 }
1113
1114 #[test]
1115 fn parse_quit() {
1116 assert!(matches!(Command::parse("quit"), Command::Quit));
1117 assert!(matches!(Command::parse("exit"), Command::Quit));
1118 }
1119
1120 #[test]
1121 fn parse_unknown() {
1122 assert!(matches!(Command::parse("blah"), Command::Unknown(_)));
1123 }
1124
1125 #[test]
1126 fn parse_missing_arg() {
1127 assert!(matches!(Command::parse("chain"), Command::Unknown(_)));
1128 assert!(matches!(Command::parse("entry"), Command::Unknown(_)));
1129 assert!(matches!(Command::parse("cut"), Command::Unknown(_)));
1130 assert!(matches!(Command::parse("diff"), Command::Unknown(_)));
1131 assert!(matches!(Command::parse("imports"), Command::Unknown(_)));
1132 assert!(matches!(Command::parse("importers"), Command::Unknown(_)));
1133 assert!(matches!(Command::parse("info"), Command::Unknown(_)));
1134 }
1135
1136 #[test]
1137 fn parse_preserves_arg_with_spaces() {
1138 assert!(
1139 matches!(Command::parse("chain @scope/pkg"), Command::Chain(ref t, _) if t == "@scope/pkg")
1140 );
1141 }
1142
1143 #[test]
1144 fn parse_trims_whitespace() {
1145 assert!(matches!(Command::parse(" quit "), Command::Quit));
1146 }
1147
1148 #[test]
1149 fn settings_defaults() {
1150 let s = ReplSettings::default();
1151 assert!(!s.include_dynamic);
1152 assert_eq!(s.top, report::DEFAULT_TOP);
1153 assert_eq!(s.top_modules, report::DEFAULT_TOP_MODULES);
1154 assert!(s.ignore.is_empty());
1155 }
1156
1157 #[test]
1158 fn command_options_resolve_uses_settings_when_none() {
1159 let settings = ReplSettings::default();
1160 let opts = CommandOptions::default();
1161 let (trace_opts, top_modules) = opts.resolve(&settings);
1162 assert!(!trace_opts.include_dynamic);
1163 assert_eq!(trace_opts.top_n, report::DEFAULT_TOP);
1164 assert!(trace_opts.ignore.is_empty());
1165 assert_eq!(top_modules, report::DEFAULT_TOP_MODULES);
1166 }
1167
1168 #[test]
1169 fn command_options_resolve_overrides_settings() {
1170 let settings = ReplSettings::default();
1171 let opts = CommandOptions {
1172 include_dynamic: Some(true),
1173 top: Some(5),
1174 top_modules: Some(50),
1175 ignore: Some(vec!["zod".into()]),
1176 json: false,
1177 };
1178 let (trace_opts, top_modules) = opts.resolve(&settings);
1179 assert!(trace_opts.include_dynamic);
1180 assert_eq!(trace_opts.top_n, 5);
1181 assert_eq!(trace_opts.ignore, vec!["zod".to_string()]);
1182 assert_eq!(top_modules, 50);
1183 }
1184
1185 #[test]
1186 fn parse_flags_no_flags() {
1187 let (opts, remaining) = parse_flags(&["src/index.ts"]).unwrap();
1188 assert!(opts.include_dynamic.is_none());
1189 assert!(opts.top.is_none());
1190 assert_eq!(remaining, "src/index.ts");
1191 }
1192
1193 #[test]
1194 fn parse_flags_dynamic() {
1195 let (opts, remaining) = parse_flags(&["--include-dynamic", "src/index.ts"]).unwrap();
1196 assert_eq!(opts.include_dynamic, Some(true));
1197 assert_eq!(remaining, "src/index.ts");
1198 }
1199
1200 #[test]
1201 fn parse_flags_no_dynamic() {
1202 let (opts, remaining) = parse_flags(&["--no-include-dynamic", "src/index.ts"]).unwrap();
1203 assert_eq!(opts.include_dynamic, Some(false));
1204 assert_eq!(remaining, "src/index.ts");
1205 }
1206
1207 #[test]
1208 fn parse_flags_top() {
1209 let (opts, remaining) = parse_flags(&["--top", "5", "src/index.ts"]).unwrap();
1210 assert_eq!(opts.top, Some(5));
1211 assert_eq!(remaining, "src/index.ts");
1212 }
1213
1214 #[test]
1215 fn parse_flags_top_modules() {
1216 let (opts, remaining) = parse_flags(&["--top-modules", "30", "src/index.ts"]).unwrap();
1217 assert_eq!(opts.top_modules, Some(30));
1218 assert_eq!(remaining, "src/index.ts");
1219 }
1220
1221 #[test]
1222 fn parse_flags_ignore() {
1223 let (opts, remaining) =
1226 parse_flags(&["src/index.ts", "--ignore", "zod", "lodash"]).unwrap();
1227 assert_eq!(opts.ignore, Some(vec!["zod".into(), "lodash".into()]));
1228 assert_eq!(remaining, "src/index.ts");
1229 }
1230
1231 #[test]
1232 fn parse_flags_ignore_stops_at_next_flag() {
1233 let (opts, remaining) =
1234 parse_flags(&["src/index.ts", "--ignore", "zod", "--include-dynamic"]).unwrap();
1235 assert_eq!(opts.ignore, Some(vec!["zod".to_string()]));
1236 assert_eq!(opts.include_dynamic, Some(true));
1237 assert_eq!(remaining, "src/index.ts");
1238 }
1239
1240 #[test]
1241 fn parse_flags_multiple() {
1242 let (opts, remaining) = parse_flags(&["--include-dynamic", "--top", "5", "zod"]).unwrap();
1243 assert_eq!(opts.include_dynamic, Some(true));
1244 assert_eq!(opts.top, Some(5));
1245 assert_eq!(remaining, "zod");
1246 }
1247
1248 #[test]
1249 fn parse_flags_empty() {
1250 let (opts, remaining) = parse_flags(&[]).unwrap();
1251 assert!(opts.include_dynamic.is_none());
1252 assert!(remaining.is_empty());
1253 }
1254
1255 #[test]
1256 fn parse_flags_only_flags_no_positional() {
1257 let (opts, remaining) = parse_flags(&["--include-dynamic"]).unwrap();
1258 assert_eq!(opts.include_dynamic, Some(true));
1259 assert!(remaining.is_empty());
1260 }
1261
1262 #[test]
1263 fn parse_flags_scoped_package_not_treated_as_flag() {
1264 let (opts, remaining) = parse_flags(&["@scope/pkg"]).unwrap();
1265 assert!(opts.include_dynamic.is_none());
1266 assert_eq!(remaining, "@scope/pkg");
1267 }
1268
1269 #[test]
1270 fn parse_flags_top_non_numeric_preserves_positional() {
1271 let (opts, remaining) = parse_flags(&["--top", "src/index.ts"]).unwrap();
1274 assert!(opts.top.is_none());
1275 assert_eq!(remaining, "src/index.ts");
1276 }
1277
1278 #[test]
1279 fn parse_flags_top_modules_non_numeric_preserves_positional() {
1280 let (opts, remaining) = parse_flags(&["--top-modules", "src/index.ts"]).unwrap();
1281 assert!(opts.top_modules.is_none());
1282 assert_eq!(remaining, "src/index.ts");
1283 }
1284
1285 #[test]
1286 fn parse_flags_top_rejects_negative_below_minus_one() {
1287 let (opts, _) = parse_flags(&["--top", "-5", "src/index.ts"]).unwrap();
1288 assert!(opts.top.is_none());
1289 }
1290
1291 #[test]
1292 fn parse_flags_top_accepts_negative_one() {
1293 let (opts, _) = parse_flags(&["--top", "-1", "src/index.ts"]).unwrap();
1294 assert_eq!(opts.top, Some(-1));
1295 }
1296
1297 #[test]
1298 fn parse_flags_top_modules_rejects_negative_below_minus_one() {
1299 let (opts, _) = parse_flags(&["--top-modules", "-5", "src/index.ts"]).unwrap();
1300 assert!(opts.top_modules.is_none());
1301 }
1302
1303 #[test]
1304 fn parse_flags_top_modules_accepts_negative_one() {
1305 let (opts, _) = parse_flags(&["--top-modules", "-1", "src/index.ts"]).unwrap();
1306 assert_eq!(opts.top_modules, Some(-1));
1307 }
1308
1309 #[test]
1310 fn parse_flags_json() {
1311 let (opts, remaining) = parse_flags(&["--json", "src/index.ts"]).unwrap();
1312 assert!(opts.json);
1313 assert_eq!(remaining, "src/index.ts");
1314 }
1315
1316 #[test]
1317 fn parse_flags_unknown_flag_returns_error() {
1318 let err = parse_flags(&["--bogus", "src/index.ts"]).unwrap_err();
1319 assert!(err.contains("unknown flag '--bogus'"));
1320 }
1321
1322 #[test]
1323 fn parse_trace_with_flags() {
1324 let cmd = Command::parse("trace --include-dynamic --top 5 src/index.ts");
1325 match cmd {
1326 Command::Trace(Some(ref f), ref opts) => {
1327 assert_eq!(f, "src/index.ts");
1328 assert_eq!(opts.include_dynamic, Some(true));
1329 assert_eq!(opts.top, Some(5));
1330 }
1331 other => panic!("expected Trace, got {other:?}"),
1332 }
1333 }
1334
1335 #[test]
1336 fn parse_trace_flags_no_file() {
1337 let cmd = Command::parse("trace --include-dynamic");
1338 match cmd {
1339 Command::Trace(None, ref opts) => {
1340 assert_eq!(opts.include_dynamic, Some(true));
1341 }
1342 other => panic!("expected Trace(None, _), got {other:?}"),
1343 }
1344 }
1345
1346 #[test]
1347 fn parse_chain_with_dynamic() {
1348 let cmd = Command::parse("chain --include-dynamic zod");
1349 match cmd {
1350 Command::Chain(ref target, ref opts) => {
1351 assert_eq!(target, "zod");
1352 assert_eq!(opts.include_dynamic, Some(true));
1353 }
1354 other => panic!("expected Chain, got {other:?}"),
1355 }
1356 }
1357
1358 #[test]
1359 fn parse_cut_with_top() {
1360 let cmd = Command::parse("cut --top 3 zod");
1361 match cmd {
1362 Command::Cut(ref target, ref opts) => {
1363 assert_eq!(target, "zod");
1364 assert_eq!(opts.top, Some(3));
1365 }
1366 other => panic!("expected Cut, got {other:?}"),
1367 }
1368 }
1369
1370 #[test]
1371 fn parse_packages_with_top() {
1372 let cmd = Command::parse("packages --top 20");
1373 match cmd {
1374 Command::Packages(ref opts) => {
1375 assert_eq!(opts.top, Some(20));
1376 }
1377 other => panic!("expected Packages, got {other:?}"),
1378 }
1379 }
1380
1381 #[test]
1382 fn parse_set() {
1383 assert!(matches!(
1384 Command::parse("set dynamic"),
1385 Command::Set(ref s) if s == "dynamic"
1386 ));
1387 assert!(matches!(
1388 Command::parse("set top 5"),
1389 Command::Set(ref s) if s == "top 5"
1390 ));
1391 }
1392
1393 #[test]
1394 fn parse_unset() {
1395 assert!(matches!(
1396 Command::parse("unset ignore"),
1397 Command::Unset(ref s) if s == "ignore"
1398 ));
1399 }
1400
1401 #[test]
1402 fn parse_show() {
1403 assert!(matches!(Command::parse("show"), Command::Show));
1404 }
1405
1406 #[test]
1407 fn parse_set_missing_arg() {
1408 assert!(matches!(Command::parse("set"), Command::Unknown(_)));
1409 }
1410
1411 #[test]
1412 fn parse_unset_missing_arg() {
1413 assert!(matches!(Command::parse("unset"), Command::Unknown(_)));
1414 }
1415
1416 #[test]
1417 fn set_dynamic_toggle() {
1418 let mut s = ReplSettings::default();
1419 dispatch_set(&mut s, "dynamic", StderrColor::new(true));
1420 assert!(s.include_dynamic);
1421 dispatch_set(&mut s, "dynamic", StderrColor::new(true));
1422 assert!(!s.include_dynamic);
1423 }
1424
1425 #[test]
1426 fn set_dynamic_explicit() {
1427 let mut s = ReplSettings::default();
1428 dispatch_set(&mut s, "dynamic true", StderrColor::new(true));
1429 assert!(s.include_dynamic);
1430 dispatch_set(&mut s, "dynamic false", StderrColor::new(true));
1431 assert!(!s.include_dynamic);
1432 }
1433
1434 #[test]
1435 fn set_include_dynamic_alias() {
1436 let mut s = ReplSettings::default();
1437 dispatch_set(&mut s, "include-dynamic true", StderrColor::new(true));
1438 assert!(s.include_dynamic);
1439 }
1440
1441 #[test]
1442 fn unset_include_dynamic_alias() {
1443 let mut s = ReplSettings {
1444 include_dynamic: true,
1445 ..ReplSettings::default()
1446 };
1447 dispatch_unset(&mut s, "include-dynamic", StderrColor::new(true));
1448 assert!(!s.include_dynamic);
1449 }
1450
1451 #[test]
1452 fn set_top() {
1453 let mut s = ReplSettings::default();
1454 dispatch_set(&mut s, "top 5", StderrColor::new(true));
1455 assert_eq!(s.top, 5);
1456 }
1457
1458 #[test]
1459 fn set_top_modules() {
1460 let mut s = ReplSettings::default();
1461 dispatch_set(&mut s, "top-modules 30", StderrColor::new(true));
1462 assert_eq!(s.top_modules, 30);
1463 }
1464
1465 #[test]
1466 fn set_top_rejects_invalid_value() {
1467 let mut s = ReplSettings::default();
1468 dispatch_set(&mut s, "top -5", StderrColor::new(true));
1469 assert_eq!(s.top, report::DEFAULT_TOP);
1471 }
1472
1473 #[test]
1474 fn set_top_modules_rejects_invalid_value() {
1475 let mut s = ReplSettings::default();
1476 dispatch_set(&mut s, "top-modules -2", StderrColor::new(true));
1477 assert_eq!(s.top_modules, report::DEFAULT_TOP_MODULES);
1478 }
1479
1480 #[test]
1481 fn set_top_accepts_negative_one() {
1482 let mut s = ReplSettings::default();
1483 dispatch_set(&mut s, "top -1", StderrColor::new(true));
1484 assert_eq!(s.top, -1);
1485 }
1486
1487 #[test]
1488 fn set_top_accepts_zero() {
1489 let mut s = ReplSettings::default();
1490 dispatch_set(&mut s, "top 0", StderrColor::new(true));
1491 assert_eq!(s.top, 0);
1492 }
1493
1494 #[test]
1495 fn set_ignore() {
1496 let mut s = ReplSettings::default();
1497 dispatch_set(&mut s, "ignore zod lodash", StderrColor::new(true));
1498 assert_eq!(s.ignore, vec!["zod".to_string(), "lodash".to_string()]);
1499 }
1500
1501 #[test]
1502 fn unset_dynamic() {
1503 let mut s = ReplSettings {
1504 include_dynamic: true,
1505 ..ReplSettings::default()
1506 };
1507 dispatch_unset(&mut s, "dynamic", StderrColor::new(true));
1508 assert!(!s.include_dynamic);
1509 }
1510
1511 #[test]
1512 fn unset_ignore() {
1513 let mut s = ReplSettings {
1514 ignore: vec!["zod".into()],
1515 ..ReplSettings::default()
1516 };
1517 dispatch_unset(&mut s, "ignore", StderrColor::new(true));
1518 assert!(s.ignore.is_empty());
1519 }
1520
1521 #[test]
1522 fn unset_top() {
1523 let mut s = ReplSettings {
1524 top: 99,
1525 ..ReplSettings::default()
1526 };
1527 dispatch_unset(&mut s, "top", StderrColor::new(true));
1528 assert_eq!(s.top, report::DEFAULT_TOP);
1529 }
1530
1531 #[test]
1532 fn unset_top_modules() {
1533 let mut s = ReplSettings {
1534 top_modules: 99,
1535 ..ReplSettings::default()
1536 };
1537 dispatch_unset(&mut s, "top-modules", StderrColor::new(true));
1538 assert_eq!(s.top_modules, report::DEFAULT_TOP_MODULES);
1539 }
1540
1541 #[test]
1542 fn parse_diff_with_dynamic() {
1543 let cmd = Command::parse("diff --include-dynamic src/other.ts");
1544 match cmd {
1545 Command::Diff(ref path, ref opts) => {
1546 assert_eq!(path, "src/other.ts");
1547 assert_eq!(opts.include_dynamic, Some(true));
1548 }
1549 other => panic!("expected Diff, got {other:?}"),
1550 }
1551 }
1552}