Refactor filesystem access to package:file

Replace dart:io File/Directory with package:file abstractions so that
tests can use MemoryFileSystem instead of mutating the process-global
Directory.current.

- Add file: ^7.0.1 to core and kanban dependencies
- ProjectContext.find() accepts FileSystem fs parameter
- TicketStore, KanbanInitHook, InitCommand, all kanban commands accept
  FileSystem fs (defaulting to LocalFileSystem())
- KanbanCommand and registerCommands() thread fs to subcommands
- Tests rewritten to use MemoryFileSystem() — no Directory.current mutation
- Remove dart_test.yaml (concurrency: 1 no longer needed)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Hendrickson 2026-04-23 22:26:09 -04:00
parent 037f5fde28
commit 8d787235b9
28 changed files with 316 additions and 255 deletions

View file

@ -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

View file

@ -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<ProjectContext> find({Directory? from}) async {
var dir = from ?? Directory.current;
static Future<ProjectContext> 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;

View file

@ -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<void> {
final List<DewInitHook> _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<void> {
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);

View file

@ -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

View file

@ -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, '/');
});
});
}

View file

@ -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));
}

View file

@ -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.';

View file

@ -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<String> callAsTool(Map<String, dynamic> 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.';

View file

@ -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();

View file

@ -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<String> callAsTool(Map<String, dynamic> 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,

View file

@ -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<String> callAsTool(Map<String, dynamic> 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.';

View file

@ -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<String> callAsTool(Map<String, dynamic> 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.');

View file

@ -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<String> callAsTool(Map<String, dynamic> 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(', ');

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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<String> callAsTool(Map<String, dynamic> 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();

View file

@ -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<String> callAsTool(Map<String, dynamic> 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".';

View file

@ -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);

View file

@ -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,

View file

@ -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

View file

@ -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<void> onInit(
String projectRoot,
@ -26,7 +30,7 @@ class KanbanInitHook implements DewInitHook {
}
Future<void> _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');
}
}

View file

@ -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<Ticket> create({
required String title,
@ -18,7 +23,7 @@ class TicketStore {
List<String> milestones = const [],
List<String> 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<Ticket>> 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 = <Ticket>[];
@ -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<int> _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;

View file

@ -12,6 +12,7 @@ environment:
dependencies:
dew_core:
path: ../core
file: ^7.0.1
path: ^1.9.0
yaml: ^3.1.0

View file

@ -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<ArgumentError>()),
);
// backlog done should throw (not in allowed_transitions).
await expectLater(
tools['kanban_move_ticket']!.handler({'id': 'T-0001', 'column': 'done'}),
throwsA(isA<ArgumentError>()),
);
// 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<ArgumentError>()),
@ -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<ArgumentError>()),
@ -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);
});
});
}

View file

@ -138,7 +138,7 @@ packages:
source: hosted
version: "0.5.0"
file:
dependency: transitive
dependency: "direct dev"
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4

View file

@ -14,6 +14,7 @@ workspace:
- packages/mcp
dev_dependencies:
file: ^7.0.1
lints: ^6.0.0
melos: ^7.0.0