diff --git a/dart_test.yaml b/dart_test.yaml deleted file mode 100644 index 4ab73eb..0000000 --- a/dart_test.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# Tests that mutate Directory.current (a process-global OS call) must not run -# concurrently across test files in the same process — the last chdir() wins -# and can leave other suites pointing at a deleted temp directory. -concurrency: 1 diff --git a/packages/core/lib/src/config.dart b/packages/core/lib/src/config.dart index 63b2c07..0931276 100644 --- a/packages/core/lib/src/config.dart +++ b/packages/core/lib/src/config.dart @@ -1,5 +1,5 @@ -import 'dart:io'; - +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; @@ -10,9 +10,7 @@ import 'package:yaml/yaml.dart'; /// This keeps feature-specific config classes out of core. class DewConfig { final YamlMap raw; - const DewConfig({required this.raw}); - factory DewConfig.fromYaml(YamlMap yaml) => DewConfig(raw: yaml); } @@ -20,20 +18,25 @@ class DewConfig { class ProjectContext { final String root; final DewConfig config; + final FileSystem fs; - const ProjectContext({required this.root, required this.config}); + const ProjectContext({required this.root, required this.config, required this.fs}); - /// Walks up from [from] (defaults to [Directory.current]) until a + /// Walks up from [from] (defaults to [fs.currentDirectory]) until a /// `.project/dew.yaml` is found. - static Future find({Directory? from}) async { - var dir = from ?? Directory.current; + static Future find({ + FileSystem fs = const LocalFileSystem(), + Directory? from, + }) async { + var dir = from ?? fs.currentDirectory; while (true) { - final configFile = File(p.join(dir.path, '.project', 'dew.yaml')); + final configFile = fs.file(p.join(dir.path, '.project', 'dew.yaml')); if (await configFile.exists()) { final yaml = loadYaml(await configFile.readAsString()) as YamlMap; return ProjectContext( root: dir.path, config: DewConfig.fromYaml(yaml), + fs: fs, ); } final parent = dir.parent; diff --git a/packages/core/lib/src/init.dart b/packages/core/lib/src/init.dart index ac6dc81..2ccd531 100644 --- a/packages/core/lib/src/init.dart +++ b/packages/core/lib/src/init.dart @@ -1,6 +1,6 @@ -import 'dart:io'; - import 'package:args/command_runner.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; @@ -62,8 +62,9 @@ dew: class InitCommand extends Command { final List _hooks; + final FileSystem _fs; - InitCommand(this._hooks) { + InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption( 'path', @@ -93,8 +94,8 @@ class InitCommand extends Command { final projectRoot = p.canonicalize(rawPath); final options = DewInitOptions(gitkeep: gitkeep); - final projectDir = Directory(p.join(projectRoot, '.project')); - final configFile = File(p.join(projectDir.path, 'dew.yaml')); + final projectDir = _fs.directory(p.join(projectRoot, '.project')); + final configFile = _fs.file(p.join(projectDir.path, 'dew.yaml')); await projectDir.create(recursive: true); diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 8fec53e..cd807d2 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -11,6 +11,7 @@ environment: # Add regular dependencies here. dependencies: args: ^2.7.0 + file: ^7.0.1 path: ^1.9.0 yaml: ^3.1.0 diff --git a/packages/core/test/dew_core_test.dart b/packages/core/test/dew_core_test.dart index 2ad7bc5..574854f 100644 --- a/packages/core/test/dew_core_test.dart +++ b/packages/core/test/dew_core_test.dart @@ -1,7 +1,5 @@ -import 'dart:io'; - import 'package:dew_core/dew_core.dart'; -import 'package:path/path.dart' as p; +import 'package:file/memory.dart'; import 'package:test/test.dart'; class _TestCommand extends DewCommand { @@ -37,16 +35,7 @@ void main() { }); group('ProjectContext', () { - late Directory tempDir; - late Directory originalDir; - - setUp(() async { - originalDir = Directory.current; - tempDir = await Directory.systemTemp.createTemp('dew_core_test_'); - await Directory(p.join(tempDir.path, '.project')).create(); - await File( - p.join(tempDir.path, '.project', 'dew.yaml'), - ).writeAsString(''' + const configYaml = ''' dew: mcp: host: localhost @@ -60,17 +49,14 @@ dew: - id: todo name: To Do color: blue -'''); - Directory.current = tempDir; - }); - - tearDown(() async { - Directory.current = originalDir; - await tempDir.delete(recursive: true); - }); +'''; test('find() loads config and exposes raw yaml', () async { - final ctx = await ProjectContext.find(); + final fs = MemoryFileSystem(); + fs.directory('/.project').createSync(recursive: true); + fs.file('/.project/dew.yaml').writeAsStringSync(configYaml); + + final ctx = await ProjectContext.find(fs: fs); final dew = ctx.config.raw['dew']; expect(dew['kanban']['prefix'], 'TEST'); expect(dew['mcp']['host'], 'localhost'); @@ -78,10 +64,13 @@ dew: }); test('find() locates config from a subdirectory', () async { - final sub = await Directory(p.join(tempDir.path, 'sub')).create(); - Directory.current = sub; - final ctx = await ProjectContext.find(); - expect(ctx.root, tempDir.path); + final fs = MemoryFileSystem(); + fs.directory('/.project').createSync(recursive: true); + fs.file('/.project/dew.yaml').writeAsStringSync(configYaml); + fs.directory('/sub').createSync(recursive: true); + + final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/sub')); + expect(ctx.root, '/'); }); }); } diff --git a/packages/kanban/lib/dew_kanban.dart b/packages/kanban/lib/dew_kanban.dart index 3eefb9b..de02250 100644 --- a/packages/kanban/lib/dew_kanban.dart +++ b/packages/kanban/lib/dew_kanban.dart @@ -7,11 +7,16 @@ export 'src/ticket.dart'; export 'src/ticket_store.dart'; import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:dew_kanban/src/dew_kanban_base.dart'; import 'package:dew_kanban/src/kanban_init_hook.dart'; /// Registers all Kanban commands and init hooks into [registry]. -void registerCommands(CommandRegistry registry) { - registry.register(KanbanCommand()); - registry.registerInitHook(KanbanInitHook()); +void registerCommands( + CommandRegistry registry, { + FileSystem fs = const LocalFileSystem(), +}) { + registry.register(KanbanCommand(fs: fs)); + registry.registerInitHook(KanbanInitHook(fs: fs)); } diff --git a/packages/kanban/lib/src/commands/add_comment_command.dart b/packages/kanban/lib/src/commands/add_comment_command.dart index d470ac7..2436dea 100644 --- a/packages/kanban/lib/src/commands/add_comment_command.dart +++ b/packages/kanban/lib/src/commands/add_comment_command.dart @@ -1,11 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; import '../ticket_store.dart'; class AddCommentCommand extends DewCommand with DewToolCommand { - AddCommentCommand() { + final FileSystem _fs; + + AddCommentCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID (e.g. DEW-0001).') ..addOption('comment', abbr: 'm', mandatory: true, help: 'Comment text to append.'); @@ -25,10 +29,11 @@ class AddCommentCommand extends DewCommand with DewToolCommand { final id = (args['id'] as String).toUpperCase(); final comment = args['comment'] as String; - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, + fs: context.fs, ); await store.addComment(id, comment); return 'Comment added to $id.'; diff --git a/packages/kanban/lib/src/commands/archive_command.dart b/packages/kanban/lib/src/commands/archive_command.dart index 0e1ee60..b22fe20 100644 --- a/packages/kanban/lib/src/commands/archive_command.dart +++ b/packages/kanban/lib/src/commands/archive_command.dart @@ -1,13 +1,15 @@ -import 'dart:io'; - import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; import '../ticket_store.dart'; class ArchiveCommand extends DewCommand with DewToolCommand { - ArchiveCommand() { + final FileSystem _fs; + + ArchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser.addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to archive.'); } @@ -24,22 +26,22 @@ class ArchiveCommand extends DewCommand with DewToolCommand { Future callAsTool(Map args) async { final id = (args['id'] as String).toUpperCase(); - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final config = context.config.kanban; final kanbanDir = p.join(context.root, '.project', 'kanban'); - final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix); + final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs); final ticket = await store.findById(id); if (ticket == null) throw ArgumentError('Ticket $id not found.'); if (ticket.column == 'archive') return '$id is already archived.'; - final archiveDir = Directory(p.join(kanbanDir, 'archive')); + final archiveDir = context.fs.directory(p.join(kanbanDir, 'archive')); await archiveDir.create(recursive: true); // Move the file directly rather than through update() so we don't write // the column back into the ticket frontmatter (it's derived from the dir). - final srcFile = File(p.join(kanbanDir, ticket.column, '$id.md')); - final dstFile = File(p.join(archiveDir.path, '$id.md')); + final srcFile = context.fs.file(p.join(kanbanDir, ticket.column, '$id.md')); + final dstFile = context.fs.file(p.join(archiveDir.path, '$id.md')); await srcFile.rename(dstFile.path); return 'Archived $id.'; diff --git a/packages/kanban/lib/src/commands/board_command.dart b/packages/kanban/lib/src/commands/board_command.dart index 3a5a2f9..596adc3 100644 --- a/packages/kanban/lib/src/commands/board_command.dart +++ b/packages/kanban/lib/src/commands/board_command.dart @@ -1,4 +1,6 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; @@ -6,7 +8,9 @@ import '../ticket.dart'; import '../ticket_store.dart'; class BoardCommand extends DewCommand with DewToolCommand { - BoardCommand() { + final FileSystem _fs; + + BoardCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('type', abbr: 't', help: 'Filter tickets to this type.') ..addOption('label', help: 'Filter tickets to this label.') @@ -28,11 +32,12 @@ class BoardCommand extends DewCommand with DewToolCommand { final labelFilter = args['label'] as String?; final milestoneFilter = args['milestone'] as String?; - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final config = context.config.kanban; final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: config.prefix, + fs: context.fs, ); var tickets = await store.list(); diff --git a/packages/kanban/lib/src/commands/create_command.dart b/packages/kanban/lib/src/commands/create_command.dart index 89145fe..e672752 100644 --- a/packages/kanban/lib/src/commands/create_command.dart +++ b/packages/kanban/lib/src/commands/create_command.dart @@ -1,11 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; import '../ticket_store.dart'; class CreateCommand extends DewCommand with DewToolCommand { - CreateCommand() { + final FileSystem _fs; + + CreateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.') ..addOption( @@ -37,7 +41,7 @@ class CreateCommand extends DewCommand with DewToolCommand { @override Future callAsTool(Map args) async { - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final config = context.config.kanban; final title = args['title'] as String; @@ -65,6 +69,7 @@ class CreateCommand extends DewCommand with DewToolCommand { final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: config.prefix, + fs: context.fs, ); final ticket = await store.create( title: title, diff --git a/packages/kanban/lib/src/commands/delete_command.dart b/packages/kanban/lib/src/commands/delete_command.dart index 6c01643..a1a82c3 100644 --- a/packages/kanban/lib/src/commands/delete_command.dart +++ b/packages/kanban/lib/src/commands/delete_command.dart @@ -1,11 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; import '../ticket_store.dart'; class DeleteCommand extends DewCommand with DewToolCommand { - DeleteCommand() { + final FileSystem _fs; + + DeleteCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser.addOption( 'id', abbr: 'i', @@ -26,10 +30,11 @@ class DeleteCommand extends DewCommand with DewToolCommand { @override Future callAsTool(Map args) async { final id = (args['id'] as String).toUpperCase(); - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, + fs: context.fs, ); await store.delete(id); return 'Deleted $id.'; diff --git a/packages/kanban/lib/src/commands/get_command.dart b/packages/kanban/lib/src/commands/get_command.dart index 23f88e1..56a20c2 100644 --- a/packages/kanban/lib/src/commands/get_command.dart +++ b/packages/kanban/lib/src/commands/get_command.dart @@ -1,4 +1,6 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; @@ -6,7 +8,9 @@ import '../ticket.dart'; import '../ticket_store.dart'; class GetCommand extends DewCommand with DewToolCommand { - GetCommand() { + final FileSystem _fs; + + GetCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser.addOption( 'id', abbr: 'i', @@ -27,10 +31,11 @@ class GetCommand extends DewCommand with DewToolCommand { @override Future callAsTool(Map args) async { final id = (args['id'] as String).toUpperCase(); - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, + fs: context.fs, ); final ticket = await store.findById(id); if (ticket == null) throw ArgumentError('Ticket $id not found.'); diff --git a/packages/kanban/lib/src/commands/get_config_command.dart b/packages/kanban/lib/src/commands/get_config_command.dart index 9f012a9..98d7477 100644 --- a/packages/kanban/lib/src/commands/get_config_command.dart +++ b/packages/kanban/lib/src/commands/get_config_command.dart @@ -1,7 +1,12 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; class GetConfigCommand extends DewCommand with DewToolCommand { + final FileSystem _fs; + + GetConfigCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs; @override final String name = 'config'; @@ -13,7 +18,7 @@ class GetConfigCommand extends DewCommand with DewToolCommand { @override Future callAsTool(Map args) async { - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final config = context.config.kanban; final columns = config.columns.map((c) => '${c.id} (${c.name})').join(', '); diff --git a/packages/kanban/lib/src/commands/link_command.dart b/packages/kanban/lib/src/commands/link_command.dart index 8e4d6b7..4b4e6db 100644 --- a/packages/kanban/lib/src/commands/link_command.dart +++ b/packages/kanban/lib/src/commands/link_command.dart @@ -1,4 +1,6 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; @@ -6,7 +8,9 @@ import '../ticket.dart'; import '../ticket_store.dart'; class LinkCommand extends DewCommand with DewToolCommand { - LinkCommand() { + final FileSystem _fs; + + LinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.') ..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID.') @@ -38,10 +42,11 @@ class LinkCommand extends DewCommand with DewToolCommand { if (id == targetId) throw ArgumentError('A ticket cannot be linked to itself.'); - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, + fs: context.fs, ); await store.linkTickets(id, targetId, type); diff --git a/packages/kanban/lib/src/commands/list_command.dart b/packages/kanban/lib/src/commands/list_command.dart index 9059779..f2a573a 100644 --- a/packages/kanban/lib/src/commands/list_command.dart +++ b/packages/kanban/lib/src/commands/list_command.dart @@ -1,4 +1,6 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; @@ -6,7 +8,9 @@ import '../ticket.dart'; import '../ticket_store.dart'; class ListCommand extends DewCommand with DewToolCommand { - ListCommand() { + final FileSystem _fs; + + ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('column', abbr: 'c', help: 'Filter to tickets in this column.') ..addOption('type', abbr: 't', help: 'Filter to tickets of this type.') @@ -32,10 +36,11 @@ class ListCommand extends DewCommand with DewToolCommand { final milestoneFilter = args['milestone'] as String?; final includeArchived = args['include-archived'] as bool? ?? false; - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, + fs: context.fs, ); var tickets = await store.list(includeArchived: includeArchived); diff --git a/packages/kanban/lib/src/commands/move_command.dart b/packages/kanban/lib/src/commands/move_command.dart index d08ecb4..ec7b12f 100644 --- a/packages/kanban/lib/src/commands/move_command.dart +++ b/packages/kanban/lib/src/commands/move_command.dart @@ -1,11 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; import '../ticket_store.dart'; class MoveCommand extends DewCommand with DewToolCommand { - MoveCommand() { + final FileSystem _fs; + + MoveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.') ..addOption('column', abbr: 'c', mandatory: true, help: 'Target column ID.'); @@ -25,7 +29,7 @@ class MoveCommand extends DewCommand with DewToolCommand { final id = (args['id'] as String).toUpperCase(); final column = args['column'] as String; - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final config = context.config.kanban; if (!config.columns.any((c) => c.id == column)) { @@ -38,6 +42,7 @@ class MoveCommand extends DewCommand with DewToolCommand { final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: config.prefix, + fs: context.fs, ); final ticket = await store.findById(id); diff --git a/packages/kanban/lib/src/commands/search_command.dart b/packages/kanban/lib/src/commands/search_command.dart index a9fe46b..5b3f198 100644 --- a/packages/kanban/lib/src/commands/search_command.dart +++ b/packages/kanban/lib/src/commands/search_command.dart @@ -1,11 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; import '../ticket_store.dart'; class SearchCommand extends DewCommand with DewToolCommand { - SearchCommand() { + final FileSystem _fs; + + SearchCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption( 'query', @@ -38,10 +42,11 @@ class SearchCommand extends DewCommand with DewToolCommand { final milestoneFilter = args['milestone'] as String?; final includeArchived = args['include-archived'] as bool? ?? false; - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, + fs: context.fs, ); var tickets = await store.list(includeArchived: includeArchived); diff --git a/packages/kanban/lib/src/commands/stats_command.dart b/packages/kanban/lib/src/commands/stats_command.dart index a657990..0e45bb9 100644 --- a/packages/kanban/lib/src/commands/stats_command.dart +++ b/packages/kanban/lib/src/commands/stats_command.dart @@ -1,10 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; import '../ticket_store.dart'; class StatsCommand extends DewCommand with DewToolCommand { + final FileSystem _fs; + + StatsCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs; @override final String name = 'stats'; @@ -16,11 +21,12 @@ class StatsCommand extends DewCommand with DewToolCommand { @override Future callAsTool(Map args) async { - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final config = context.config.kanban; final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: config.prefix, + fs: context.fs, ); final stats = await store.stats(); diff --git a/packages/kanban/lib/src/commands/unarchive_command.dart b/packages/kanban/lib/src/commands/unarchive_command.dart index 0199bca..fd0eb9b 100644 --- a/packages/kanban/lib/src/commands/unarchive_command.dart +++ b/packages/kanban/lib/src/commands/unarchive_command.dart @@ -1,13 +1,15 @@ -import 'dart:io'; - import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; import '../ticket_store.dart'; class UnarchiveCommand extends DewCommand with DewToolCommand { - UnarchiveCommand() { + final FileSystem _fs; + + UnarchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID to unarchive.') ..addOption( @@ -30,11 +32,11 @@ class UnarchiveCommand extends DewCommand with DewToolCommand { Future callAsTool(Map args) async { final id = (args['id'] as String).toUpperCase(); - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final config = context.config.kanban; final kanbanDir = p.join(context.root, '.project', 'kanban'); - final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix); + final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs); final ticket = await store.findById(id); if (ticket == null) throw ArgumentError('Ticket $id not found.'); if (ticket.column != 'archive') return '$id is not archived.'; @@ -48,11 +50,11 @@ class UnarchiveCommand extends DewCommand with DewToolCommand { ); } - final targetDir = Directory(p.join(kanbanDir, targetColumn)); + final targetDir = context.fs.directory(p.join(kanbanDir, targetColumn)); await targetDir.create(recursive: true); - final srcFile = File(p.join(kanbanDir, 'archive', '$id.md')); - final dstFile = File(p.join(targetDir.path, '$id.md')); + final srcFile = context.fs.file(p.join(kanbanDir, 'archive', '$id.md')); + final dstFile = context.fs.file(p.join(targetDir.path, '$id.md')); await srcFile.rename(dstFile.path); return 'Restored $id to "$targetColumn".'; diff --git a/packages/kanban/lib/src/commands/unlink_command.dart b/packages/kanban/lib/src/commands/unlink_command.dart index de81ea0..926349c 100644 --- a/packages/kanban/lib/src/commands/unlink_command.dart +++ b/packages/kanban/lib/src/commands/unlink_command.dart @@ -1,11 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; import '../ticket_store.dart'; class UnlinkCommand extends DewCommand with DewToolCommand { - UnlinkCommand() { + final FileSystem _fs; + + UnlinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.') ..addOption('target', abbr: 't', mandatory: true, help: 'Target ticket ID to remove link to.'); @@ -25,10 +29,11 @@ class UnlinkCommand extends DewCommand with DewToolCommand { final id = (args['id'] as String).toUpperCase(); final targetId = (args['target'] as String).toUpperCase(); - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: context.config.kanban.prefix, + fs: context.fs, ); await store.unlinkTickets(id, targetId); diff --git a/packages/kanban/lib/src/commands/update_command.dart b/packages/kanban/lib/src/commands/update_command.dart index b3ea3e4..7caf981 100644 --- a/packages/kanban/lib/src/commands/update_command.dart +++ b/packages/kanban/lib/src/commands/update_command.dart @@ -1,11 +1,15 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import '../kanban_config.dart'; import 'package:path/path.dart' as p; import '../ticket_store.dart'; class UpdateCommand extends DewCommand with DewToolCommand { - UpdateCommand() { + final FileSystem _fs; + + UpdateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs { argParser ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.') ..addOption('title', abbr: 't', help: 'New title.') @@ -58,7 +62,7 @@ class UpdateCommand extends DewCommand with DewToolCommand { ); } - final context = await ProjectContext.find(); + final context = await ProjectContext.find(fs: _fs); final config = context.config.kanban; if (typeId != null && !config.ticketTypes.any((t) => t.id == typeId)) { @@ -77,6 +81,7 @@ class UpdateCommand extends DewCommand with DewToolCommand { final store = TicketStore( kanbanDir: p.join(context.root, '.project', 'kanban'), prefix: config.prefix, + fs: context.fs, ); final ticket = await store.update( id, diff --git a/packages/kanban/lib/src/dew_kanban_base.dart b/packages/kanban/lib/src/dew_kanban_base.dart index ccc48ff..0fb0b93 100644 --- a/packages/kanban/lib/src/dew_kanban_base.dart +++ b/packages/kanban/lib/src/dew_kanban_base.dart @@ -1,4 +1,6 @@ import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'commands/add_comment_command.dart'; import 'commands/archive_command.dart'; @@ -18,22 +20,22 @@ import 'commands/update_command.dart'; /// Top-level CLI command for all Kanban board operations. class KanbanCommand extends DewCommand { - KanbanCommand() { - addSubcommand(CreateCommand()); - addSubcommand(ListCommand()); - addSubcommand(BoardCommand()); - addSubcommand(GetCommand()); - addSubcommand(UpdateCommand()); - addSubcommand(DeleteCommand()); - addSubcommand(ArchiveCommand()); - addSubcommand(UnarchiveCommand()); - addSubcommand(MoveCommand()); - addSubcommand(SearchCommand()); - addSubcommand(AddCommentCommand()); - addSubcommand(GetConfigCommand()); - addSubcommand(StatsCommand()); - addSubcommand(LinkCommand()); - addSubcommand(UnlinkCommand()); + KanbanCommand({FileSystem fs = const LocalFileSystem()}) { + addSubcommand(CreateCommand(fs: fs)); + addSubcommand(ListCommand(fs: fs)); + addSubcommand(BoardCommand(fs: fs)); + addSubcommand(GetCommand(fs: fs)); + addSubcommand(UpdateCommand(fs: fs)); + addSubcommand(DeleteCommand(fs: fs)); + addSubcommand(ArchiveCommand(fs: fs)); + addSubcommand(UnarchiveCommand(fs: fs)); + addSubcommand(MoveCommand(fs: fs)); + addSubcommand(SearchCommand(fs: fs)); + addSubcommand(AddCommentCommand(fs: fs)); + addSubcommand(GetConfigCommand(fs: fs)); + addSubcommand(StatsCommand(fs: fs)); + addSubcommand(LinkCommand(fs: fs)); + addSubcommand(UnlinkCommand(fs: fs)); } @override diff --git a/packages/kanban/lib/src/kanban_init_hook.dart b/packages/kanban/lib/src/kanban_init_hook.dart index 4d8f065..f5df996 100644 --- a/packages/kanban/lib/src/kanban_init_hook.dart +++ b/packages/kanban/lib/src/kanban_init_hook.dart @@ -1,11 +1,15 @@ -import 'dart:io'; - import 'package:dew_core/dew_core.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:path/path.dart' as p; import 'kanban_config.dart'; class KanbanInitHook implements DewInitHook { + final FileSystem _fs; + + KanbanInitHook({FileSystem fs = const LocalFileSystem()}) : _fs = fs; + @override Future onInit( String projectRoot, @@ -26,7 +30,7 @@ class KanbanInitHook implements DewInitHook { } Future _createDir(String path, bool gitkeep) async { - final dir = Directory(path); + final dir = _fs.directory(path); final existed = await dir.exists(); await dir.create(recursive: true); final rel = '.project/kanban/${p.basename(path)}'; @@ -35,7 +39,7 @@ class KanbanInitHook implements DewInitHook { } else { print(' created $rel/'); if (gitkeep) { - await File(p.join(path, '.gitkeep')).writeAsString(''); + await _fs.file(p.join(path, '.gitkeep')).writeAsString(''); print(' created $rel/.gitkeep'); } } diff --git a/packages/kanban/lib/src/ticket_store.dart b/packages/kanban/lib/src/ticket_store.dart index d081961..e4f7ef3 100644 --- a/packages/kanban/lib/src/ticket_store.dart +++ b/packages/kanban/lib/src/ticket_store.dart @@ -1,5 +1,5 @@ -import 'dart:io'; - +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:path/path.dart' as p; import 'ticket.dart'; @@ -7,8 +7,13 @@ import 'ticket.dart'; class TicketStore { final String kanbanDir; final String prefix; + final FileSystem fs; - const TicketStore({required this.kanbanDir, required this.prefix}); + const TicketStore({ + required this.kanbanDir, + required this.prefix, + this.fs = const LocalFileSystem(), + }); Future create({ required String title, @@ -18,7 +23,7 @@ class TicketStore { List milestones = const [], List labels = const [], }) async { - final columnDir = Directory(p.join(kanbanDir, column)); + final columnDir = fs.directory(p.join(kanbanDir, column)); await columnDir.create(recursive: true); final id = _formatId(await _nextNumber()); final ticket = Ticket( @@ -32,7 +37,7 @@ class TicketStore { milestones: milestones, labels: labels, ); - await File(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent()); + await fs.file(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent()); return ticket; } @@ -43,7 +48,7 @@ class TicketStore { } Future> list({bool includeArchived = false}) async { - final dir = Directory(kanbanDir); + final dir = fs.directory(kanbanDir); if (!await dir.exists()) return const []; final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$'); final tickets = []; @@ -168,9 +173,9 @@ class TicketStore { if (column != null && column != ticket.column) { // Column changed — move the file to the new column directory. await found.file.delete(); - final newColDir = Directory(p.join(kanbanDir, column)); + final newColDir = fs.directory(p.join(kanbanDir, column)); await newColDir.create(recursive: true); - await File(p.join(newColDir.path, '$id.md')).writeAsString(updated.toFileContent()); + await fs.file(p.join(newColDir.path, '$id.md')).writeAsString(updated.toFileContent()); } else { await found.file.writeAsString(updated.toFileContent()); } @@ -182,26 +187,26 @@ class TicketStore { if (found == null) throw ArgumentError('Ticket $id not found.'); await found.file.delete(); // Clean up per-ticket attachment directory if present. - final attachmentsDir = Directory(p.join(kanbanDir, 'attachments', id)); + final attachmentsDir = fs.directory(p.join(kanbanDir, 'attachments', id)); if (await attachmentsDir.exists()) await attachmentsDir.delete(recursive: true); } /// Searches all column subdirectories (one level deep) for a ticket file. /// Skips the [attachments] directory. Includes [archive]. Future<({File file, String column})?> _findTicketFile(String id) async { - final dir = Directory(kanbanDir); + final dir = fs.directory(kanbanDir); if (!await dir.exists()) return null; await for (final entity in dir.list()) { if (entity is! Directory) continue; if (p.basename(entity.path) == 'attachments') continue; - final file = File(p.join(entity.path, '$id.md')); + final file = fs.file(p.join(entity.path, '$id.md')); if (await file.exists()) return (file: file, column: p.basename(entity.path)); } return null; } Future _nextNumber() async { - final dir = Directory(kanbanDir); + final dir = fs.directory(kanbanDir); if (!await dir.exists()) return 1; final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$'); var max = 0; diff --git a/packages/kanban/pubspec.yaml b/packages/kanban/pubspec.yaml index c48471a..dae1a49 100644 --- a/packages/kanban/pubspec.yaml +++ b/packages/kanban/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: dew_core: path: ../core + file: ^7.0.1 path: ^1.9.0 yaml: ^3.1.0 diff --git a/packages/kanban/test/dew_kanban_test.dart b/packages/kanban/test/dew_kanban_test.dart index df234d8..c7aba9e 100644 --- a/packages/kanban/test/dew_kanban_test.dart +++ b/packages/kanban/test/dew_kanban_test.dart @@ -1,10 +1,31 @@ -import 'dart:io'; - import 'package:dew_core/dew_core.dart'; import 'package:dew_kanban/dew_kanban.dart'; -import 'package:path/path.dart' as p; +import 'package:file/memory.dart'; import 'package:test/test.dart'; +const _testConfig = ''' +dew: + mcp: + host: localhost + port: 9090 + kanban: + prefix: T + ticket_types: + - id: task + name: Task + columns: + - id: todo + name: To Do + color: blue +'''; + +MemoryFileSystem _makeFs() { + final fs = MemoryFileSystem(); + fs.directory('/.project/kanban').createSync(recursive: true); + fs.file('/.project/dew.yaml').writeAsStringSync(_testConfig); + return fs; +} + void main() { group('KanbanCommand', () { test('has correct name and description', () { @@ -75,69 +96,44 @@ void main() { }); test('create and list tools have working handlers', () async { - final tempDir = await Directory.systemTemp.createTemp('kanban_tool_test_'); - final origDir = Directory.current; - try { - await Directory(p.join(tempDir.path, '.project', 'kanban')).create(recursive: true); - await File(p.join(tempDir.path, '.project', 'dew.yaml')).writeAsString(''' -dew: - mcp: - host: localhost - port: 9090 - kanban: - prefix: T - ticket_types: - - id: task - name: Task - columns: - - id: todo - name: To Do - color: blue -'''); - Directory.current = tempDir; - final registry = CommandRegistry(); - registerCommands(registry); - final tools = {for (final t in registry.mcpTools) t.name: t}; + final fs = _makeFs(); + final registry = CommandRegistry(); + registerCommands(registry, fs: fs); + final tools = {for (final t in registry.mcpTools) t.name: t}; - final result = await tools['kanban_create_ticket']!.handler({ - 'title': 'Hello', - 'type': 'task', - }); - expect(result, contains('T-0001')); + final result = await tools['kanban_create_ticket']!.handler({ + 'title': 'Hello', + 'type': 'task', + }); + expect(result, contains('T-0001')); - final listResult = await tools['kanban_list_tickets']!.handler({}); - expect(listResult, contains('T-0001')); + final listResult = await tools['kanban_list_tickets']!.handler({}); + expect(listResult, contains('T-0001')); - final searchResult = await tools['kanban_search_tickets']!.handler({'query': 'Hello'}); - expect(searchResult, contains('T-0001')); + final searchResult = await tools['kanban_search_tickets']!.handler({'query': 'Hello'}); + expect(searchResult, contains('T-0001')); - await tools['kanban_add_comment']!.handler({'id': 'T-0001', 'comment': 'Nice ticket.'}); + await tools['kanban_add_comment']!.handler({'id': 'T-0001', 'comment': 'Nice ticket.'}); - final getResult = await tools['kanban_get_ticket']!.handler({'id': 'T-0001'}); - expect(getResult, contains('Nice ticket.')); + final getResult = await tools['kanban_get_ticket']!.handler({'id': 'T-0001'}); + expect(getResult, contains('Nice ticket.')); - final configResult = await tools['kanban_get_config']!.handler({}); - expect(configResult, contains('todo')); - expect(configResult, contains('task')); + final configResult = await tools['kanban_get_config']!.handler({}); + expect(configResult, contains('todo')); + expect(configResult, contains('task')); - final statsResult = await tools['kanban_stats']!.handler({}); - expect(statsResult, contains('Total: 1')); - expect(statsResult, contains('todo: 1')); - expect(statsResult, contains('task: 1')); - } finally { - Directory.current = origDir; - await tempDir.delete(recursive: true); - } + final statsResult = await tools['kanban_stats']!.handler({}); + expect(statsResult, contains('Total: 1')); + expect(statsResult, contains('todo: 1')); + expect(statsResult, contains('task: 1')); }); }); group('MoveCommand transition validation', () { test('move respects allowed_transitions when configured', () async { - final tempDir = await Directory.systemTemp.createTemp('kanban_transitions_test_'); - final origDir = Directory.current; - try { - await Directory(p.join(tempDir.path, '.project', 'kanban')).create(recursive: true); - await File(p.join(tempDir.path, '.project', 'dew.yaml')).writeAsString(''' + final fs = MemoryFileSystem(); + fs.directory('/.project/kanban').createSync(recursive: true); + fs.file('/.project/dew.yaml').writeAsStringSync(''' dew: mcp: host: localhost @@ -163,48 +159,39 @@ dew: name: Done color: green '''); - Directory.current = tempDir; - final registry = CommandRegistry(); - registerCommands(registry); - final tools = {for (final t in registry.mcpTools) t.name: t}; + final registry = CommandRegistry(); + registerCommands(registry, fs: fs); + final tools = {for (final t in registry.mcpTools) t.name: t}; - await tools['kanban_create_ticket']!.handler({ - 'title': 'Flow test', - 'type': 'task', - }); + await tools['kanban_create_ticket']!.handler({ + 'title': 'Flow test', + 'type': 'task', + }); - // Allowed: backlog → doing - await expectLater( - tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}), - completes, - ); + // Allowed: backlog → doing + await expectLater( + tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}), + completes, + ); - // Disallowed: doing → done is allowed, but doing → backlog is also - // allowed; skip to testing a rejected transition: - // "done" has no allowed_transitions (unconstrained), but let's test - // that "backlog" column can't go directly to "done". - // Reset to backlog first. - await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'backlog'}); + // Reset to backlog first. + await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'backlog'}); - // backlog → done should throw (not in allowed_transitions). - await expectLater( - tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}), - throwsA(isA()), - ); + // backlog → done should throw (not in allowed_transitions). + await expectLater( + tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}), + throwsA(isA()), + ); - // Unconstrained column (done) — any target is valid. - await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}); - await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}); - // done → backlog: done has no constraints, so it's allowed. - final result = await tools['kanban_move_ticket']!.handler({ - 'id': 'T-0001', - 'column': 'backlog', - }); - expect(result, contains('T-0001')); - } finally { - Directory.current = origDir; - await tempDir.delete(recursive: true); - } + // Unconstrained column (done) — any target is valid. + await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'doing'}); + await tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}); + // done → backlog: done has no constraints, so it's allowed. + final result = await tools['kanban_move_ticket']!.handler({ + 'id': 'T-0001', + 'column': 'backlog', + }); + expect(result, contains('T-0001')); }); }); @@ -313,42 +300,30 @@ dew: }); group('TicketStore', () { - late Directory tempDir; - - setUp(() async { - tempDir = await Directory.systemTemp.createTemp('dew_kanban_test_'); - }); - - tearDown(() => tempDir.delete(recursive: true)); - - TicketStore makeStore() => TicketStore( - kanbanDir: p.join(tempDir.path, 'kanban'), + TicketStore makeStore(MemoryFileSystem fs) => TicketStore( + kanbanDir: '/kanban', prefix: 'TEST', + fs: fs, ); test('create assigns incrementing IDs', () async { - final store = makeStore(); - final t1 = await store.create( - title: 'First', - type: 'task', - column: 'todo', - ); - final t2 = await store.create( - title: 'Second', - type: 'bug', - column: 'todo', - ); + final fs = MemoryFileSystem(); + final store = makeStore(fs); + final t1 = await store.create(title: 'First', type: 'task', column: 'todo'); + final t2 = await store.create(title: 'Second', type: 'bug', column: 'todo'); expect(t1.id, 'TEST-0001'); expect(t2.id, 'TEST-0002'); }); test('findById returns null for missing ticket', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); expect(await store.findById('TEST-0099'), isNull); }); test('create and list milestones/labels persist via store', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create( title: 'Tagged', type: 'task', @@ -365,7 +340,8 @@ dew: }); test('update patches milestones and labels', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create( title: 'Tagged', type: 'task', @@ -379,7 +355,8 @@ dew: }); test('list returns sorted tickets', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo'); final all = await store.list(); @@ -387,14 +364,13 @@ dew: }); test('list excludes archive by default, includes with flag', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create(title: 'Active', type: 'task', column: 'todo'); // Manually move to archive dir to simulate archived state. - final kanbanDir = Directory(p.join(tempDir.path, 'kanban')); - final archiveDir = Directory(p.join(kanbanDir.path, 'archive')); - await archiveDir.create(recursive: true); - final src = File(p.join(kanbanDir.path, 'todo', 'TEST-0001.md')); - await src.rename(p.join(archiveDir.path, 'TEST-0001.md')); + fs.directory('/kanban/archive').createSync(recursive: true); + final src = fs.file('/kanban/todo/TEST-0001.md'); + await src.rename('/kanban/archive/TEST-0001.md'); expect(await store.list(), isEmpty); final withArchive = await store.list(includeArchived: true); @@ -403,7 +379,8 @@ dew: }); test('update patches specified fields', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create(title: 'Old', type: 'task', column: 'todo'); final updated = await store.update('TEST-0001', title: 'New'); expect(updated.title, 'New'); @@ -411,7 +388,8 @@ dew: }); test('update throws for missing ticket', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); expect( () => store.update('TEST-0099', title: 'X'), throwsA(isA()), @@ -419,14 +397,16 @@ dew: }); test('delete removes ticket', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create(title: 'Bye', type: 'task', column: 'todo'); await store.delete('TEST-0001'); expect(await store.findById('TEST-0001'), isNull); }); test('delete throws for missing ticket', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); expect( () => store.delete('TEST-0099'), throwsA(isA()), @@ -434,7 +414,8 @@ dew: }); test('linkTickets adds typed link bidirectionally and is idempotent', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo'); await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); @@ -457,7 +438,8 @@ dew: }); test('linkTickets relates_to is symmetric', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo'); await store.linkTickets('TEST-0001', 'TEST-0002', 'relates_to'); @@ -469,7 +451,8 @@ dew: }); test('linkTickets parent_of / child_of inverse pair', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create(title: 'Epic', type: 'task', column: 'todo'); await store.create(title: 'Story', type: 'task', column: 'todo'); await store.linkTickets('TEST-0001', 'TEST-0002', 'parent_of'); @@ -481,13 +464,15 @@ dew: }); test('linkTickets throws for self-link via command', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); // Self-link guard is in the command layer, not the store. }); test('unlinkTickets removes link on both sides', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo'); await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); @@ -500,7 +485,8 @@ dew: }); test('stats returns correct counts', () async { - final store = makeStore(); + final fs = MemoryFileSystem(); + final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'done'); await store.create(title: 'C', type: 'bug', column: 'todo'); @@ -512,7 +498,4 @@ dew: expect((s['byType'] as Map)['bug'], 1); }); }); - } - - diff --git a/pubspec.lock b/pubspec.lock index a54bbc3..7129dd0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -138,7 +138,7 @@ packages: source: hosted version: "0.5.0" file: - dependency: transitive + dependency: "direct dev" description: name: file sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 diff --git a/pubspec.yaml b/pubspec.yaml index 6870225..7f23ebf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ workspace: - packages/mcp dev_dependencies: + file: ^7.0.1 lints: ^6.0.0 melos: ^7.0.0