diff --git a/lib/src/commands/packages/commands/check/commands/licenses.dart b/lib/src/commands/packages/commands/check/commands/licenses.dart index 8f345bbf6..ac0cd1fad 100644 --- a/lib/src/commands/packages/commands/check/commands/licenses.dart +++ b/lib/src/commands/packages/commands/check/commands/licenses.dart @@ -20,6 +20,7 @@ import 'package:package_config/package_config.dart' as package_config; import 'package:pana/src/license_detection/license_detector.dart' as detector; import 'package:path/path.dart' as path; import 'package:very_good_cli/src/pub_license/spdx_license.gen.dart'; +import 'package:very_good_cli/src/pubspec/pubspec.dart'; import 'package:very_good_cli/src/pubspec_lock/pubspec_lock.dart'; /// Overrides the [package_config.findPackageConfig] function for testing. @@ -35,6 +36,10 @@ Future Function(String, double)? detectLicenseOverride; @visibleForTesting const pubspecLockBasename = 'pubspec.lock'; +/// The basename of the pubspec file. +@visibleForTesting +const pubspecBasename = 'pubspec.yaml'; + /// The URI for the pub.dev license page for the given [packageName]. @visibleForTesting Uri pubLicenseUri(String packageName) => @@ -178,11 +183,48 @@ class PackagesCheckLicensesCommand extends Command { return ExitCode.noInput.code; } + // Check if this is a workspace root and collect dependencies accordingly + final pubspecFile = File(path.join(targetPath, pubspecBasename)); + final pubspec = _tryParsePubspec(pubspecFile); + + // Collect workspace dependencies if this is a workspace root + final workspaceDependencies = _collectWorkspaceDependencies( + pubspec: pubspec, + targetDirectory: targetDirectory, + dependencyTypes: dependencyTypes, + ); + final filteredDependencies = pubspecLock.packages.where((dependency) { if (!dependency.isPubHosted) return false; if (skippedPackages.contains(dependency.name)) return false; + // If we have workspace dependencies, use them for filtering direct deps + if (workspaceDependencies != null) { + // For direct-main and direct-dev, check against workspace dependencies + if (dependencyTypes.contains('direct-main') || + dependencyTypes.contains('direct-dev')) { + if (workspaceDependencies.contains(dependency.name)) { + return true; + } + } + + // For transitive and direct-overridden, still use pubspec.lock types + final dependencyType = dependency.type; + if (dependencyTypes.contains('transitive') && + dependencyType == PubspecLockPackageDependencyType.transitive) { + return true; + } + if (dependencyTypes.contains('direct-overridden') && + dependencyType == + PubspecLockPackageDependencyType.directOverridden) { + return true; + } + + return false; + } + + // Non-workspace: use the original filtering logic final dependencyType = dependency.type; return (dependencyTypes.contains('direct-main') && dependencyType == PubspecLockPackageDependencyType.directMain) || @@ -497,3 +539,69 @@ extension on List { return '${join(', ')} and $last'; } } + +/// Attempts to parse a [Pubspec] from a file. +/// +/// Returns `null` if the file doesn't exist or cannot be parsed. +Pubspec? _tryParsePubspec(File pubspecFile) { + if (!pubspecFile.existsSync()) return null; + try { + return Pubspec.fromFile(pubspecFile); + } on PubspecParseException catch (_) { + return null; + } +} + +/// Collects dependencies from a workspace. +/// +/// If [pubspec] is not a workspace root, returns `null`. +/// Otherwise, returns a set of dependency names collected from all workspace +/// members based on the requested [dependencyTypes]. +Set? _collectWorkspaceDependencies({ + required Pubspec? pubspec, + required Directory targetDirectory, + required List dependencyTypes, +}) { + if (pubspec == null || !pubspec.isWorkspaceRoot) return null; + + final dependencies = {}; + + // Collect dependencies from the root pubspec itself + if (dependencyTypes.contains('direct-main')) { + dependencies.addAll(pubspec.dependencies); + } + if (dependencyTypes.contains('direct-dev')) { + dependencies.addAll(pubspec.devDependencies); + } + + // Collect dependencies from workspace members + final members = pubspec.resolveWorkspaceMembers(targetDirectory); + for (final memberDirectory in members) { + final memberPubspecFile = File( + path.join(memberDirectory.path, pubspecBasename), + ); + final memberPubspec = _tryParsePubspec(memberPubspecFile); + if (memberPubspec == null) continue; + + if (dependencyTypes.contains('direct-main')) { + dependencies.addAll(memberPubspec.dependencies); + } + if (dependencyTypes.contains('direct-dev')) { + dependencies.addAll(memberPubspec.devDependencies); + } + + // Handle nested workspaces recursively + if (memberPubspec.isWorkspaceRoot) { + final nestedDeps = _collectWorkspaceDependencies( + pubspec: memberPubspec, + targetDirectory: memberDirectory, + dependencyTypes: dependencyTypes, + ); + if (nestedDeps != null) { + dependencies.addAll(nestedDeps); + } + } + } + + return dependencies; +} diff --git a/lib/src/pubspec/pubspec.dart b/lib/src/pubspec/pubspec.dart new file mode 100644 index 000000000..6a69608b9 --- /dev/null +++ b/lib/src/pubspec/pubspec.dart @@ -0,0 +1,206 @@ +/// A simple parser for pubspec.yaml files. +/// +/// This is used by the `packages check licenses` command to detect workspace +/// configurations and collect dependencies from workspace members. +library; + +import 'dart:collection'; +import 'dart:io'; + +import 'package:glob/glob.dart'; +import 'package:glob/list_local_fs.dart'; +import 'package:path/path.dart' as path; +import 'package:yaml/yaml.dart'; + +/// {@template PubspecParseException} +/// Thrown when a [Pubspec] fails to parse. +/// {@endtemplate} +class PubspecParseException implements Exception { + /// {@macro PubspecParseException} + const PubspecParseException([this.message]); + + /// The error message. + final String? message; + + @override + String toString() => message != null + ? 'PubspecParseException: $message' + : 'PubspecParseException'; +} + +/// {@template Pubspec} +/// A representation of a pubspec.yaml file. +/// {@endtemplate} +class Pubspec { + const Pubspec._({ + required this.name, + required this.dependencies, + required this.devDependencies, + required this.workspace, + required this.resolution, + }); + + /// Parses a [Pubspec] from a string. + /// + /// Throws a [PubspecParseException] if the string cannot be parsed. + factory Pubspec.fromString(String content) { + late final YamlMap yaml; + try { + yaml = loadYaml(content) as YamlMap; + // loadYaml throws TypeError when it fails to cast content as a YamlMap. + // YamlException is thrown when the content is not valid YAML. + // We need to catch both to provide a meaningful exception. + // ignore: avoid_catching_errors + } on TypeError catch (_) { + throw const PubspecParseException('Failed to parse YAML content'); + } on YamlException catch (_) { + throw const PubspecParseException('Failed to parse YAML content'); + } + + final name = yaml['name'] as String? ?? ''; + + final dependencies = _parseDependencies(yaml['dependencies']); + final devDependencies = _parseDependencies(yaml['dev_dependencies']); + + final workspaceValue = yaml['workspace']; + List? workspace; + if (workspaceValue is YamlList) { + workspace = workspaceValue.cast().toList(); + } + + final resolutionValue = yaml['resolution']; + PubspecResolution? resolution; + if (resolutionValue is String) { + resolution = PubspecResolution.tryParse(resolutionValue); + } + + return Pubspec._( + name: name, + dependencies: UnmodifiableListView(dependencies), + devDependencies: UnmodifiableListView(devDependencies), + workspace: workspace != null ? UnmodifiableListView(workspace) : null, + resolution: resolution, + ); + } + + /// Parses a [Pubspec] from a file. + /// + /// Throws a [PubspecParseException] if the file cannot be read or parsed. + factory Pubspec.fromFile(File file) { + if (!file.existsSync()) { + throw PubspecParseException('File not found: ${file.path}'); + } + return Pubspec.fromString(file.readAsStringSync()); + } + + /// The name of the package. + final String name; + + /// The direct main dependencies. + final UnmodifiableListView dependencies; + + /// The direct dev dependencies. + final UnmodifiableListView devDependencies; + + /// The workspace member paths, if this is a workspace root. + /// + /// This is `null` if this pubspec is not a workspace root. + final UnmodifiableListView? workspace; + + /// The resolution mode for this package. + /// + /// This is `null` if no resolution is specified (typical for standalone + /// packages or workspace roots). + final PubspecResolution? resolution; + + /// Whether this pubspec is a workspace root. + bool get isWorkspaceRoot => workspace != null; + + /// Whether this pubspec is a workspace member. + bool get isWorkspaceMember => resolution == PubspecResolution.workspace; + + /// Resolves workspace member paths to actual directories. + /// + /// This handles glob patterns in workspace paths (e.g., `packages/*`). + /// The [rootDirectory] should be the directory containing this pubspec. + /// + /// Returns an empty list if this is not a workspace root. + List resolveWorkspaceMembers(Directory rootDirectory) { + if (workspace == null) return []; + + final members = []; + for (final pattern in workspace!) { + if (_isGlobPattern(pattern)) { + // Handle glob patterns + final glob = Glob(pattern); + final matches = glob.listSync(root: rootDirectory.path); + for (final match in matches) { + if (match is Directory) { + final pubspecFile = File(path.join(match.path, 'pubspec.yaml')); + if (pubspecFile.existsSync()) { + members.add(Directory(match.path)); + } + } else if (match is File && + path.basename(match.path) == 'pubspec.yaml') { + members.add(Directory(match.parent.path)); + } + } + } else { + // Handle direct path + final memberPath = path.join(rootDirectory.path, pattern); + final memberDir = Directory(memberPath); + if (memberDir.existsSync()) { + members.add(memberDir); + } + } + } + + return members; + } +} + +/// Parses dependency names from a YAML dependencies map. +List _parseDependencies(Object? value) { + if (value == null) return []; + if (value is! YamlMap) return []; + + return value.keys.cast().toList(); +} + +/// Checks if a path pattern contains glob characters. +bool _isGlobPattern(String pattern) { + return pattern.contains('*') || + pattern.contains('?') || + pattern.contains('[') || + pattern.contains('{'); +} + +/// {@template PubspecResolution} +/// The resolution mode for a pubspec. +/// {@endtemplate} +enum PubspecResolution { + /// This package is a workspace member and should resolve with the workspace + /// root. + workspace._('workspace'), + + /// This package uses external resolution (e.g., Dart SDK packages). + external._('external'), + ; + + const PubspecResolution._(this.value); + + /// Tries to parse a [PubspecResolution] from a string. + /// + /// Returns `null` if the string is not a valid resolution value. + static PubspecResolution? tryParse(String value) { + for (final resolution in PubspecResolution.values) { + if (resolution.value == value) { + return resolution; + } + } + return null; + } + + /// The string representation as it appears in pubspec.yaml. + final String value; +} diff --git a/test/src/commands/packages/commands/check/commands/licenses_test.dart b/test/src/commands/packages/commands/check/commands/licenses_test.dart index 11f0bdce5..734d0bb6a 100644 --- a/test/src/commands/packages/commands/check/commands/licenses_test.dart +++ b/test/src/commands/packages/commands/check/commands/licenses_test.dart @@ -1737,6 +1737,400 @@ and limitations under the License.'''); }), ); }); + + group('workspace support', () { + test( + 'collects dependencies from workspace members', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create workspace root pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create workspace member directories and pubspec.yaml files + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberAppPubspecContent, + ); + + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + // Create pubspec.lock at workspace root with all dependencies + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_workspacePubspecLockContent); + + when( + () => packageConfig.packages, + ).thenReturn([ + veryGoodTestRunnerConfigPackage, + cliCompletionConfigPackage, + ]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [...commandArguments, tempDirectory.path], + ); + + // Should find dependencies from both workspace members + verify( + () => progress.update( + 'Collecting licenses from 1 out of 2 packages', + ), + ).called(1); + verify( + () => progress.update( + 'Collecting licenses from 2 out of 2 packages', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 2 licenses from 2 packages of type: MIT (2).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'filters dev dependencies from workspace members correctly', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create workspace root pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create workspace member with dev dependencies + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberWithDevDepsPubspecContent, + ); + + // Create shared package directory + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + // Create pubspec.lock at workspace root + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_workspacePubspecLockWithDevDepsContent); + + when( + () => packageConfig.packages, + ).thenReturn([veryGoodAnalysisConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [ + ...commandArguments, + '--dependency-type', + 'direct-dev', + tempDirectory.path, + ], + ); + + // Should find dev dependencies from workspace members + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 1 license from 1 package of type: MIT (1).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'filters transitive dependencies in workspace correctly', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create workspace root pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create workspace member directories + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberAppPubspecContent, + ); + + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + // Create pubspec.lock with transitive dependencies + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_workspacePubspecLockWithTransitiveContent); + + when( + () => packageConfig.packages, + ).thenReturn([yamlConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [ + ...commandArguments, + '--dependency-type', + 'transitive', + tempDirectory.path, + ], + ); + + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 1 license from 1 package of type: MIT (1).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'filters direct-overridden dependencies in workspace correctly', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create workspace root pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create workspace member directories + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberAppPubspecContent, + ); + + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + // Create pubspec.lock with direct-overridden dependencies + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_workspacePubspecLockWithOverriddenContent); + + when( + () => packageConfig.packages, + ).thenReturn([pathConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [ + ...commandArguments, + '--dependency-type', + 'direct-overridden', + tempDirectory.path, + ], + ); + + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 1 license from 1 package of type: MIT (1).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'handles malformed workspace member pubspec gracefully', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create workspace root pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create workspace member with malformed pubspec + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + 'invalid: yaml: content: [', + ); + + // Create valid shared package + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_workspacePubspecLockContent); + + when( + () => packageConfig.packages, + ).thenReturn([cliCompletionConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [...commandArguments, tempDirectory.path], + ); + + // Should still work, just skipping the malformed member + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'handles nested workspaces recursively', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create root workspace pubspec.yaml + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceRootPubspecContent, + ); + + // Create first-level workspace member that is also a workspace root + final appDir = Directory( + path.join(tempDirectory.path, 'packages', 'app'), + )..createSync(recursive: true); + File(path.join(appDir.path, 'pubspec.yaml')).writeAsStringSync( + _nestedWorkspaceRootPubspecContent, + ); + + // Create nested workspace member + final nestedDir = Directory( + path.join(tempDirectory.path, 'packages', 'app', 'nested', 'pkg'), + )..createSync(recursive: true); + File(path.join(nestedDir.path, 'pubspec.yaml')).writeAsStringSync( + _nestedWorkspaceMemberPubspecContent, + ); + + // Create shared package + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages', 'shared'), + )..createSync(recursive: true); + File(path.join(sharedDir.path, 'pubspec.yaml')).writeAsStringSync( + _workspaceMemberSharedPubspecContent, + ); + + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_nestedWorkspacePubspecLockContent); + + when( + () => packageConfig.packages, + ).thenReturn([ + veryGoodTestRunnerConfigPackage, + cliCompletionConfigPackage, + yamlConfigPackage, + ]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [...commandArguments, tempDirectory.path], + ); + + // Should collect dependencies from all levels including nested + verify( + () => progress.complete( + '''Retrieved 3 licenses from 3 packages of type: MIT (3).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + + test( + 'works with non-workspace projects (backwards compatibility)', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + // Create a regular pubspec.yaml (no workspace property) + File(path.join(tempDirectory.path, 'pubspec.yaml')).writeAsStringSync( + _regularPubspecContent, + ); + + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_validPubspecLockContent); + + when( + () => packageConfig.packages, + ).thenReturn([veryGoodTestRunnerConfigPackage]); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + when(() => logger.progress(any())).thenReturn(progress); + + final result = await commandRunner.run( + [...commandArguments, tempDirectory.path], + ); + + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 1 license from 1 package of type: MIT (1).''', + ), + ).called(1); + + expect(result, equals(ExitCode.success.code)); + }), + ); + }); }); } @@ -1853,3 +2247,231 @@ sdks: dart: ">=3.10.0 <4.0.0" '''; + +/// A workspace root pubspec.yaml content. +const _workspaceRootPubspecContent = ''' +name: workspace_root + +environment: + sdk: ^3.6.0 + +workspace: + - packages/app + - packages/shared +'''; + +/// A workspace member pubspec.yaml for app package. +const _workspaceMemberAppPubspecContent = ''' +name: app + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + very_good_test_runner: ^0.1.0 +'''; + +/// A workspace member pubspec.yaml for shared package. +const _workspaceMemberSharedPubspecContent = ''' +name: shared + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + cli_completion: ^0.4.0 +'''; + +/// A workspace member pubspec.yaml with dev dependencies. +const _workspaceMemberWithDevDepsPubspecContent = ''' +name: app + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + http: ^1.0.0 + +dev_dependencies: + very_good_analysis: ^5.0.0 +'''; + +/// A pubspec.lock for workspace with dependencies from members. +const _workspacePubspecLockContent = ''' +packages: + very_good_test_runner: + dependency: "direct main" + description: + name: very_good_test_runner + sha256: "4d41e5d7677d259b9a1599c78645ac2d36bc2bd6ff7773507bcb0bab41417fe2" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + cli_completion: + dependency: "direct main" + description: + name: cli_completion + sha256: "1e87700c029c77041d836e57f9016b5c90d353151c43c2ca0c36deaadc05aa3a" + url: "https://pub.dev" + source: hosted + version: "0.4.0" +sdks: + dart: ">=3.10.0 <4.0.0" + +'''; + +/// A pubspec.lock for workspace with dev dependencies. +const _workspacePubspecLockWithDevDepsContent = ''' +packages: + http: + dependency: "direct main" + description: + name: http + sha256: "5895291c13fa8a3bd82e76d5627f69e0f97bf76e" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: "9ae7f3a3bd5764fb021b335ca28a34f040cd0ab6eec00a1b213b445dae58a4b8" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + cli_completion: + dependency: "direct main" + description: + name: cli_completion + sha256: "1e87700c029c77041d836e57f9016b5c90d353151c43c2ca0c36deaadc05aa3a" + url: "https://pub.dev" + source: hosted + version: "0.4.0" +sdks: + dart: ">=3.10.0 <4.0.0" + +'''; + +/// A regular pubspec.yaml (non-workspace). +const _regularPubspecContent = ''' +name: regular_package + +environment: + sdk: ^3.0.0 + +dependencies: + very_good_test_runner: ^0.1.0 +'''; + +/// A pubspec.lock for workspace with transitive dependencies. +const _workspacePubspecLockWithTransitiveContent = ''' +packages: + very_good_test_runner: + dependency: "direct main" + description: + name: very_good_test_runner + sha256: "4d41e5d7677d259b9a1599c78645ac2d36bc2bd6ff7773507bcb0bab41417fe2" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.10.0 <4.0.0" + +'''; + +/// A pubspec.lock for workspace with direct-overridden dependencies. +const _workspacePubspecLockWithOverriddenContent = ''' +packages: + very_good_test_runner: + dependency: "direct main" + description: + name: very_good_test_runner + sha256: "4d41e5d7677d259b9a1599c78645ac2d36bc2bd6ff7773507bcb0bab41417fe2" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + path: + dependency: "direct overridden" + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" +sdks: + dart: ">=3.10.0 <4.0.0" + +'''; + +/// A nested workspace root pubspec.yaml (workspace member that is also a root). +const _nestedWorkspaceRootPubspecContent = ''' +name: app + +environment: + sdk: ^3.6.0 + +workspace: + - nested/pkg + +dependencies: + very_good_test_runner: ^0.1.0 +'''; + +/// A nested workspace member pubspec.yaml. +const _nestedWorkspaceMemberPubspecContent = ''' +name: nested_pkg + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + yaml: ^3.1.0 +'''; + +/// A pubspec.lock for nested workspace with dependencies from all levels. +const _nestedWorkspacePubspecLockContent = ''' +packages: + very_good_test_runner: + dependency: "direct main" + description: + name: very_good_test_runner + sha256: "4d41e5d7677d259b9a1599c78645ac2d36bc2bd6ff7773507bcb0bab41417fe2" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + cli_completion: + dependency: "direct main" + description: + name: cli_completion + sha256: "1e87700c029c77041d836e57f9016b5c90d353151c43c2ca0c36deaadc05aa3a" + url: "https://pub.dev" + source: hosted + version: "0.4.0" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.10.0 <4.0.0" + +'''; diff --git a/test/src/pubspec/pubspec_test.dart b/test/src/pubspec/pubspec_test.dart new file mode 100644 index 000000000..55c4c0ca4 --- /dev/null +++ b/test/src/pubspec/pubspec_test.dart @@ -0,0 +1,319 @@ +// Ensures we don't have to use const constructors +// and instances are created at runtime. +// ignore_for_file: prefer_const_constructors + +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; +import 'package:very_good_cli/src/pubspec/pubspec.dart'; + +void main() { + group('$Pubspec', () { + group('fromString', () { + test('parses basic pubspec correctly', () { + final pubspec = Pubspec.fromString(_basicPubspecContent); + + expect(pubspec.name, equals('test_package')); + expect(pubspec.dependencies, equals(['foo', 'bar'])); + expect(pubspec.devDependencies, equals(['test', 'mocktail'])); + expect(pubspec.workspace, isNull); + expect(pubspec.resolution, isNull); + expect(pubspec.isWorkspaceRoot, isFalse); + expect(pubspec.isWorkspaceMember, isFalse); + }); + + test('parses workspace root pubspec correctly', () { + final pubspec = Pubspec.fromString(_workspaceRootPubspecContent); + + expect(pubspec.name, equals('workspace_root')); + expect(pubspec.dependencies, isEmpty); + expect(pubspec.devDependencies, isEmpty); + expect(pubspec.workspace, equals(['packages/app', 'packages/shared'])); + expect(pubspec.resolution, isNull); + expect(pubspec.isWorkspaceRoot, isTrue); + expect(pubspec.isWorkspaceMember, isFalse); + }); + + test('parses workspace member pubspec correctly', () { + final pubspec = Pubspec.fromString(_workspaceMemberPubspecContent); + + expect(pubspec.name, equals('workspace_member')); + expect(pubspec.dependencies, equals(['http', 'shared'])); + expect(pubspec.devDependencies, equals(['test'])); + expect(pubspec.workspace, isNull); + expect(pubspec.resolution, equals(PubspecResolution.workspace)); + expect(pubspec.isWorkspaceRoot, isFalse); + expect(pubspec.isWorkspaceMember, isTrue); + }); + + test('parses workspace with glob pattern correctly', () { + final pubspec = Pubspec.fromString(_workspaceWithGlobPubspecContent); + + expect(pubspec.workspace, equals(['packages/*'])); + expect(pubspec.isWorkspaceRoot, isTrue); + }); + + test('parses pubspec with no dependencies correctly', () { + final pubspec = Pubspec.fromString(_noDependenciesPubspecContent); + + expect(pubspec.name, equals('no_deps')); + expect(pubspec.dependencies, isEmpty); + expect(pubspec.devDependencies, isEmpty); + }); + + test('throws $PubspecParseException when content is empty', () { + expect( + () => Pubspec.fromString(''), + throwsA(isA()), + ); + }); + + test('throws $PubspecParseException when content is invalid YAML', () { + expect( + () => Pubspec.fromString('invalid: yaml: content: ['), + throwsA(isA()), + ); + }); + + test('handles missing name gracefully', () { + final pubspec = Pubspec.fromString('dependencies:\n foo: ^1.0.0'); + expect(pubspec.name, equals('')); + }); + }); + + group('fromFile', () { + late Directory tempDirectory; + + setUp(() { + tempDirectory = Directory.systemTemp.createTempSync(); + }); + + tearDown(() { + tempDirectory.deleteSync(recursive: true); + }); + + test('parses file correctly', () { + final pubspecFile = File(path.join(tempDirectory.path, 'pubspec.yaml')) + ..writeAsStringSync(_basicPubspecContent); + + final pubspec = Pubspec.fromFile(pubspecFile); + + expect(pubspec.name, equals('test_package')); + expect(pubspec.dependencies, equals(['foo', 'bar'])); + }); + + test('throws $PubspecParseException when file does not exist', () { + final nonExistentFile = File( + path.join(tempDirectory.path, 'nonexistent.yaml'), + ); + + expect( + () => Pubspec.fromFile(nonExistentFile), + throwsA(isA()), + ); + }); + }); + + group('resolveWorkspaceMembers', () { + late Directory tempDirectory; + + setUp(() { + tempDirectory = Directory.systemTemp.createTempSync(); + }); + + tearDown(() { + tempDirectory.deleteSync(recursive: true); + }); + + test('returns empty list when not a workspace root', () { + final pubspec = Pubspec.fromString(_basicPubspecContent); + final members = pubspec.resolveWorkspaceMembers(tempDirectory); + expect(members, isEmpty); + }); + + test('resolves direct path members correctly', () { + // Create workspace structure + final appDir = Directory(path.join(tempDirectory.path, 'packages/app')) + ..createSync(recursive: true); + File( + path.join(appDir.path, 'pubspec.yaml'), + ).writeAsStringSync(_workspaceMemberPubspecContent); + + final sharedDir = Directory( + path.join(tempDirectory.path, 'packages/shared'), + )..createSync(recursive: true); + File( + path.join(sharedDir.path, 'pubspec.yaml'), + ).writeAsStringSync(_workspaceMemberPubspecContent); + + final pubspec = Pubspec.fromString(_workspaceRootPubspecContent); + final members = pubspec.resolveWorkspaceMembers(tempDirectory); + + expect(members.length, equals(2)); + expect(members.map((d) => path.basename(d.path)), contains('app')); + expect(members.map((d) => path.basename(d.path)), contains('shared')); + }); + + test('ignores directories without pubspec.yaml for glob patterns', () { + // Create workspace structure with one valid and one invalid member + final validDir = Directory( + path.join(tempDirectory.path, 'packages/valid'), + )..createSync(recursive: true); + File( + path.join(validDir.path, 'pubspec.yaml'), + ).writeAsStringSync(_workspaceMemberPubspecContent); + + // Create a directory without pubspec.yaml + Directory( + path.join(tempDirectory.path, 'packages/invalid'), + ).createSync(recursive: true); + + final pubspec = Pubspec.fromString(_workspaceWithGlobPubspecContent); + final members = pubspec.resolveWorkspaceMembers(tempDirectory); + + expect(members.length, equals(1)); + expect(path.basename(members.first.path), equals('valid')); + }); + + test('skips non-existent direct path members', () { + // Don't create any directories + final pubspec = Pubspec.fromString(_workspaceRootPubspecContent); + final members = pubspec.resolveWorkspaceMembers(tempDirectory); + + expect(members, isEmpty); + }); + + test('resolves glob pattern matching pubspec.yaml files directly', () { + // Create workspace structure + final memberDir = Directory( + path.join(tempDirectory.path, 'packages/member'), + )..createSync(recursive: true); + File( + path.join(memberDir.path, 'pubspec.yaml'), + ).writeAsStringSync(_workspaceMemberPubspecContent); + + // Use a glob pattern that matches pubspec.yaml files directly + final pubspec = Pubspec.fromString( + _workspaceWithFileGlobPubspecContent, + ); + final members = pubspec.resolveWorkspaceMembers(tempDirectory); + + expect(members.length, equals(1)); + expect(path.basename(members.first.path), equals('member')); + }); + }); + }); + + group('$PubspecParseException', () { + test('toString returns message when provided', () { + final exception = PubspecParseException('test message'); + expect( + exception.toString(), + equals('PubspecParseException: test message'), + ); + }); + + test('toString returns class name when no message', () { + final exception = PubspecParseException(); + expect(exception.toString(), equals('PubspecParseException')); + }); + }); + + group('$PubspecResolution', () { + group('tryParse', () { + test('parses workspace correctly', () { + expect( + PubspecResolution.tryParse('workspace'), + equals(PubspecResolution.workspace), + ); + }); + + test('parses external correctly', () { + expect( + PubspecResolution.tryParse('external'), + equals(PubspecResolution.external), + ); + }); + + test('returns null for invalid value', () { + expect(PubspecResolution.tryParse('invalid'), isNull); + }); + }); + }); +} + +/// A basic pubspec.yaml content with dependencies. +const _basicPubspecContent = ''' +name: test_package + +environment: + sdk: ^3.0.0 + +dependencies: + foo: ^1.0.0 + bar: ^2.0.0 + +dev_dependencies: + test: ^1.0.0 + mocktail: ^1.0.0 +'''; + +/// A workspace root pubspec.yaml content. +const _workspaceRootPubspecContent = ''' +name: workspace_root + +environment: + sdk: ^3.6.0 + +workspace: + - packages/app + - packages/shared +'''; + +/// A workspace member pubspec.yaml content. +const _workspaceMemberPubspecContent = ''' +name: workspace_member + +environment: + sdk: ^3.6.0 + +resolution: workspace + +dependencies: + http: ^1.0.0 + shared: ^1.0.0 + +dev_dependencies: + test: ^1.0.0 +'''; + +/// A workspace pubspec.yaml with glob pattern. +const _workspaceWithGlobPubspecContent = ''' +name: workspace_glob + +environment: + sdk: ^3.6.0 + +workspace: + - packages/* +'''; + +/// A pubspec.yaml with no dependencies. +const _noDependenciesPubspecContent = ''' +name: no_deps + +environment: + sdk: ^3.0.0 +'''; + +/// A workspace pubspec.yaml with glob pattern that matches pubspec.yaml files. +const _workspaceWithFileGlobPubspecContent = ''' +name: workspace_file_glob + +environment: + sdk: ^3.6.0 + +workspace: + - packages/*/pubspec.yaml +''';