content: // Copyright 2013 The Flutter Authors. All rights reserved.
  // Use of this source code is governed by a BSD-style license that can be
  // found in the LICENSE file.
  
  import 'dart:async';
  
  import 'package:colorize/colorize.dart';
  import 'package:file/file.dart';
  import 'package:git/git.dart';
  import 'package:path/path.dart' as p;
  import 'package:platform/platform.dart';
  import 'package:pub_semver/pub_semver.dart';
  
  import 'core.dart';
  import 'plugin_command.dart';
  import 'process_runner.dart';
  import 'repository_package.dart';
  
  /// Enumeration options for package looping commands.
  enum PackageLoopingType {
    /// Only enumerates the top level packages, without including any of their
    /// subpackages.
    topLevelOnly,
  
    /// Enumerates the top level packages and any example packages they contain.
    includeExamples,
  
    /// Enumerates all packages recursively, including both example and
    /// non-example subpackages.
    includeAllSubpackages,
  }
  
  /// Possible outcomes of a command run for a package.
  enum RunState {
    /// The command succeeded for the package.
    succeeded,
  
    /// The command was skipped for the package.
    skipped,
  
    /// The command was skipped for the package because it was explicitly excluded
    /// in the command arguments.
    excluded,
  
    /// The command failed for the package.
    failed,
  }
  
  /// The result of a [runForPackage] call.
  class PackageResult {
    /// A successful result.
    PackageResult.success() : this._(RunState.succeeded);
  
    /// A run that was skipped as explained in [reason].
    PackageResult.skip(String reason)
        : this._(RunState.skipped, <String>[reason]);
  
    /// A run that was excluded by the command invocation.
    PackageResult.exclude() : this._(RunState.excluded);
  
    /// A run that failed.
    ///
    /// If [errors] are provided, they will be listed in the summary, otherwise
    /// the summary will simply show that the package failed.
    PackageResult.fail([List<String> errors = const <String>[]])
        : this._(RunState.failed, errors);
  
    const PackageResult._(this.state, [this.details = const <String>[]]);
  
    /// The state the package run completed with.
    final RunState state;
  
    /// Information about the result:
    /// - For `succeeded`, this is empty.
    /// - For `skipped`, it contains a single entry describing why the run was
    ///   skipped.
    /// - For `failed`, it contains zero or more specific error details to be
    ///   shown in the summary.
    final List<String> details;
  }
  
  /// An abstract base class for a command that iterates over a set of packages
  /// controlled by a standard set of flags, running some actions on each package,
  /// and collecting and reporting the success/failure of those actions.
  abstract class PackageLoopingCommand extends PluginCommand {
    /// Creates a command to operate on [packagesDir] with the given environment.
    PackageLoopingCommand(
      Directory packagesDir, {
      ProcessRunner processRunner = const ProcessRunner(),
      Platform platform = const LocalPlatform(),
      GitDir? gitDir,
    }) : super(packagesDir,
              processRunner: processRunner, platform: platform, gitDir: gitDir) {
      argParser.addOption(
        _skipByFlutterVersionArg,
        help: 'Skip any packages that require a Flutter version newer than '
            'the provided version.',
      );
      argParser.addOption(
        _skipByDartVersionArg,
        help: 'Skip any packages that require a Dart version newer than '
            'the provided version.',
      );
    }
  
    static const String _skipByFlutterVersionArg =
        'skip-if-not-supporting-flutter-version';
    static const String _skipByDartVersionArg =
        'skip-if-not-supporting-dart-version';
  
    /// Packages that had at least one [logWarning] call.
    final Set<PackageEnumerationEntry> _packagesWithWarnings =
        <PackageEnumerationEntry>{};
  
    /// Number of warnings that happened outside of a [runForPackage] call.
    int _otherWarningCount = 0;
  
    /// The package currently being run by [runForPackage].
    PackageEnumerationEntry? _currentPackageEntry;
  
    /// Called during [run] before any calls to [runForPackage]. This provides an
    /// opportunity to fail early if the command can't be run (e.g., because the
    /// arguments are invalid), and to set up any run-level state.
    Future<void> initializeRun() async {}
  
    /// Returns the packages to process. By default, this returns the packages
    /// defined by the standard tooling flags and the [inculdeSubpackages] option,
    /// but can be overridden for custom package enumeration.
    ///
    /// Note: Consistent behavior across commands whenever possibel is a goal for
    /// this tool, so this should be overridden only in rare cases.
    Stream<PackageEnumerationEntry> getPackagesToProcess() async* {
      switch (packageLoopingType) {
        case PackageLoopingType.topLevelOnly:
          yield* getTargetPackages(filterExcluded: false);
          break;
        case PackageLoopingType.includeExamples:
          await for (final PackageEnumerationEntry packageEntry
              in getTargetPackages(filterExcluded: false)) {
            yield packageEntry;
            yield* Stream<PackageEnumerationEntry>.fromIterable(packageEntry
                .package
                .getExamples()
                .map((RepositoryPackage package) => PackageEnumerationEntry(
                    package,
                    excluded: packageEntry.excluded)));
          }
          break;
        case PackageLoopingType.includeAllSubpackages:
          yield* getTargetPackagesAndSubpackages(filterExcluded: false);
          break;
      }
    }
  
    /// Runs the command for [package], returning a list of errors.
    ///
    /// Errors may either be an empty string if there is no context that should
    /// be included in the final error summary (e.g., a command that only has a
    /// single failure mode), or strings that should be listed for that package
    /// in the final summary. An empty list indicates success.
    Future<PackageResult> runForPackage(RepositoryPackage package);
  
    /// Called during [run] after all calls to [runForPackage]. This provides an
    /// opportunity to do any cleanup of run-level state.
    Future<void> completeRun() async {}
  
    /// If [captureOutput], this is called just before exiting with all captured
    /// [output].
    Future<void> handleCapturedOutput(List<String> output) async {}
  
    /// Whether or not the output (if any) of [runForPackage] is long, or short.
    ///
    /// This changes the logging that happens at the start of each package's
    /// run; long output gets a banner-style message to make it easier to find,
    /// while short output gets a single-line entry.
    ///
    /// When this is false, runForPackage output should be indented if possible,
    /// to make the output structure easier to follow.
    bool get hasLongOutput => true;
  
    /// Whether to loop over top-level packages only, or some or all of their
    /// sub-packages as well.
    PackageLoopingType get packageLoopingType => PackageLoopingType.topLevelOnly;
  
    /// The text to output at the start when reporting one or more failures.
    /// This will be followed by a list of packages that reported errors, with
    /// the per-package details if any.
    ///
    /// This only needs to be overridden if the summary should provide extra
    /// context.
    String get failureListHeader => 'The following packages had errors:';
  
    /// The text to output at the end when reporting one or more failures. This
    /// will be printed immediately after the a list of packages that reported
    /// errors.
    ///
    /// This only needs to be overridden if the summary should provide extra
    /// context.
    String get failureListFooter => 'See above for full details.';
  
    /// The summary string used for a successful run in the final overview output.
    String get successSummaryMessage => 'ran';
  
    /// If true, all printing (including the summary) will be redirected to a
    /// buffer, and provided in a call to [handleCapturedOutput] at the end of
    /// the run.
    ///
    /// Capturing output will disable any colorizing of output from this base
    /// class.
    bool get captureOutput => false;
  
    // ----------------------------------------
  
    /// Logs that a warning occurred, and prints `warningMessage` in yellow.
    ///
    /// Warnings are not surfaced in CI summaries, so this is only useful for
    /// highlighting something when someone is already looking though the log
    /// messages. DO NOT RELY on someone noticing a warning; instead, use it for
    /// things that might be useful to someone debugging an unexpected result.
    void logWarning(String warningMessage) {
      _printColorized(warningMessage, Styles.YELLOW);
      if (_currentPackageEntry != null) {
        _packagesWithWarnings.add(_currentPackageEntry!);
      } else {
        ++_otherWarningCount;
      }
    }
  
    /// Returns the relative path from [from] to [entity] in Posix style.
    ///
    /// This should be used when, for example, printing package-relative paths in
    /// status or error messages.
    String getRelativePosixPath(
      FileSystemEntity entity, {
      required Directory from,
    }) =>
        p.posix.joinAll(path.split(path.relative(entity.path, from: from.path)));
  
    /// The suggested indentation for printed output.
    String get indentation => hasLongOutput ? '' : '  ';
  
    // ----------------------------------------
  
    @override
    Future<void> run() async {
      bool succeeded;
      if (captureOutput) {
        final List<String> output = <String>[];
        final ZoneSpecification logSwitchSpecification = ZoneSpecification(
            print: (Zone self, ZoneDelegate parent, Zone zone, String message) {
          output.add(message);
        });
        succeeded = await runZoned<Future<bool>>(_runInternal,
            zoneSpecification: logSwitchSpecification);
        await handleCapturedOutput(output);
      } else {
        succeeded = await _runInternal();
      }
  
      if (!succeeded) {
        throw ToolExit(exitCommandFoundErrors);
      }
    }
  
    Future<bool> _runInternal() async {
      _packagesWithWarnings.clear();
      _otherWarningCount = 0;
      _currentPackageEntry = null;
  
      final String minFlutterVersionArg = getStringArg(_skipByFlutterVersionArg);
      final Version? minFlutterVersion = minFlutterVersionArg.isEmpty
          ? null
          : Version.parse(minFlutterVersionArg);
      final String minDartVersionArg = getStringArg(_skipByDartVersionArg);
      final Version? minDartVersion =
          minDartVersionArg.isEmpty ? null : Version.parse(minDartVersionArg);
  
      final DateTime runStart = DateTime.now();
  
      await initializeRun();
  
      final List<PackageEnumerationEntry> targetPackages =
          await getPackagesToProcess().toList();
  
      final Map<PackageEnumerationEntry, PackageResult> results =
          <PackageEnumerationEntry, PackageResult>{};
      for (final PackageEnumerationEntry entry in targetPackages) {
        final DateTime packageStart = DateTime.now();
        _currentPackageEntry = entry;
        _printPackageHeading(entry, startTime: runStart);
  
        // Command implementations should never see excluded packages; they are
        // included at this level only for logging.
        if (entry.excluded) {
          results[entry] = PackageResult.exclude();
          continue;
        }
  
        PackageResult result;
        try {
          result = await _runForPackageIfSupported(entry.package,
              minFlutterVersion: minFlutterVersion,
              minDartVersion: minDartVersion);
        } catch (e, stack) {
          printError(e.toString());
          printError(stack.toString());
          result = PackageResult.fail(<String>['Unhandled exception']);
        }
        if (result.state == RunState.skipped) {
          _printColorized('${indentation}SKIPPING: ${result.details.first}',
              Styles.DARK_GRAY);
        }
        results[entry] = result;
  
        // Only log an elapsed time for long output; for short output, comparing
        // the relative timestamps of successive entries should be trivial.
        if (shouldLogTiming && hasLongOutput) {
          final Duration elapsedTime = DateTime.now().difference(packageStart);
          _printColorized(
              '\n[${entry.package.displayName} completed in '
              '${elapsedTime.inMinutes}m ${elapsedTime.inSeconds % 60}s]',
              Styles.DARK_GRAY);
        }
      }
      _currentPackageEntry = null;
  
      completeRun();
  
      print('\n');
      // If there were any errors reported, summarize them and exit.
      if (results.values
          .any((PackageResult result) => result.state == RunState.failed)) {
        _printFailureSummary(targetPackages, results);
        return false;
      }
  
      // Otherwise, print a summary of what ran for ease of auditing that all the
      // expected tests ran.
      _printRunSummary(targetPackages, results);
  
      print('\n');
      _printSuccess('No issues found!');
      return true;
    }
  
    /// Returns the result of running [runForPackage] if the package is supported
    /// by any run constraints, or a skip result if it is not.
    Future<PackageResult> _runForPackageIfSupported(
      RepositoryPackage package, {
      Version? minFlutterVersion,
      Version? minDartVersion,
    }) async {
      if (minFlutterVersion != null) {
        final Pubspec pubspec = package.parsePubspec();
        final VersionConstraint? flutterConstraint =
            pubspec.environment?['flutter'];
        if (flutterConstraint != null &&
            !flutterConstraint.allows(minFlutterVersion)) {
          return PackageResult.skip(
              'Does not support Flutter ${minFlutterVersion.toString()}');
        }
      }
  
      if (minDartVersion != null) {
        final Pubspec pubspec = package.parsePubspec();
        final VersionConstraint? dartConstraint = pubspec.environment?['sdk'];
        if (dartConstraint != null && !dartConstraint.allows(minDartVersion)) {
          return PackageResult.skip(
              'Does not support Dart ${minDartVersion.toString()}');
        }
      }
  
      return await runForPackage(package);
    }
  
    void _printSuccess(String message) {
      captureOutput ? print(message) : printSuccess(message);
    }
  
    void _printError(String message) {
      captureOutput ? print(message) : printError(message);
    }
  
    /// Prints the status message indicating that the command is being run for
    /// [package].
    ///
    /// Something is always printed to make it easier to distinguish between
    /// a command running for a package and producing no output, and a command
    /// not having been run for a package.
    void _printPackageHeading(PackageEnumerationEntry entry,
        {required DateTime startTime}) {
      final String packageDisplayName = entry.package.displayName;
      String heading = entry.excluded
          ? 'Not running for $packageDisplayName; excluded'
          : 'Running for $packageDisplayName';
  
      if (shouldLogTiming) {
        final Duration relativeTime = DateTime.now().difference(startTime);
        final String timeString = _formatDurationAsRelativeTime(relativeTime);
        heading =
            hasLongOutput ? '$heading [@$timeString]' : '[$timeString] $heading';
      }
  
      if (hasLongOutput) {
        heading = '''
  
  ============================================================
  || $heading
  ============================================================
  ''';
      } else if (!entry.excluded) {
        heading = '$heading...';
      }
      _printColorized(heading, entry.excluded ? Styles.DARK_GRAY : Styles.CYAN);
    }
  
    /// Prints a summary of packges run, packages skipped, and warnings.
    void _printRunSummary(List<PackageEnumerationEntry> packages,
        Map<PackageEnumerationEntry, PackageResult> results) {
      final Set<PackageEnumerationEntry> skippedPackages = results.entries
          .where((MapEntry<PackageEnumerationEntry, PackageResult> entry) =>
              entry.value.state == RunState.skipped)
          .map((MapEntry<PackageEnumerationEntry, PackageResult> entry) =>
              entry.key)
          .toSet();
      final int skipCount = skippedPackages.length +
          packages
              .where((PackageEnumerationEntry package) => package.excluded)
              .length;
      // Split the warnings into those from packages that ran, and those that
      // were skipped.
      final Set<PackageEnumerationEntry> _skippedPackagesWithWarnings =
          _packagesWithWarnings.intersection(skippedPackages);
      final int skippedWarningCount = _skippedPackagesWithWarnings.length;
      final int runWarningCount =
          _packagesWithWarnings.length - skippedWarningCount;
  
      final String runWarningSummary =
          runWarningCount > 0 ? ' ($runWarningCount with warnings)' : '';
      final String skippedWarningSummary =
          runWarningCount > 0 ? ' ($skippedWarningCount with warnings)' : '';
      print('------------------------------------------------------------');
      if (hasLongOutput) {
        _printPerPackageRunOverview(packages, skipped: skippedPackages);
      }
      print(
          'Ran for ${packages.length - skipCount} package(s)$runWarningSummary');
      if (skipCount > 0) {
        print('Skipped $skipCount package(s)$skippedWarningSummary');
      }
      if (_otherWarningCount > 0) {
        print('$_otherWarningCount warnings not associated with a package');
      }
    }
  
    /// Prints a one-line-per-package overview of the run results for each
    /// package.
    void _printPerPackageRunOverview(
        List<PackageEnumerationEntry> packageEnumeration,
        {required Set<PackageEnumerationEntry> skipped}) {
      print('Run overview:');
      for (final PackageEnumerationEntry entry in packageEnumeration) {
        final bool hadWarning = _packagesWithWarnings.contains(entry);
        Styles style;
        String summary;
        if (entry.excluded) {
          summary = 'excluded';
          style = Styles.DARK_GRAY;
        } else if (skipped.contains(entry)) {
          summary = 'skipped';
          style = hadWarning ? Styles.LIGHT_YELLOW : Styles.DARK_GRAY;
        } else {
          summary = successSummaryMessage;
          style = hadWarning ? Styles.YELLOW : Styles.GREEN;
        }
        if (hadWarning) {
          summary += ' (with warning)';
        }
  
        if (!captureOutput) {
          summary = (Colorize(summary)..apply(style)).toString();
        }
        print('  ${entry.package.displayName} - $summary');
      }
      print('');
    }
  
    /// Prints a summary of all of the failures from [results].
    void _printFailureSummary(List<PackageEnumerationEntry> packageEnumeration,
        Map<PackageEnumerationEntry, PackageResult> results) {
      const String indentation = '  ';
      _printError(failureListHeader);
      for (final PackageEnumerationEntry entry in packageEnumeration) {
        final PackageResult result = results[entry]!;
        if (result.state == RunState.failed) {
          final String errorIndentation = indentation * 2;
          String errorDetails = '';
          if (result.details.isNotEmpty) {
            errorDetails =
                ':\n$errorIndentation${result.details.join('\n$errorIndentation')}';
          }
          _printError('$indentation${entry.package.displayName}$errorDetails');
        }
      }
      _printError(failureListFooter);
    }
  
    /// Prints [message] in [color] unless [captureOutput] is set, in which case
    /// it is printed without color.
    void _printColorized(String message, Styles color) {
      if (captureOutput) {
        print(message);
      } else {
        print(Colorize(message)..apply(color));
      }
    }
  
    /// Returns a duration [d] formatted as minutes:seconds. Does not use hours,
    /// since time logging is primarily intended for CI, where durations should
    /// always be less than an hour.
    String _formatDurationAsRelativeTime(Duration d) =>
        '${d.inMinutes}:${(d.inSeconds % 60).toString().padLeft(2, '0')}';
  }
  