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