1use std::path::{Path, PathBuf};
8
9use rustyline::Helper;
10use rustyline::completion::{Completer, Pair};
11use rustyline::highlight::Highlighter;
12use rustyline::hint::Hinter;
13use rustyline::validate::Validator;
14
15use crate::error::Error;
16use crate::graph::EdgeKind;
17use crate::query::{self, ChainTarget};
18use crate::report::{self, StderrColor};
19use crate::session::Session;
20
21pub enum Command {
23 Trace(Option<String>),
25 Entry(String),
27 Chain(String),
29 Cut(String),
31 Diff(String),
33 Packages,
35 Imports(String),
37 Importers(String),
39 Info(String),
41 Help,
43 Quit,
45 Unknown(String),
47}
48
49impl Command {
50 pub fn parse(line: &str) -> Self {
52 fn require(arg: Option<&str>, msg: &str) -> Result<String, String> {
54 match arg {
55 Some(a) if !a.is_empty() => Ok(a.to_string()),
56 _ => Err(msg.to_string()),
57 }
58 }
59
60 let line = line.trim();
61 if line.is_empty() {
62 return Self::Help;
63 }
64 let (cmd, arg) = line
65 .split_once(' ')
66 .map_or((line, None), |(c, a)| (c, Some(a.trim())));
67
68 match cmd {
69 "trace" => Self::Trace(arg.map(String::from)),
70 "entry" => match require(arg, "entry requires a file argument") {
71 Ok(a) => Self::Entry(a),
72 Err(e) => Self::Unknown(e),
73 },
74 "chain" => match require(arg, "chain requires a target argument") {
75 Ok(a) => Self::Chain(a),
76 Err(e) => Self::Unknown(e),
77 },
78 "cut" => match require(arg, "cut requires a target argument") {
79 Ok(a) => Self::Cut(a),
80 Err(e) => Self::Unknown(e),
81 },
82 "diff" => match require(arg, "diff requires a file argument") {
83 Ok(a) => Self::Diff(a),
84 Err(e) => Self::Unknown(e),
85 },
86 "imports" => match require(arg, "imports requires a file argument") {
87 Ok(a) => Self::Imports(a),
88 Err(e) => Self::Unknown(e),
89 },
90 "importers" => match require(arg, "importers requires a file argument") {
91 Ok(a) => Self::Importers(a),
92 Err(e) => Self::Unknown(e),
93 },
94 "info" => match require(arg, "info requires a package name") {
95 Ok(a) => Self::Info(a),
96 Err(e) => Self::Unknown(e),
97 },
98 "packages" => Self::Packages,
99 "help" | "?" => Self::Help,
100 "quit" | "exit" => Self::Quit,
101 _ => Self::Unknown(format!("unknown command: {cmd}")),
102 }
103 }
104}
105
106pub const COMMAND_NAMES: &[&str] = &[
108 "trace",
109 "entry",
110 "chain",
111 "cut",
112 "diff",
113 "packages",
114 "imports",
115 "importers",
116 "info",
117 "help",
118 "quit",
119 "exit",
120];
121
122const MAX_COMPLETIONS: usize = 20;
127
128struct ChainsawHelper {
129 file_paths: Vec<String>,
130 package_names: Vec<String>,
131}
132
133impl ChainsawHelper {
134 fn new() -> Self {
135 Self {
136 file_paths: Vec::new(),
137 package_names: Vec::new(),
138 }
139 }
140
141 fn update_from_session(&mut self, session: &Session) {
142 self.file_paths = session
143 .graph()
144 .modules
145 .iter()
146 .map(|m| report::relative_path(&m.path, session.root()))
147 .collect();
148 self.package_names = session.graph().package_map.keys().cloned().collect();
149 }
150}
151
152impl Completer for ChainsawHelper {
153 type Candidate = Pair;
154
155 fn complete(
156 &self,
157 line: &str,
158 pos: usize,
159 _ctx: &rustyline::Context<'_>,
160 ) -> rustyline::Result<(usize, Vec<Pair>)> {
161 let line = &line[..pos];
162
163 if !line.contains(' ') {
165 let matches: Vec<Pair> = COMMAND_NAMES
166 .iter()
167 .filter(|c| c.starts_with(line))
168 .map(|&c| Pair {
169 display: c.to_string(),
170 replacement: c.to_string(),
171 })
172 .collect();
173 return Ok((0, matches));
174 }
175
176 let (cmd, partial) = line.split_once(' ').unwrap_or((line, ""));
178 let partial = partial.trim_start();
179 let start = pos - partial.len();
180
181 let matches: Vec<Pair> = match cmd {
182 "chain" | "cut" => self
183 .package_names
184 .iter()
185 .chain(self.file_paths.iter())
186 .filter(|c| c.starts_with(partial))
187 .take(MAX_COMPLETIONS)
188 .map(|c| Pair {
189 display: c.clone(),
190 replacement: c.clone(),
191 })
192 .collect(),
193 "info" => self
194 .package_names
195 .iter()
196 .filter(|c| c.starts_with(partial))
197 .take(MAX_COMPLETIONS)
198 .map(|c| Pair {
199 display: c.clone(),
200 replacement: c.clone(),
201 })
202 .collect(),
203 "trace" | "entry" | "imports" | "importers" | "diff" => self
204 .file_paths
205 .iter()
206 .filter(|c| c.starts_with(partial))
207 .take(MAX_COMPLETIONS)
208 .map(|c| Pair {
209 display: c.clone(),
210 replacement: c.clone(),
211 })
212 .collect(),
213 _ => return Ok((start, vec![])),
214 };
215
216 Ok((start, matches))
217 }
218}
219
220impl Hinter for ChainsawHelper {
221 type Hint = String;
222}
223impl Highlighter for ChainsawHelper {}
224impl Validator for ChainsawHelper {}
225impl Helper for ChainsawHelper {}
226
227pub fn run(entry: &Path, no_color: bool, sc: StderrColor) -> Result<(), Error> {
233 let start = std::time::Instant::now();
234 let mut session = Session::open(entry, false)?;
235
236 report::print_load_status(
237 session.from_cache(),
238 session.graph().module_count(),
239 start.elapsed().as_secs_f64() * 1000.0,
240 session.file_warnings(),
241 session.unresolvable_dynamic_count(),
242 session.unresolvable_dynamic_files(),
243 session.root(),
244 sc,
245 );
246 eprintln!("Type 'help' for commands, 'quit' to exit.\n");
247
248 let mut helper = ChainsawHelper::new();
249 helper.update_from_session(&session);
250
251 let mut rl =
252 rustyline::Editor::new().map_err(|e| Error::Readline(format!("init failed: {e}")))?;
253 rl.set_helper(Some(helper));
254
255 let history_path = history_file();
256 if let Some(ref path) = history_path {
257 let _ = rl.load_history(path);
258 }
259
260 let prompt = format!("{}> ", sc.status("chainsaw"));
261
262 loop {
263 match session.refresh() {
265 Ok(true) => {
266 eprintln!(
267 "{} graph refreshed ({} modules)",
268 sc.status("Reloaded:"),
269 session.graph().module_count()
270 );
271 if let Some(h) = rl.helper_mut() {
272 h.update_from_session(&session);
273 }
274 }
275 Ok(false) => {}
276 Err(e) => eprintln!("{} refresh failed: {e}", sc.warning("warning:")),
277 }
278
279 let line = match rl.readline(&prompt) {
280 Ok(line) => line,
281 Err(rustyline::error::ReadlineError::Interrupted) => continue,
282 Err(rustyline::error::ReadlineError::Eof) => break,
283 Err(e) => {
284 eprintln!("{} {e}", sc.error("error:"));
285 break;
286 }
287 };
288
289 let trimmed = line.trim();
290 if trimmed.is_empty() {
291 continue;
292 }
293 rl.add_history_entry(trimmed).ok();
294
295 match Command::parse(trimmed) {
296 Command::Trace(file) => dispatch_trace(&session, file.as_deref(), no_color, sc),
297 Command::Entry(path) => dispatch_entry(&mut session, &path, sc),
298 Command::Chain(target) => dispatch_chain(&session, &target, no_color, sc),
299 Command::Cut(target) => dispatch_cut(&session, &target, no_color, sc),
300 Command::Diff(path) => dispatch_diff(&session, &path, no_color, sc),
301 Command::Packages => dispatch_packages(&session, no_color),
302 Command::Imports(path) => dispatch_imports(&session, &path, sc),
303 Command::Importers(path) => dispatch_importers(&session, &path, sc),
304 Command::Info(name) => dispatch_info(&session, &name, sc),
305 Command::Help => print_help(),
306 Command::Quit => break,
307 Command::Unknown(msg) => eprintln!("{} {msg}", sc.error("error:")),
308 }
309 }
310
311 if let Some(ref path) = history_path {
312 let _ = rl.save_history(path);
313 }
314 Ok(())
315}
316
317fn history_file() -> Option<PathBuf> {
318 let dir = std::env::var_os("XDG_DATA_HOME")
319 .map(PathBuf::from)
320 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
321 let dir = dir.join("chainsaw");
322 std::fs::create_dir_all(&dir).ok()?;
323 Some(dir.join("history"))
324}
325
326fn dispatch_trace(session: &Session, file: Option<&str>, no_color: bool, sc: StderrColor) {
331 let opts = query::TraceOptions::default();
332 let (result, entry_path) = if let Some(f) = file {
333 match session.trace_from(Path::new(f), &opts) {
334 Ok(r) => r,
335 Err(e) => {
336 eprintln!("{} {e}", sc.error("error:"));
337 return;
338 }
339 }
340 } else {
341 (session.trace(&opts), session.entry().to_path_buf())
342 };
343
344 let display_opts = report::DisplayOpts {
345 top: report::DEFAULT_TOP,
346 top_modules: report::DEFAULT_TOP_MODULES,
347 include_dynamic: false,
348 no_color,
349 max_weight: None,
350 };
351 report::print_trace(
352 session.graph(),
353 &result,
354 &entry_path,
355 session.root(),
356 &display_opts,
357 );
358}
359
360fn dispatch_entry(session: &mut Session, path: &str, sc: StderrColor) {
361 if let Err(e) = session.set_entry(Path::new(path)) {
362 eprintln!("{} {e}", sc.error("error:"));
363 return;
364 }
365 let rel = report::relative_path(session.entry(), session.root());
366 eprintln!("{} entry point is now {rel}", sc.status("Switched:"));
367}
368
369fn dispatch_chain(session: &Session, target: &str, no_color: bool, sc: StderrColor) {
370 let (resolved, chains) = session.chain(target, false);
371 if resolved.target == ChainTarget::Module(session.entry_id()) {
372 eprintln!("{} target is the entry point itself", sc.error("error:"));
373 return;
374 }
375 if !resolved.exists {
376 eprintln!(
377 "{} '{}' not found in graph",
378 sc.warning("warning:"),
379 resolved.label
380 );
381 }
382 report::print_chains(
383 session.graph(),
384 &chains,
385 &resolved.label,
386 session.root(),
387 resolved.exists,
388 no_color,
389 );
390}
391
392fn dispatch_cut(session: &Session, target: &str, no_color: bool, sc: StderrColor) {
393 let (resolved, chains, cuts) = session.cut(target, report::DEFAULT_TOP, false);
394 if resolved.target == ChainTarget::Module(session.entry_id()) {
395 eprintln!("{} target is the entry point itself", sc.error("error:"));
396 return;
397 }
398 if !resolved.exists {
399 eprintln!(
400 "{} '{}' not found in graph",
401 sc.warning("warning:"),
402 resolved.label
403 );
404 }
405 report::print_cut(
406 session.graph(),
407 &cuts,
408 &chains,
409 &resolved.label,
410 session.root(),
411 resolved.exists,
412 no_color,
413 );
414}
415
416fn dispatch_diff(session: &Session, path: &str, no_color: bool, sc: StderrColor) {
417 let opts = query::TraceOptions::default();
418 match session.diff_entry(Path::new(path), &opts) {
419 Ok((diff, other_canon)) => {
420 let entry_rel = session.entry_label();
421 let other_rel = session.entry_label_for(&other_canon);
422 report::print_diff(&diff, &entry_rel, &other_rel, report::DEFAULT_TOP, no_color);
423 }
424 Err(e) => eprintln!("{} {e}", sc.error("error:")),
425 }
426}
427
428fn dispatch_packages(session: &Session, no_color: bool) {
429 report::print_packages(session.graph(), report::DEFAULT_TOP, no_color);
430}
431
432fn dispatch_imports(session: &Session, path: &str, sc: StderrColor) {
433 match session.imports(Path::new(path)) {
434 Ok(imports) => {
435 if imports.is_empty() {
436 println!(" (no imports)");
437 return;
438 }
439 for (p, kind) in &imports {
440 let rel = report::relative_path(p, session.root());
441 let suffix = match kind {
442 EdgeKind::Static => "",
443 EdgeKind::Dynamic => " (dynamic)",
444 EdgeKind::TypeOnly => " (type-only)",
445 };
446 println!(" {rel}{suffix}");
447 }
448 }
449 Err(e) => eprintln!("{} {e}", sc.error("error:")),
450 }
451}
452
453fn dispatch_importers(session: &Session, path: &str, sc: StderrColor) {
454 match session.importers(Path::new(path)) {
455 Ok(importers) => {
456 if importers.is_empty() {
457 println!(" (no importers)");
458 return;
459 }
460 for (p, kind) in &importers {
461 let rel = report::relative_path(p, session.root());
462 let suffix = match kind {
463 EdgeKind::Static => "",
464 EdgeKind::Dynamic => " (dynamic)",
465 EdgeKind::TypeOnly => " (type-only)",
466 };
467 println!(" {rel}{suffix}");
468 }
469 }
470 Err(e) => eprintln!("{} {e}", sc.error("error:")),
471 }
472}
473
474fn dispatch_info(session: &Session, name: &str, sc: StderrColor) {
475 match session.info(name) {
476 Some(info) => {
477 println!(
478 " {} ({} files, {})",
479 info.name,
480 info.total_reachable_files,
481 report::format_size(info.total_reachable_size)
482 );
483 }
484 None => eprintln!("{} package '{name}' not found", sc.error("error:")),
485 }
486}
487
488fn print_help() {
489 println!("Commands:");
490 println!(" trace [file] Trace from entry point (or specified file)");
491 println!(" entry <file> Switch the default entry point");
492 println!(" chain <target> Show import chains to a package or file");
493 println!(" cut <target> Show where to cut to sever chains");
494 println!(" diff <file> Compare weight against another entry");
495 println!(" packages List third-party packages");
496 println!(" imports <file> Show what a file imports");
497 println!(" importers <file> Show what imports a file");
498 println!(" info <package> Show package details");
499 println!(" help Show this help");
500 println!(" quit Exit");
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn parse_trace_no_arg() {
509 assert!(matches!(Command::parse("trace"), Command::Trace(None)));
510 }
511
512 #[test]
513 fn parse_trace_with_file() {
514 assert!(
515 matches!(Command::parse("trace src/index.ts"), Command::Trace(Some(ref f)) if f == "src/index.ts")
516 );
517 }
518
519 #[test]
520 fn parse_chain() {
521 assert!(matches!(Command::parse("chain zod"), Command::Chain(ref t) if t == "zod"));
522 }
523
524 #[test]
525 fn parse_entry() {
526 assert!(
527 matches!(Command::parse("entry src/other.ts"), Command::Entry(ref f) if f == "src/other.ts")
528 );
529 }
530
531 #[test]
532 fn parse_packages() {
533 assert!(matches!(Command::parse("packages"), Command::Packages));
534 }
535
536 #[test]
537 fn parse_imports() {
538 assert!(
539 matches!(Command::parse("imports src/foo.ts"), Command::Imports(ref f) if f == "src/foo.ts")
540 );
541 }
542
543 #[test]
544 fn parse_importers() {
545 assert!(
546 matches!(Command::parse("importers lib/bar.py"), Command::Importers(ref f) if f == "lib/bar.py")
547 );
548 }
549
550 #[test]
551 fn parse_info() {
552 assert!(matches!(Command::parse("info zod"), Command::Info(ref p) if p == "zod"));
553 }
554
555 #[test]
556 fn parse_empty_is_help() {
557 assert!(matches!(Command::parse(""), Command::Help));
558 }
559
560 #[test]
561 fn parse_question_mark_is_help() {
562 assert!(matches!(Command::parse("?"), Command::Help));
563 }
564
565 #[test]
566 fn parse_quit() {
567 assert!(matches!(Command::parse("quit"), Command::Quit));
568 assert!(matches!(Command::parse("exit"), Command::Quit));
569 }
570
571 #[test]
572 fn parse_unknown() {
573 assert!(matches!(Command::parse("blah"), Command::Unknown(_)));
574 }
575
576 #[test]
577 fn parse_missing_arg() {
578 assert!(matches!(Command::parse("chain"), Command::Unknown(_)));
579 assert!(matches!(Command::parse("entry"), Command::Unknown(_)));
580 assert!(matches!(Command::parse("cut"), Command::Unknown(_)));
581 assert!(matches!(Command::parse("diff"), Command::Unknown(_)));
582 assert!(matches!(Command::parse("imports"), Command::Unknown(_)));
583 assert!(matches!(Command::parse("importers"), Command::Unknown(_)));
584 assert!(matches!(Command::parse("info"), Command::Unknown(_)));
585 }
586
587 #[test]
588 fn parse_preserves_arg_with_spaces() {
589 assert!(
590 matches!(Command::parse("chain @scope/pkg"), Command::Chain(ref t) if t == "@scope/pkg")
591 );
592 }
593
594 #[test]
595 fn parse_trims_whitespace() {
596 assert!(matches!(Command::parse(" quit "), Command::Quit));
597 }
598}