1use crate::core::config::{APP_NAME, CONFIG_DIR_NAME};
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum PackageCommand {
32 Install,
34 Remove,
36 Update,
38 List,
40}
41
42impl PackageCommand {
43 #[must_use]
45 pub const fn as_str(self) -> &'static str {
46 match self {
47 Self::Install => "install",
48 Self::Remove => "remove",
49 Self::Update => "update",
50 Self::List => "list",
51 }
52 }
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
57pub enum UpdateTarget {
58 All,
60 Self_,
62 Extensions {
64 source: Option<String>,
66 },
67 Models,
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct PackageCommandOptions {
74 pub command: PackageCommand,
76 pub source: Option<String>,
78 pub update_target: Option<UpdateTarget>,
80 pub show_extensions_skipped_note: ExtensionsSkippedNotice,
82 pub local: bool,
84 pub force: bool,
86 pub project_trust_override: Option<bool>,
88 pub help: bool,
90 pub invalid_option: Option<String>,
92 pub invalid_argument: Option<String>,
94 pub missing_option_value: Option<String>,
96 pub conflicting_options: Option<String>,
98}
99
100#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct PackageOutcome {
103 pub exit_code: u8,
105 pub drain_quirk: bool,
108}
109
110impl PackageOutcome {
111 #[must_use]
113 pub const fn success() -> Self {
114 Self {
115 exit_code: 0,
116 drain_quirk: false,
117 }
118 }
119
120 #[must_use]
122 pub const fn failure(code: u8) -> Self {
123 Self {
124 exit_code: code,
125 drain_quirk: false,
126 }
127 }
128}
129
130#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct ListedPackage {
133 pub display: String,
135 pub installed_path: Option<String>,
137 pub scope: ListedScope,
139}
140
141#[derive(Clone, Copy, Debug, Eq, PartialEq)]
143pub enum ListedScope {
144 User,
146 Project,
148}
149
150pub trait PackageHandler {
157 fn set_offline(&self, _offline: bool) {}
159
160 fn install(&self, source: &str, local: bool) -> Result<(), String>;
165
166 fn remove(&self, source: &str, local: bool) -> Result<bool, String>;
171
172 fn list(&self) -> Result<Vec<ListedPackage>, String>;
177
178 fn set_project_trust_override(&self, _trust_override: Option<bool>) {}
180
181 fn is_project_trusted(&self) -> bool;
183
184 fn refresh_models(&self) -> Result<(), String>;
189
190 fn update_extensions(&self, source: Option<&str>) -> Result<(), String>;
195
196 fn update_self(&self, force: bool) -> Result<bool, String>;
204}
205
206pub trait PackageOutput {
209 fn status(&self, line: &str);
211 fn status_dim(&self, line: &str);
213 fn success(&self, line: &str);
215 fn error(&self, line: &str);
217}
218
219#[must_use]
221pub fn package_command_kind(argv0: &str) -> Option<PackageCommand> {
222 match argv0 {
223 "install" => Some(PackageCommand::Install),
224 "remove" | "uninstall" => Some(PackageCommand::Remove),
225 "update" => Some(PackageCommand::Update),
226 "list" => Some(PackageCommand::List),
227 _ => None,
228 }
229}
230
231#[must_use]
233pub fn is_config_command(argv0: &str) -> bool {
234 argv0 == "config"
235}
236
237#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
239pub enum ExtensionsSkippedNotice {
240 #[default]
242 Hidden,
243 Show,
245}
246
247impl ExtensionsSkippedNotice {
248 const fn should_print(self) -> bool {
249 matches!(self, Self::Show)
250 }
251}
252
253#[must_use]
259pub fn parse_package_command(args: &[String]) -> Option<PackageCommandOptions> {
260 let (raw, rest) = args.split_first()?;
261 let command = package_command_kind(raw)?;
262 let mut options = PackageCommandOptions {
263 command,
264 source: None,
265 update_target: None,
266 show_extensions_skipped_note: ExtensionsSkippedNotice::Hidden,
267 local: false,
268 force: false,
269 project_trust_override: None,
270 help: false,
271 invalid_option: None,
272 invalid_argument: None,
273 missing_option_value: None,
274 conflicting_options: None,
275 };
276 let mut update_flags = UpdateFlagState::default();
277 parse_package_arguments(rest, &mut options, &mut update_flags);
278
279 if command == PackageCommand::Update {
280 resolve_update_target(&mut options, update_flags);
281 }
282 Some(options)
283}
284
285fn parse_package_arguments(
286 args: &[String],
287 options: &mut PackageCommandOptions,
288 update_flags: &mut UpdateFlagState,
289) {
290 let mut index = 0;
291 while index < args.len() {
292 let arg = args[index].as_str();
293 if parse_simple_package_flag(arg, options, update_flags) {
294 index += 1;
295 continue;
296 }
297 if arg == "--extension" {
298 index += parse_extension_flag(&args[index..], options, update_flags);
299 continue;
300 }
301 if arg.starts_with('-') {
302 options.invalid_option.get_or_insert_with(|| arg.to_owned());
303 } else if options.source.is_none() {
304 options.source = Some(arg.to_owned());
305 } else {
306 options
307 .invalid_argument
308 .get_or_insert_with(|| arg.to_owned());
309 }
310 index += 1;
311 }
312}
313
314fn parse_simple_package_flag(
315 arg: &str,
316 options: &mut PackageCommandOptions,
317 update_flags: &mut UpdateFlagState,
318) -> bool {
319 match arg {
320 "-h" | "--help" => options.help = true,
321 "-l" | "--local" => {
322 if matches!(
323 options.command,
324 PackageCommand::Install | PackageCommand::Remove
325 ) {
326 options.local = true;
327 } else {
328 options.invalid_option.get_or_insert_with(|| arg.to_owned());
329 }
330 }
331 "--self" => record_update_flag(UpdateFlag::Self_, arg, options, update_flags),
332 "--extensions" => {
333 record_update_flag(UpdateFlag::Extensions, arg, options, update_flags);
334 }
335 "--models" => record_update_flag(UpdateFlag::Models, arg, options, update_flags),
336 "--all" => record_update_flag(UpdateFlag::All, arg, options, update_flags),
337 "--offline" => {}
338 "--approve" | "-a" => options.project_trust_override = Some(true),
339 "--no-approve" | "-na" => options.project_trust_override = Some(false),
340 "--force" => {
341 if options.command == PackageCommand::Update {
342 options.force = true;
343 } else {
344 options.invalid_option.get_or_insert_with(|| arg.to_owned());
345 }
346 }
347 _ => return false,
348 }
349 true
350}
351
352fn record_update_flag(
353 flag: UpdateFlag,
354 arg: &str,
355 options: &mut PackageCommandOptions,
356 update_flags: &mut UpdateFlagState,
357) {
358 if options.command == PackageCommand::Update {
359 update_flags.flags.insert(flag);
360 } else {
361 options.invalid_option.get_or_insert_with(|| arg.to_owned());
362 }
363}
364
365fn parse_extension_flag(
366 args: &[String],
367 options: &mut PackageCommandOptions,
368 update_flags: &mut UpdateFlagState,
369) -> usize {
370 let arg = args[0].as_str();
371 if options.command != PackageCommand::Update {
372 options.invalid_option.get_or_insert_with(|| arg.to_owned());
373 return 1;
374 }
375 match args.get(1).map(String::as_str) {
376 Some(value) if !value.starts_with('-') => {
377 if update_flags.extension_source.is_some() {
378 options
379 .conflicting_options
380 .get_or_insert_with(|| "--extension can only be provided once".to_owned());
381 } else {
382 update_flags.extension_source = Some(value.to_owned());
383 }
384 2
385 }
386 _ => {
387 options
388 .missing_option_value
389 .get_or_insert_with(|| arg.to_owned());
390 1
391 }
392 }
393}
394
395fn resolve_update_target(options: &mut PackageCommandOptions, flags: UpdateFlagState) {
396 let self_flag = flags.flags.contains(UpdateFlag::Self_);
397 let extensions_flag = flags.flags.contains(UpdateFlag::Extensions);
398 let models_flag = flags.flags.contains(UpdateFlag::Models);
399 let all_flag = flags.flags.contains(UpdateFlag::All);
400 let extension_flag_source = flags.extension_source;
401
402 if all_flag && (self_flag || extensions_flag || models_flag || extension_flag_source.is_some())
403 {
404 options.conflicting_options.get_or_insert_with(|| {
405 "--all cannot be combined with --self, --extensions, --models, or --extension"
406 .to_owned()
407 });
408 }
409 if all_flag && options.source.is_some() {
410 options
411 .conflicting_options
412 .get_or_insert_with(|| "--all cannot be combined with a positional source".to_owned());
413 }
414
415 if models_flag {
416 if self_flag || extensions_flag || all_flag || extension_flag_source.is_some() {
417 options.conflicting_options.get_or_insert_with(|| {
418 "--models cannot be combined with --self, --extensions, --all, or --extension"
419 .to_owned()
420 });
421 }
422 if options.source.is_some() {
423 options.conflicting_options.get_or_insert_with(|| {
424 "--models cannot be combined with a positional source".to_owned()
425 });
426 }
427 options.update_target = Some(UpdateTarget::Models);
428 return;
429 }
430
431 if let Some(ext_source) = extension_flag_source.clone() {
432 if self_flag || extensions_flag || all_flag {
433 options.conflicting_options.get_or_insert_with(|| {
434 "--extension cannot be combined with --self, --extensions, or --all".to_owned()
435 });
436 }
437 if options.source.is_some() {
438 options.conflicting_options.get_or_insert_with(|| {
439 "--extension cannot be combined with a positional source".to_owned()
440 });
441 }
442 options.update_target = Some(UpdateTarget::Extensions {
443 source: Some(ext_source),
444 });
445 return;
446 }
447
448 if let Some(source) = options.source.clone() {
449 let source_is_self = source == "self" || source == APP_NAME;
450 if source_is_self {
451 options.update_target = Some(if extensions_flag {
452 UpdateTarget::All
453 } else {
454 UpdateTarget::Self_
455 });
456 } else {
457 if extensions_flag || self_flag || all_flag {
458 options.conflicting_options.get_or_insert_with(|| {
459 "positional update targets cannot be combined with --self, --extensions, or --all"
460 .to_owned()
461 });
462 }
463 options.update_target = Some(UpdateTarget::Extensions {
464 source: Some(source),
465 });
466 }
467 return;
468 }
469
470 if all_flag || self_flag && extensions_flag {
471 options.update_target = Some(UpdateTarget::All);
472 } else if self_flag {
473 options.update_target = Some(UpdateTarget::Self_);
474 } else if extensions_flag {
475 options.update_target = Some(UpdateTarget::Extensions { source: None });
476 } else {
477 options.update_target = Some(UpdateTarget::Self_);
478 options.show_extensions_skipped_note = ExtensionsSkippedNotice::Show;
479 }
480}
481
482#[derive(Clone, Copy)]
483enum UpdateFlag {
484 Self_,
485 Extensions,
486 Models,
487 All,
488}
489
490#[derive(Default)]
491struct UpdateFlagSet(u8);
492
493impl UpdateFlagSet {
494 fn insert(&mut self, flag: UpdateFlag) {
495 self.0 |= 1 << flag as u8;
496 }
497
498 const fn contains(&self, flag: UpdateFlag) -> bool {
499 self.0 & (1 << flag as u8) != 0
500 }
501}
502
503#[derive(Default)]
504struct UpdateFlagState {
505 flags: UpdateFlagSet,
506 extension_source: Option<String>,
507}
508
509#[must_use]
511pub fn package_command_usage(command: PackageCommand) -> String {
512 match command {
513 PackageCommand::Install => {
514 format!("{APP_NAME} install <source> [-l] [--approve|--no-approve]")
515 }
516 PackageCommand::Remove => {
517 format!("{APP_NAME} remove <source> [-l] [--approve|--no-approve]")
518 }
519 PackageCommand::Update => format!(
520 "{APP_NAME} update [source|self|pi] [--self|--extensions|--models|--all] [--extension <source>] [--approve|--no-approve] [--force]"
521 ),
522 PackageCommand::List => format!("{APP_NAME} list [--approve|--no-approve]"),
523 }
524}
525
526#[must_use]
528pub fn config_command_usage() -> String {
529 format!("{APP_NAME} config [-l] [--approve|--no-approve]")
530}
531
532#[must_use]
534pub fn format_package_command_help(command: PackageCommand) -> String {
535 let usage = package_command_usage(command);
536 match command {
537 PackageCommand::Install => format!(
538 "Usage:\n {usage}\n\nInstall a package and add it to settings.\n\nOptions:\n -l, --local Install project-locally ({CONFIG_DIR_NAME}/settings.json)\n -a, --approve Trust project-local files for this command\n -na, --no-approve Ignore project-local files for this command\n\nExamples:\n {APP_NAME} install npm:@foo/bar\n {APP_NAME} install git:github.com/user/repo\n {APP_NAME} install git:git@github.com:user/repo\n {APP_NAME} install https://github.com/user/repo\n {APP_NAME} install ssh://git@github.com/user/repo\n {APP_NAME} install ./local/path\n"
539 ),
540 PackageCommand::Remove => format!(
541 "Usage:\n {usage}\n\nRemove a package and its source from settings.\nAlias: {APP_NAME} uninstall <source> [-l]\n\nOptions:\n -l, --local Remove from project settings ({CONFIG_DIR_NAME}/settings.json)\n -a, --approve Trust project-local files for this command\n -na, --no-approve Ignore project-local files for this command\n\nExamples:\n {APP_NAME} remove npm:@foo/bar\n {APP_NAME} uninstall npm:@foo/bar\n"
542 ),
543 PackageCommand::Update => format!(
544 "Usage:\n {usage}\n\nUpdate pi, installed packages, or model catalogs.\n\nOptions:\n --self Update pi only (default when no target is given)\n --extensions Update installed packages only\n --models Refresh model catalogs only\n --all Update pi and installed packages\n --extension <source> Update one package only\n -a, --approve Trust project-local files for this command\n -na, --no-approve Ignore project-local files for this command\n --force Reinstall pi even if the current version is latest\n\nShort forms:\n {APP_NAME} update Update pi only\n {APP_NAME} update --all Update pi and all extensions\n {APP_NAME} update --models Refresh model catalogs only\n {APP_NAME} update <source> Update one package\n {APP_NAME} update pi Update pi only (self works as alias to pi)\n"
545 ),
546 PackageCommand::List => format!(
547 "Usage:\n {usage}\n\nList installed packages from user and project settings.\n\nOptions:\n -a, --approve Trust project-local files for this command\n -na, --no-approve Ignore project-local files for this command\n"
548 ),
549 }
550}
551
552#[must_use]
554pub fn format_config_command_help() -> String {
555 format!(
556 "Usage:\n {}\n\nOpen the resource configuration TUI to enable or disable package resources.\nWithout -l, starts in global settings (~/{CONFIG_DIR_NAME}/agent/settings.json).\nPress Tab in the TUI to switch between global and project-local modes.\n\nOptions:\n -l, --local Edit project overrides ({CONFIG_DIR_NAME}/settings.json)\n -a, --approve Trust project-local files for this command with -l\n -na, --no-approve Ignore project-local files for this command with -l\n",
557 config_command_usage()
558 )
559}
560
561fn writes_project_package_config(command: PackageCommand, local: bool) -> bool {
563 matches!(command, PackageCommand::Install | PackageCommand::Remove) && local
564}
565
566pub fn handle_config_command(
574 args: &[String],
575 out: &dyn PackageOutput,
576 stdin_is_tty: bool,
577 stdout_is_tty: bool,
578) -> Option<PackageOutcome> {
579 let (raw, _rest) = args.split_first()?;
580 if !is_config_command(raw) {
581 return None;
582 }
583 if args.iter().any(|a| a == "-h" || a == "--help") {
584 out.status(&format_config_command_help());
585 return Some(PackageOutcome::success());
586 }
587 if !stdin_is_tty || !stdout_is_tty {
588 out.error(&format!(
589 "{APP_NAME} config requires an interactive terminal."
590 ));
591 return Some(PackageOutcome::failure(1));
592 }
593 out.error(&format!("{APP_NAME} config TUI runner not configured."));
597 Some(PackageOutcome::failure(1))
598}
599pub fn handle_package_command(
603 args: &[String],
604 handler: &dyn PackageHandler,
605 out: &dyn PackageOutput,
606 platform: DispatchPlatform,
607) -> Option<PackageOutcome> {
608 let options = parse_package_command(args)?;
609 handler.set_project_trust_override(options.project_trust_override);
610 if let Some(outcome) = package_command_preflight(&options, handler, out) {
611 return Some(outcome);
612 }
613
614 Some(match options.command {
615 PackageCommand::Install => dispatch_install(&options, handler, out),
616 PackageCommand::Remove => dispatch_remove(&options, handler, out),
617 PackageCommand::List => dispatch_list(handler, out),
618 PackageCommand::Update => dispatch_update(&options, handler, out, platform),
619 })
620}
621
622fn package_command_preflight(
623 options: &PackageCommandOptions,
624 handler: &dyn PackageHandler,
625 out: &dyn PackageOutput,
626) -> Option<PackageOutcome> {
627 if options.help {
628 out.status(&format_package_command_help(options.command));
629 return Some(PackageOutcome::success());
630 }
631 if let Some(opt) = &options.invalid_option {
632 out.error(&format!(
633 "Unknown option {opt} for \"{}\".",
634 options.command.as_str()
635 ));
636 out.error(&format!(
637 "Use \"{APP_NAME} --help\" or \"{}\".",
638 package_command_usage(options.command)
639 ));
640 return Some(PackageOutcome::failure(1));
641 }
642 if let Some(opt) = &options.missing_option_value {
643 out.error(&format!("Missing value for {opt}."));
644 output_package_usage(options.command, out);
645 return Some(PackageOutcome::failure(1));
646 }
647 if let Some(arg) = &options.invalid_argument {
648 out.error(&format!("Unexpected argument {arg}."));
649 output_package_usage(options.command, out);
650 return Some(PackageOutcome::failure(1));
651 }
652 if let Some(msg) = &options.conflicting_options {
653 out.error(msg);
654 output_package_usage(options.command, out);
655 return Some(PackageOutcome::failure(1));
656 }
657 if matches!(
658 options.command,
659 PackageCommand::Install | PackageCommand::Remove
660 ) && options.source.is_none()
661 {
662 out.error(&format!("Missing {} source.", options.command.as_str()));
663 output_package_usage(options.command, out);
664 return Some(PackageOutcome::failure(1));
665 }
666 if options.command == PackageCommand::Update
667 && matches!(options.update_target, Some(UpdateTarget::Models))
668 {
669 return Some(match handler.refresh_models() {
670 Ok(()) => PackageOutcome::success(),
671 Err(msg) => {
672 out.error(&format!("Error: {msg}"));
673 PackageOutcome::failure(1)
674 }
675 });
676 }
677 if writes_project_package_config(options.command, options.local)
678 && !handler.is_project_trusted()
679 {
680 out.error("Project is not trusted. Use --approve to modify local package config.");
681 return Some(PackageOutcome::failure(1));
682 }
683 None
684}
685
686fn output_package_usage(command: PackageCommand, out: &dyn PackageOutput) {
687 out.error(&format!("Usage: {}", package_command_usage(command)));
688}
689
690fn dispatch_install(
691 options: &PackageCommandOptions,
692 handler: &dyn PackageHandler,
693 out: &dyn PackageOutput,
694) -> PackageOutcome {
695 let source = options.source.as_deref().unwrap_or("");
696 match handler.install(source, options.local) {
697 Ok(()) => {
698 out.success(&format!("Installed {source}"));
699 PackageOutcome::success()
700 }
701 Err(msg) => {
702 out.error(&format!("Error: {msg}"));
703 PackageOutcome::failure(1)
704 }
705 }
706}
707
708fn dispatch_remove(
709 options: &PackageCommandOptions,
710 handler: &dyn PackageHandler,
711 out: &dyn PackageOutput,
712) -> PackageOutcome {
713 let source = options.source.as_deref().unwrap_or("");
714 match handler.remove(source, options.local) {
715 Ok(true) => {
716 out.success(&format!("Removed {source}"));
717 PackageOutcome::success()
718 }
719 Ok(false) => {
720 out.error(&format!("No matching package found for {source}"));
721 PackageOutcome::failure(1)
722 }
723 Err(msg) => {
724 out.error(&format!("Error: {msg}"));
725 PackageOutcome::failure(1)
726 }
727 }
728}
729
730fn dispatch_list(handler: &dyn PackageHandler, out: &dyn PackageOutput) -> PackageOutcome {
731 match handler.list() {
732 Ok(packages) => {
733 output_package_list(&packages, out);
734 PackageOutcome::success()
735 }
736 Err(msg) => {
737 out.error(&format!("Error: {msg}"));
738 PackageOutcome::failure(1)
739 }
740 }
741}
742
743fn output_package_list(packages: &[ListedPackage], out: &dyn PackageOutput) {
744 if packages.is_empty() {
745 out.status_dim("No packages installed.");
746 return;
747 }
748 let user: Vec<&ListedPackage> = packages
749 .iter()
750 .filter(|package| package.scope == ListedScope::User)
751 .collect();
752 let project: Vec<&ListedPackage> = packages
753 .iter()
754 .filter(|package| package.scope == ListedScope::Project)
755 .collect();
756 output_package_scope("User packages:", &user, out);
757 if !project.is_empty() {
758 if !user.is_empty() {
759 out.status("");
760 }
761 output_package_scope("Project packages:", &project, out);
762 }
763}
764
765fn output_package_scope(heading: &str, packages: &[&ListedPackage], out: &dyn PackageOutput) {
766 if packages.is_empty() {
767 return;
768 }
769 out.status(heading);
770 for package in packages {
771 out.status(&format!(" {}", package.display));
772 if let Some(path) = &package.installed_path {
773 out.status_dim(&format!(" {path}"));
774 }
775 }
776}
777
778fn dispatch_update(
779 options: &PackageCommandOptions,
780 handler: &dyn PackageHandler,
781 out: &dyn PackageOutput,
782 platform: DispatchPlatform,
783) -> PackageOutcome {
784 let target = options.update_target.clone().unwrap_or(UpdateTarget::Self_);
785 if options.show_extensions_skipped_note.should_print() {
786 out.status_dim(&format!(
787 "Extensions are skipped. Run {APP_NAME} update --extensions to update extensions."
788 ));
789 }
790 if matches!(target, UpdateTarget::All | UpdateTarget::Extensions { .. })
791 && let Err(outcome) = dispatch_extensions_update(&target, handler, out)
792 {
793 return outcome;
794 }
795 if matches!(target, UpdateTarget::All | UpdateTarget::Self_) {
796 match handler.update_self(options.force) {
797 Ok(true) if matches!(platform, DispatchPlatform::Windows) => {
798 return PackageOutcome {
799 exit_code: 0,
800 drain_quirk: true,
801 };
802 }
803 Ok(false | true) => {}
804 Err(msg) => {
805 out.error(&format!("Error: {msg}"));
806 return PackageOutcome::failure(1);
807 }
808 }
809 }
810 PackageOutcome::success()
811}
812
813fn dispatch_extensions_update(
814 target: &UpdateTarget,
815 handler: &dyn PackageHandler,
816 out: &dyn PackageOutput,
817) -> Result<(), PackageOutcome> {
818 let source = match target {
819 UpdateTarget::Extensions { source } => source.as_deref(),
820 _ => None,
821 };
822 match handler.update_extensions(source) {
823 Ok(()) => {
824 if let Some(source) = source {
825 out.success(&format!("Updated {source}"));
826 } else {
827 out.success("Updated packages");
828 }
829 Ok(())
830 }
831 Err(msg) => {
832 out.error(&format!("Error: {msg}"));
833 Err(PackageOutcome::failure(1))
834 }
835 }
836}
837
838#[derive(Clone, Copy, Debug, Eq, PartialEq)]
840pub enum DispatchPlatform {
841 Unix,
843 Windows,
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850 use std::cell::RefCell;
851 use std::rc::Rc;
852
853 fn args(tokens: &[&str]) -> Vec<String> {
854 tokens.iter().map(|s| (*s).to_owned()).collect()
855 }
856
857 #[derive(Default)]
859 struct CapturedOutput {
860 status: Vec<String>,
861 status_dim: Vec<String>,
862 success: Vec<String>,
863 error: Vec<String>,
864 }
865
866 impl PackageOutput for Rc<RefCell<CapturedOutput>> {
867 fn status(&self, line: &str) {
868 self.borrow_mut().status.push(line.to_owned());
869 }
870 fn status_dim(&self, line: &str) {
871 self.borrow_mut().status_dim.push(line.to_owned());
872 }
873 fn success(&self, line: &str) {
874 self.borrow_mut().success.push(line.to_owned());
875 }
876 fn error(&self, line: &str) {
877 self.borrow_mut().error.push(line.to_owned());
878 }
879 }
880
881 struct FakeHandler {
883 install_results: Vec<Result<(), String>>,
884 remove_results: Vec<Result<bool, String>>,
885 list_result: Result<Vec<ListedPackage>, String>,
886 refresh_result: Result<(), String>,
887 update_ext_result: Result<(), String>,
888 update_self_result: Result<bool, String>,
889 trusted: bool,
890 calls: Vec<String>,
891 }
892
893 impl Default for FakeHandler {
894 fn default() -> Self {
895 Self {
896 install_results: Vec::new(),
897 remove_results: Vec::new(),
898 list_result: Ok(Vec::new()),
899 refresh_result: Ok(()),
900 update_ext_result: Ok(()),
901 update_self_result: Ok(false),
902 trusted: false,
903 calls: Vec::new(),
904 }
905 }
906 }
907
908 impl PackageHandler for Rc<RefCell<FakeHandler>> {
909 fn install(&self, source: &str, local: bool) -> Result<(), String> {
910 self.borrow_mut()
911 .calls
912 .push(format!("install:{source}:{local}"));
913 (self.borrow_mut().install_results.remove(0)).clone()
914 }
915 fn remove(&self, source: &str, local: bool) -> Result<bool, String> {
916 self.borrow_mut()
917 .calls
918 .push(format!("remove:{source}:{local}"));
919 (self.borrow_mut().remove_results.remove(0)).clone()
920 }
921 fn list(&self) -> Result<Vec<ListedPackage>, String> {
922 self.borrow().list_result.clone()
923 }
924 fn set_project_trust_override(&self, trust_override: Option<bool>) {
925 if let Some(trusted) = trust_override {
926 self.borrow_mut().trusted = trusted;
927 }
928 }
929 fn is_project_trusted(&self) -> bool {
930 self.borrow().trusted
931 }
932 fn refresh_models(&self) -> Result<(), String> {
933 self.borrow_mut().calls.push("refresh".to_owned());
934 self.borrow().refresh_result.clone()
935 }
936 fn update_extensions(&self, source: Option<&str>) -> Result<(), String> {
937 self.borrow_mut()
938 .calls
939 .push(format!("update_ext:{}", source.unwrap_or("-")));
940 self.borrow().update_ext_result.clone()
941 }
942 fn update_self(&self, force: bool) -> Result<bool, String> {
943 self.borrow_mut().calls.push(format!("update_self:{force}"));
944 self.borrow().update_self_result.clone()
945 }
946 }
947
948 #[test]
949 fn parser_recognizes_subcommands() {
950 assert_eq!(
951 parse_package_command(&args(&["install", "npm:x"])).map(|o| o.command),
952 Some(PackageCommand::Install)
953 );
954 assert_eq!(
955 parse_package_command(&args(&["uninstall", "npm:x"])).map(|o| o.command),
956 Some(PackageCommand::Remove)
957 );
958 assert_eq!(
959 parse_package_command(&args(&["list"])).map(|o| o.command),
960 Some(PackageCommand::List)
961 );
962 assert!(parse_package_command(&args(&["--help"])).is_none());
963 assert!(parse_package_command(&args(&["run"])).is_none());
964 }
965
966 #[test]
967 fn parser_install_with_local_and_trust_flags() -> Result<(), String> {
968 let opts = parse_package_command(&args(&["install", "npm:foo", "-l", "-a"]))
969 .ok_or_else(|| "expected install command to parse".to_owned())?;
970 assert_eq!(opts.command, PackageCommand::Install);
971 assert_eq!(opts.source.as_deref(), Some("npm:foo"));
972 assert!(opts.local);
973 assert_eq!(opts.project_trust_override, Some(true));
974 assert!(!opts.help);
975 assert!(opts.invalid_option.is_none());
976 Ok(())
977 }
978
979 #[test]
980 fn parser_rejects_local_for_list() -> Result<(), String> {
981 let opts = parse_package_command(&args(&["list", "-l"]))
982 .ok_or_else(|| "expected list command to parse".to_owned())?;
983 assert_eq!(opts.invalid_option.as_deref(), Some("-l"));
984 Ok(())
985 }
986
987 #[test]
988 fn parser_update_self_default() -> Result<(), String> {
989 let opts = parse_package_command(&args(&["update"]))
990 .ok_or_else(|| "expected update command to parse".to_owned())?;
991 assert_eq!(opts.command, PackageCommand::Update);
992 assert_eq!(opts.update_target, Some(UpdateTarget::Self_));
993 assert!(opts.show_extensions_skipped_note.should_print());
994 Ok(())
995 }
996
997 #[test]
998 fn parser_update_all_flag() -> Result<(), String> {
999 let opts = parse_package_command(&args(&["update", "--all"]))
1000 .ok_or_else(|| "expected update --all command to parse".to_owned())?;
1001 assert_eq!(opts.update_target, Some(UpdateTarget::All));
1002 assert!(!opts.show_extensions_skipped_note.should_print());
1003 Ok(())
1004 }
1005
1006 #[test]
1007 fn parser_update_models_isolated() -> Result<(), String> {
1008 let opts = parse_package_command(&args(&["update", "--models"]))
1009 .ok_or_else(|| "expected update --models command to parse".to_owned())?;
1010 assert_eq!(opts.update_target, Some(UpdateTarget::Models));
1011 Ok(())
1012 }
1013
1014 #[test]
1015 fn parser_update_models_conflicts_with_self() -> Result<(), String> {
1016 let opts = parse_package_command(&args(&["update", "--models", "--self"]))
1017 .ok_or_else(|| "expected conflicting update command to parse".to_owned())?;
1018 assert!(opts.conflicting_options.is_some());
1019 Ok(())
1020 }
1021
1022 #[test]
1023 fn parser_update_extension_source() -> Result<(), String> {
1024 let opts = parse_package_command(&args(&["update", "--extension", "npm:bar"]))
1025 .ok_or_else(|| "expected extension update command to parse".to_owned())?;
1026 assert_eq!(
1027 opts.update_target,
1028 Some(UpdateTarget::Extensions {
1029 source: Some("npm:bar".to_owned())
1030 })
1031 );
1032 Ok(())
1033 }
1034
1035 #[test]
1036 fn parser_update_extension_missing_value() -> Result<(), String> {
1037 let opts = parse_package_command(&args(&["update", "--extension"]))
1038 .ok_or_else(|| "expected incomplete extension update command to parse".to_owned())?;
1039 assert_eq!(opts.missing_option_value.as_deref(), Some("--extension"));
1040 Ok(())
1041 }
1042
1043 #[test]
1044 fn parser_update_pi_alias() -> Result<(), String> {
1045 let opts = parse_package_command(&args(&["update", "pi"]))
1046 .ok_or_else(|| "expected update pi alias to parse".to_owned())?;
1047 assert_eq!(opts.update_target, Some(UpdateTarget::Self_));
1048 let opts = parse_package_command(&args(&["update", "pi", "--extensions"]))
1049 .ok_or_else(|| "expected update pi --extensions alias to parse".to_owned())?;
1050 assert_eq!(opts.update_target, Some(UpdateTarget::All));
1051 Ok(())
1052 }
1053
1054 #[test]
1055 fn parser_update_positional_source() -> Result<(), String> {
1056 let opts = parse_package_command(&args(&["update", "npm:foo"]))
1057 .ok_or_else(|| "expected positional extension update to parse".to_owned())?;
1058 assert_eq!(
1059 opts.update_target,
1060 Some(UpdateTarget::Extensions {
1061 source: Some("npm:foo".to_owned())
1062 })
1063 );
1064 Ok(())
1065 }
1066
1067 #[test]
1068 fn parser_help_flag() -> Result<(), String> {
1069 let opts = parse_package_command(&args(&["install", "--help"]))
1070 .ok_or_else(|| "expected install help command to parse".to_owned())?;
1071 assert!(opts.help);
1072 Ok(())
1073 }
1074
1075 #[test]
1076 fn parser_invalid_argument_after_source() -> Result<(), String> {
1077 let opts = parse_package_command(&args(&["install", "a", "b"]))
1078 .ok_or_else(|| "expected invalid install command to parse".to_owned())?;
1079 assert_eq!(opts.invalid_argument.as_deref(), Some("b"));
1080 Ok(())
1081 }
1082
1083 #[test]
1084 fn help_block_install_contains_usage_and_examples() {
1085 let text = format_package_command_help(PackageCommand::Install);
1086 assert!(text.contains("Usage:"));
1087 assert!(text.contains(&format!("{APP_NAME} install <source>")));
1088 assert!(text.contains("npm:@foo/bar"));
1089 }
1090
1091 #[test]
1092 fn help_block_update_contains_all_flags() {
1093 let text = format_package_command_help(PackageCommand::Update);
1094 assert!(text.contains("--self"));
1095 assert!(text.contains("--extensions"));
1096 assert!(text.contains("--models"));
1097 assert!(text.contains("--all"));
1098 assert!(text.contains("--force"));
1099 }
1100
1101 #[test]
1102 fn config_help_mentions_tui_and_local() {
1103 let text = format_config_command_help();
1104 assert!(text.contains("Usage:"));
1105 assert!(text.contains("-l, --local"));
1106 assert!(text.contains("Tab"));
1107 }
1108
1109 #[test]
1110 fn dispatch_help_short_circuits_with_usage() -> Result<(), String> {
1111 let handler = Rc::new(RefCell::new(FakeHandler::default()));
1112 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1113 let outcome = handle_package_command(
1114 &args(&["install", "--help"]),
1115 &handler,
1116 &out,
1117 DispatchPlatform::Unix,
1118 )
1119 .ok_or_else(|| "expected install help dispatch outcome".to_owned())?;
1120 assert_eq!(outcome.exit_code, 0);
1121 assert!(!out.borrow().status.is_empty());
1122 assert!(handler.borrow().calls.is_empty());
1123 Ok(())
1124 }
1125
1126 #[test]
1127 fn dispatch_install_success() -> Result<(), String> {
1128 let handler = Rc::new(RefCell::new(FakeHandler {
1129 install_results: vec![Ok(())],
1130 trusted: true,
1131 ..FakeHandler::default()
1132 }));
1133 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1134 let outcome = handle_package_command(
1135 &args(&["install", "npm:foo"]),
1136 &handler,
1137 &out,
1138 DispatchPlatform::Unix,
1139 )
1140 .ok_or_else(|| "expected install dispatch outcome".to_owned())?;
1141 assert_eq!(outcome.exit_code, 0);
1142 assert_eq!(out.borrow().success, vec!["Installed npm:foo"]);
1143 assert_eq!(handler.borrow().calls, vec!["install:npm:foo:false"]);
1144 Ok(())
1145 }
1146
1147 #[test]
1148 fn dispatch_install_local_untrusted_exits_one() -> Result<(), String> {
1149 let handler = Rc::new(RefCell::new(FakeHandler {
1150 trusted: false,
1151 ..FakeHandler::default()
1152 }));
1153 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1154 let outcome = handle_package_command(
1155 &args(&["install", "npm:foo", "-l"]),
1156 &handler,
1157 &out,
1158 DispatchPlatform::Unix,
1159 )
1160 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1161 assert_eq!(outcome.exit_code, 1);
1162 assert_eq!(
1163 out.borrow().error[0],
1164 "Project is not trusted. Use --approve to modify local package config."
1165 );
1166 assert!(handler.borrow().calls.is_empty());
1167 Ok(())
1168 }
1169
1170 #[test]
1171 fn approve_override_unblocks_local_install() -> Result<(), String> {
1172 let handler = Rc::new(RefCell::new(FakeHandler {
1173 install_results: vec![Ok(())],
1174 trusted: false,
1175 ..FakeHandler::default()
1176 }));
1177 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1178 let outcome = handle_package_command(
1179 &args(&["install", "npm:foo", "-l", "--approve"]),
1180 &handler,
1181 &out,
1182 DispatchPlatform::Unix,
1183 )
1184 .ok_or_else(|| "expected dispatch outcome".to_owned())?;
1185 assert_eq!(outcome.exit_code, 0);
1186 assert_eq!(handler.borrow().calls, ["install:npm:foo:true"]);
1187 Ok(())
1188 }
1189
1190 #[test]
1191 fn no_approve_override_forces_local_denial() -> Result<(), String> {
1192 let handler = Rc::new(RefCell::new(FakeHandler {
1193 trusted: true,
1194 ..FakeHandler::default()
1195 }));
1196 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1197 let outcome = handle_package_command(
1198 &args(&["install", "npm:foo", "-l", "--no-approve"]),
1199 &handler,
1200 &out,
1201 DispatchPlatform::Unix,
1202 )
1203 .ok_or_else(|| "expected dispatch outcome".to_owned())?;
1204 assert_eq!(outcome.exit_code, 1);
1205 assert!(handler.borrow().calls.is_empty());
1206 Ok(())
1207 }
1208
1209 #[test]
1210 fn dispatch_remove_no_match_exits_one() -> Result<(), String> {
1211 let handler = Rc::new(RefCell::new(FakeHandler {
1212 remove_results: vec![Ok(false)],
1213 trusted: true,
1214 ..FakeHandler::default()
1215 }));
1216 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1217 let outcome = handle_package_command(
1218 &args(&["remove", "npm:foo"]),
1219 &handler,
1220 &out,
1221 DispatchPlatform::Unix,
1222 )
1223 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1224 assert_eq!(outcome.exit_code, 1);
1225 assert_eq!(
1226 out.borrow().error[0],
1227 "No matching package found for npm:foo"
1228 );
1229 Ok(())
1230 }
1231
1232 #[test]
1233 fn dispatch_remove_success() -> Result<(), String> {
1234 let handler = Rc::new(RefCell::new(FakeHandler {
1235 remove_results: vec![Ok(true)],
1236 trusted: true,
1237 ..FakeHandler::default()
1238 }));
1239 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1240 let outcome = handle_package_command(
1241 &args(&["uninstall", "npm:foo"]),
1242 &handler,
1243 &out,
1244 DispatchPlatform::Unix,
1245 )
1246 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1247 assert_eq!(outcome.exit_code, 0);
1248 assert_eq!(out.borrow().success, vec!["Removed npm:foo"]);
1249 Ok(())
1250 }
1251
1252 #[test]
1253 fn dispatch_missing_source_exits_one() -> Result<(), String> {
1254 let handler = Rc::new(RefCell::new(FakeHandler::default()));
1255 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1256 let outcome =
1257 handle_package_command(&args(&["install"]), &handler, &out, DispatchPlatform::Unix)
1258 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1259 assert_eq!(outcome.exit_code, 1);
1260 assert!(out.borrow().error[0].contains("Missing install source"));
1261 Ok(())
1262 }
1263
1264 #[test]
1265 fn dispatch_list_empty_prints_dim_notice() -> Result<(), String> {
1266 let handler = Rc::new(RefCell::new(FakeHandler {
1267 list_result: Ok(Vec::new()),
1268 ..FakeHandler::default()
1269 }));
1270 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1271 let outcome =
1272 handle_package_command(&args(&["list"]), &handler, &out, DispatchPlatform::Unix)
1273 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1274 assert_eq!(outcome.exit_code, 0);
1275 assert_eq!(out.borrow().status_dim, vec!["No packages installed."]);
1276 Ok(())
1277 }
1278
1279 #[test]
1280 fn dispatch_list_with_packages() -> Result<(), String> {
1281 let handler = Rc::new(RefCell::new(FakeHandler {
1282 list_result: Ok(vec![
1283 ListedPackage {
1284 display: "npm:a".to_owned(),
1285 installed_path: Some("/path/a".to_owned()),
1286 scope: ListedScope::User,
1287 },
1288 ListedPackage {
1289 display: "git:b".to_owned(),
1290 installed_path: None,
1291 scope: ListedScope::Project,
1292 },
1293 ]),
1294 ..FakeHandler::default()
1295 }));
1296 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1297 let outcome =
1298 handle_package_command(&args(&["list"]), &handler, &out, DispatchPlatform::Unix)
1299 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1300 assert_eq!(outcome.exit_code, 0);
1301 let captured = out.borrow();
1302 assert!(captured.status.iter().any(|s| s == "User packages:"));
1303 assert!(captured.status.iter().any(|s| s == "Project packages:"));
1304 assert!(captured.status.iter().any(|s| s == " npm:a"));
1305 assert!(captured.status_dim.iter().any(|s| s == " /path/a"));
1306 Ok(())
1307 }
1308
1309 #[test]
1310 fn dispatch_update_models_routes_to_refresh() -> Result<(), String> {
1311 let handler = Rc::new(RefCell::new(FakeHandler {
1312 refresh_result: Ok(()),
1313 ..FakeHandler::default()
1314 }));
1315 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1316 let outcome = handle_package_command(
1317 &args(&["update", "--models"]),
1318 &handler,
1319 &out,
1320 DispatchPlatform::Unix,
1321 )
1322 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1323 assert_eq!(outcome.exit_code, 0);
1324 assert_eq!(handler.borrow().calls, vec!["refresh"]);
1325 Ok(())
1326 }
1327
1328 #[test]
1329 fn dispatch_update_models_error_propagates() -> Result<(), String> {
1330 let handler = Rc::new(RefCell::new(FakeHandler {
1331 refresh_result: Err("boom".to_owned()),
1332 ..FakeHandler::default()
1333 }));
1334 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1335 let outcome = handle_package_command(
1336 &args(&["update", "--models"]),
1337 &handler,
1338 &out,
1339 DispatchPlatform::Unix,
1340 )
1341 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1342 assert_eq!(outcome.exit_code, 1);
1343 assert_eq!(out.borrow().error[0], "Error: boom");
1344 Ok(())
1345 }
1346
1347 #[test]
1348 fn dispatch_update_self_already_latest_is_success() -> Result<(), String> {
1349 let handler = Rc::new(RefCell::new(FakeHandler {
1350 update_self_result: Ok(false),
1351 ..FakeHandler::default()
1352 }));
1353 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1354 let outcome =
1355 handle_package_command(&args(&["update"]), &handler, &out, DispatchPlatform::Unix)
1356 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1357 assert_eq!(outcome.exit_code, 0);
1358 assert!(
1361 out.borrow()
1362 .status_dim
1363 .iter()
1364 .any(|s| s.contains("Extensions are skipped"))
1365 );
1366 assert!(out.borrow().error.is_empty());
1367 Ok(())
1368 }
1369
1370 #[test]
1371 fn dispatch_update_self_success_windows_sets_drain_quirk() -> Result<(), String> {
1372 let handler = Rc::new(RefCell::new(FakeHandler {
1373 update_self_result: Ok(true),
1374 ..FakeHandler::default()
1375 }));
1376 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1377 let outcome = handle_package_command(
1378 &args(&["update"]),
1379 &handler,
1380 &out,
1381 DispatchPlatform::Windows,
1382 )
1383 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1384 assert_eq!(outcome.exit_code, 0);
1385 assert!(outcome.drain_quirk);
1386 Ok(())
1387 }
1388
1389 #[test]
1390 fn dispatch_update_self_success_unix_no_quirk() -> Result<(), String> {
1391 let handler = Rc::new(RefCell::new(FakeHandler {
1392 update_self_result: Ok(true),
1393 ..FakeHandler::default()
1394 }));
1395 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1396 let outcome =
1397 handle_package_command(&args(&["update"]), &handler, &out, DispatchPlatform::Unix)
1398 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1399 assert_eq!(outcome.exit_code, 0);
1400 assert!(!outcome.drain_quirk);
1401 Ok(())
1402 }
1403
1404 #[test]
1405 fn dispatch_update_all_runs_extensions_then_self() -> Result<(), String> {
1406 let handler = Rc::new(RefCell::new(FakeHandler {
1407 update_ext_result: Ok(()),
1408 update_self_result: Ok(true),
1409 ..FakeHandler::default()
1410 }));
1411 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1412 let outcome = handle_package_command(
1413 &args(&["update", "--all"]),
1414 &handler,
1415 &out,
1416 DispatchPlatform::Unix,
1417 )
1418 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1419 assert_eq!(outcome.exit_code, 0);
1420 let calls = &handler.borrow().calls;
1421 assert!(calls.iter().any(|c| c.starts_with("update_ext:")));
1422 assert!(calls.iter().any(|c| c.starts_with("update_self:")));
1423 assert_eq!(out.borrow().success, vec!["Updated packages"]);
1424 Ok(())
1425 }
1426
1427 #[test]
1428 fn dispatch_update_extensions_filtered_source() -> Result<(), String> {
1429 let handler = Rc::new(RefCell::new(FakeHandler {
1430 update_ext_result: Ok(()),
1431 ..FakeHandler::default()
1432 }));
1433 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1434 let outcome = handle_package_command(
1435 &args(&["update", "--extension", "npm:x"]),
1436 &handler,
1437 &out,
1438 DispatchPlatform::Unix,
1439 )
1440 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1441 assert_eq!(outcome.exit_code, 0);
1442 assert_eq!(handler.borrow().calls, vec!["update_ext:npm:x"]);
1443 assert_eq!(out.borrow().success, vec!["Updated npm:x"]);
1444 Ok(())
1445 }
1446
1447 #[test]
1448 fn dispatch_invalid_option_exits_one() -> Result<(), String> {
1449 let handler = Rc::new(RefCell::new(FakeHandler::default()));
1450 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1451 let outcome = handle_package_command(
1452 &args(&["list", "--bogus"]),
1453 &handler,
1454 &out,
1455 DispatchPlatform::Unix,
1456 )
1457 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1458 assert_eq!(outcome.exit_code, 1);
1459 assert!(out.borrow().error[0].contains("Unknown option --bogus"));
1460 assert!(handler.borrow().calls.is_empty());
1461 Ok(())
1462 }
1463
1464 #[test]
1465 fn dispatch_conflicting_options_exits_one() -> Result<(), String> {
1466 let handler = Rc::new(RefCell::new(FakeHandler::default()));
1467 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1468 let outcome = handle_package_command(
1469 &args(&["update", "--all", "--self"]),
1470 &handler,
1471 &out,
1472 DispatchPlatform::Unix,
1473 )
1474 .ok_or_else(|| "expected dispatch to succeed".to_owned())?;
1475 assert_eq!(outcome.exit_code, 1);
1476 assert!(out.borrow().error[0].contains("--all cannot be combined"));
1477 Ok(())
1478 }
1479
1480 #[test]
1481 fn dispatch_non_package_command_returns_none() {
1482 let handler = Rc::new(RefCell::new(FakeHandler::default()));
1483 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1484 assert!(
1485 handle_package_command(&args(&["--help"]), &handler, &out, DispatchPlatform::Unix,)
1486 .is_none()
1487 );
1488 }
1489
1490 #[test]
1491 fn config_command_dispatch_help() -> Result<(), String> {
1492 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1493 let outcome = handle_config_command(&args(&["config", "--help"]), &out, true, true)
1494 .ok_or_else(|| "expected config help dispatch outcome".to_owned())?;
1495 assert_eq!(outcome.exit_code, 0);
1496 assert!(out.borrow().status[0].contains("Usage:"));
1497 Ok(())
1498 }
1499
1500 #[test]
1501 fn config_command_dispatch_non_tty_reports_error() -> Result<(), String> {
1502 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1503 let outcome = handle_config_command(&args(&["config"]), &out, false, true)
1504 .ok_or_else(|| "expected non-TTY config dispatch outcome".to_owned())?;
1505 assert_eq!(outcome.exit_code, 1);
1506 assert!(out.borrow().error[0].contains("interactive terminal"));
1507 Ok(())
1508 }
1509
1510 #[test]
1511 fn config_command_returns_none_for_other_subcommands() {
1512 let out = Rc::new(RefCell::new(CapturedOutput::default()));
1513 assert!(handle_config_command(&args(&["install"]), &out, true, true).is_none());
1514 }
1515}