Skip to main content

pi/cli/
package_manager_cli.rs

1//! `pi install`/`remove`/`update`/`list`/`config` subcommand router.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/package-manager-cli.ts` into
4//! a thin parser + dispatcher. The parser is pure and infallible; the
5//! dispatcher maps parsed options onto exit codes and verbatim status/error
6//! strings, driving all side effects through an injected [`PackageHandler`]
7//! trait so tests never spawn real subprocesses or touch the network.
8//!
9//! # Exit code map (matches the TypeScript reference)
10//!
11//! | path                          | exit | notes                                  |
12//! |-------------------------------|------|----------------------------------------|
13//! | `--help`                      | 0    | prints usage                           |
14//! | unknown option / missing val  | 1    | verbatim error + usage hint            |
15//! | install/remove without source | 1    | `Missing {cmd} source.` + usage        |
16//! | untrusted local install/remove| 1    | `Project is not trusted. …`            |
17//! | install success               | 0    | `Installed {source}`                   |
18//! | remove no-match               | 1    | `No matching package found for {src}`  |
19//! | remove success                | 0    | `Removed {source}`                     |
20//! | list (any)                    | 0    | formatted list                         |
21//! | update models error           | 1    |                                        |
22//! | update extensions error       | 1    |                                        |
23//! | update self error             | 1    |                                        |
24//! | update self success           | 0    | win32: `drain_quirk` flag set          |
25//! | update self already-latest    | 0    | no-op                                  |
26
27use crate::core::config::{APP_NAME, CONFIG_DIR_NAME};
28
29/// Known subcommand discriminant.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum PackageCommand {
32    /// `install`.
33    Install,
34    /// `remove` (also `uninstall`).
35    Remove,
36    /// `update`.
37    Update,
38    /// `list`.
39    List,
40}
41
42impl PackageCommand {
43    /// Literal subcommand name.
44    #[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/// Update target resolution (the TS `UpdateTarget` union).
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub enum UpdateTarget {
58    /// `pi` + extensions.
59    All,
60    /// `pi` self only.
61    Self_,
62    /// Installed packages; optional filter `source`.
63    Extensions {
64        /// Optional `--extension <source>` filter.
65        source: Option<String>,
66    },
67    /// Refresh model catalogs.
68    Models,
69}
70
71/// Parsed `pi <subcommand> …` options.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct PackageCommandOptions {
74    /// Resolved subcommand.
75    pub command: PackageCommand,
76    /// First positional source argument.
77    pub source: Option<String>,
78    /// Resolved update target (`update` only).
79    pub update_target: Option<UpdateTarget>,
80    /// Whether the `Extensions are skipped…` note should print.
81    pub show_extensions_skipped_note: ExtensionsSkippedNotice,
82    /// `-l`/`--local`.
83    pub local: bool,
84    /// `--force`.
85    pub force: bool,
86    /// `--approve`/`-a` (true) or `--no-approve`/`-na` (false).
87    pub project_trust_override: Option<bool>,
88    /// `-h`/`--help`.
89    pub help: bool,
90    /// First invalid option encountered (verbatim arg).
91    pub invalid_option: Option<String>,
92    /// First unexpected positional after `source`.
93    pub invalid_argument: Option<String>,
94    /// First option missing its required value.
95    pub missing_option_value: Option<String>,
96    /// First conflict message computed during parse.
97    pub conflicting_options: Option<String>,
98}
99
100/// Outcome of dispatching one subcommand.
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct PackageOutcome {
103    /// Process exit code to surface.
104    pub exit_code: u8,
105    /// Win32 `pi update` success must drain naturally (Node assert quirk).
106    /// When true the caller returns without forcing a process exit.
107    pub drain_quirk: bool,
108}
109
110impl PackageOutcome {
111    /// Success outcome (exit 0).
112    #[must_use]
113    pub const fn success() -> Self {
114        Self {
115            exit_code: 0,
116            drain_quirk: false,
117        }
118    }
119
120    /// Failure outcome with a specific code.
121    #[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/// One configured package row reported by [`PackageHandler::list`].
131#[derive(Clone, Debug, Eq, PartialEq)]
132pub struct ListedPackage {
133    /// Display source string (may include `(filtered)`).
134    pub display: String,
135    /// Absolute installed path when known.
136    pub installed_path: Option<String>,
137    /// Scope: `user` or `project`.
138    pub scope: ListedScope,
139}
140
141/// Scope of a configured package.
142#[derive(Clone, Copy, Debug, Eq, PartialEq)]
143pub enum ListedScope {
144    /// Global agent directory.
145    User,
146    /// Project-local.
147    Project,
148}
149
150/// Side-effect surface injected by the caller.
151///
152/// Each method maps to one subcommand operation. Implementations may run real
153/// subprocesses (`core::package_manager::PackageManager`) or fake the work in
154/// tests. Strings returned by `Err` are surfaced verbatim prefixed with
155/// `Error: `.
156pub trait PackageHandler {
157    /// Apply invocation-scoped offline mode before package-command dispatch.
158    fn set_offline(&self, _offline: bool) {}
159
160    /// Install `source` in the requested scope.
161    ///
162    /// # Errors
163    /// Implementation-defined; the error string is shown verbatim.
164    fn install(&self, source: &str, local: bool) -> Result<(), String>;
165
166    /// Remove `source`; returns whether anything was removed.
167    ///
168    /// # Errors
169    /// Implementation-defined.
170    fn remove(&self, source: &str, local: bool) -> Result<bool, String>;
171
172    /// List configured packages split by scope.
173    ///
174    /// # Errors
175    /// Implementation-defined.
176    fn list(&self) -> Result<Vec<ListedPackage>, String>;
177
178    /// Supply the invocation-scoped project trust override before preflight.
179    fn set_project_trust_override(&self, _trust_override: Option<bool>) {}
180
181    /// Whether the project is currently trusted (for the local-write gate).
182    fn is_project_trusted(&self) -> bool;
183
184    /// Refresh model catalogs (`update --models`).
185    ///
186    /// # Errors
187    /// Implementation-defined.
188    fn refresh_models(&self) -> Result<(), String>;
189
190    /// Update installed extensions, optionally filtered by `source`.
191    ///
192    /// # Errors
193    /// Implementation-defined.
194    fn update_extensions(&self, source: Option<&str>) -> Result<(), String>;
195
196    /// Self-update pi; `force` reinstalls even when on the latest version.
197    ///
198    /// Returns `Ok(false)` when the engine reports the current install is
199    /// already latest and no reinstall was requested.
200    ///
201    /// # Errors
202    /// Implementation-defined.
203    fn update_self(&self, force: bool) -> Result<bool, String>;
204}
205
206/// Output sink for status/error lines. Implementations capture into a buffer
207/// (tests) or write to stdout/stderr (production, via `ProductOutput`).
208pub trait PackageOutput {
209    /// Write a status line (stdout in TS).
210    fn status(&self, line: &str);
211    /// Write a dimmed status line (stdout, chalk.dim).
212    fn status_dim(&self, line: &str);
213    /// Write a success line (stdout, chalk.green).
214    fn success(&self, line: &str);
215    /// Write an error line (stderr, chalk.red).
216    fn error(&self, line: &str);
217}
218
219/// Whether `argv[0]` is a recognized package subcommand.
220#[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/// Whether `argv[0]` is the `config` command.
232#[must_use]
233pub fn is_config_command(argv0: &str) -> bool {
234    argv0 == "config"
235}
236
237/// Whether the default self-update should explain that extensions were skipped.
238#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
239pub enum ExtensionsSkippedNotice {
240    /// Do not print the note.
241    #[default]
242    Hidden,
243    /// Print the note.
244    Show,
245}
246
247impl ExtensionsSkippedNotice {
248    const fn should_print(self) -> bool {
249        matches!(self, Self::Show)
250    }
251}
252
253/// Parse `pi <subcommand> [rest…]`.
254///
255/// Returns `None` when the first token is not a recognized package subcommand.
256/// Otherwise returns the fully resolved options including conflict checks.
257/// Mirrors `parsePackageCommand` in `package-manager-cli.ts:189-387`.
258#[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/// Usage string for one subcommand.
510#[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/// Usage string for the `config` command.
527#[must_use]
528pub fn config_command_usage() -> String {
529    format!("{APP_NAME} config [-l] [--approve|--no-approve]")
530}
531
532/// Render the per-subcommand help block (`printPackageCommandHelp`).
533#[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/// Render the `config` help block (`printConfigCommandHelp`).
553#[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
561/// Decide whether the dispatch must enforce project trust before writing.
562fn writes_project_package_config(command: PackageCommand, local: bool) -> bool {
563    matches!(command, PackageCommand::Install | PackageCommand::Remove) && local
564}
565
566/// Route `pi config …` if recognized. Returns `None` when `argv[0]` is not
567/// `config`.
568///
569/// `--help` prints the config usage block and exits 0. Otherwise, `config`
570/// opens the resource-config TUI (interactive) or reports that the TUI
571/// requires a terminal (non-interactive), matching the reference gate at
572/// `package-manager-cli.ts:603`.
573pub 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    // The resource-config TUI is launched by the interactive mode dispatcher.
594    // The bootstrap routes `config` to interactive mode when TTY is available;
595    // if we reach here, the TUI runner was not injected.
596    out.error(&format!("{APP_NAME} config TUI runner not configured."));
597    Some(PackageOutcome::failure(1))
598}
599///
600/// Mirrors the verbatim status/error strings and exit-code mapping of
601/// `handlePackageCommand` (`package-manager-cli.ts:676-887`).
602pub 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/// Platform-specific dispatch behavior.
839#[derive(Clone, Copy, Debug, Eq, PartialEq)]
840pub enum DispatchPlatform {
841    /// Linux/macOS.
842    Unix,
843    /// Windows: successful `pi update` returns without forcing process exit.
844    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    /// In-memory output sink capturing lines by kind.
858    #[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    /// Handler that records calls and returns configured results.
882    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        // Bare `update` prints the extensions-skipped dim note before self-update
1359        // (package-manager-cli.ts:820-823); already-latest still exits 0.
1360        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}