Skip to main content

chainsaw/
repl.rs

1//! REPL command parser and interactive loop.
2//!
3//! Commands accept optional inline flags (`--include-dynamic`, `--top N`, etc.)
4//! that override session-level settings for a single invocation. Use
5//! `set`/`unset`/`show` to manage persistent session settings.
6
7use 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/// Session-level settings that persist across REPL commands.
23#[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/// Per-command option overrides. `None` means "use session default".
44#[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    /// Resolve per-command overrides against session settings.
56    /// Returns `(TraceOptions, top_modules)`.
57    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
71/// Extract known `--flag` tokens from an argument list.
72///
73/// Returns `Ok((CommandOptions, positional_arg))` or `Err(message)` if an
74/// unknown `--flag` is encountered. The positional argument is the first
75/// non-flag token that isn't consumed as a flag value. `--ignore` consumes
76/// all subsequent non-flag tokens until the next `--` flag or end of input,
77/// so it must appear after the positional arg or be the last flag.
78fn 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; // i already advanced past consumed tokens
114            }
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/// A parsed REPL command.
128#[derive(Debug)]
129pub enum Command {
130    /// Trace transitive weight (optionally from a different file).
131    Trace(Option<String>, CommandOptions),
132    /// Switch the session entry point.
133    Entry(String),
134    /// Show import chains to a target.
135    Chain(String, CommandOptions),
136    /// Show optimal cut points for a target.
137    Cut(String, CommandOptions),
138    /// Diff against another entry point.
139    Diff(String, CommandOptions),
140    /// List all third-party packages.
141    Packages(CommandOptions),
142    /// List direct imports of a file.
143    Imports(String, CommandOptions),
144    /// List files that import a given file.
145    Importers(String, CommandOptions),
146    /// Show package info by name.
147    Info(String),
148    /// Set a session option.
149    Set(String),
150    /// Reset a session option to its default.
151    Unset(String),
152    /// Display current session settings.
153    Show,
154    /// Print help text.
155    Help,
156    /// Exit the REPL.
157    Quit,
158    /// Unrecognised input or missing argument.
159    Unknown(String),
160}
161
162impl Command {
163    /// Parse a single line of user input into a command.
164    #[allow(clippy::too_many_lines)]
165    pub fn parse(line: &str) -> Self {
166        /// Extract a non-empty positional or return an error message.
167        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        /// Require a non-empty raw argument string.
176        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        // Tokenize the argument string for commands that accept flags.
192        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
281/// All command names, for tab completion.
282pub 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
300// ---------------------------------------------------------------------------
301// Tab completion
302// ---------------------------------------------------------------------------
303
304const MAX_COMPLETIONS: usize = 20;
305
306/// Binary-search a sorted slice for entries starting with `prefix`, returning
307/// up to `limit` matches.
308fn 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
321/// Option names for `set`/`unset` tab completion (sorted).
322const 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        // Complete command names at start of line.
362        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        // Complete arguments based on command.
375        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
431// ---------------------------------------------------------------------------
432// REPL loop
433// ---------------------------------------------------------------------------
434
435/// Run the interactive REPL loop.
436pub 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        // Check for file changes before each command.
478        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
555// ---------------------------------------------------------------------------
556// Command dispatch
557// ---------------------------------------------------------------------------
558
559fn 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, // toggle
777                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    // -----------------------------------------------------------------------
913    // sorted_prefix_matches
914    // -----------------------------------------------------------------------
915
916    #[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    // -----------------------------------------------------------------------
975    // ChainsawHelper completion
976    // -----------------------------------------------------------------------
977
978    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        // packages first, then files
1015        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                // Leak is fine in tests — avoids lifetime gymnastics.
1044                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    // -----------------------------------------------------------------------
1053    // Command parsing
1054    // -----------------------------------------------------------------------
1055
1056    #[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        // --ignore is greedy: consumes all non-flag tokens after it.
1224        // Users should put --ignore last or use `set ignore`.
1225        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        // When --top is followed by a non-numeric token, the token should not
1272        // be consumed as the --top value — it stays as a positional arg.
1273        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        // Value should remain at default since -5 < -1.
1470        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}