Add ProjectDirs extension pattern and injectable clock

- core: Add ProjectDirs helper class on ProjectContext (context.dirs)
  with project getter for .project/ dir; feature packages extend it
  via extension methods to expose their own dirs

- kanban: Add KanbanDirs extension on ProjectDirs exposing .kanban;
  replace all 14 p.join(context.root, '.project', 'kanban') call
  sites with context.dirs.kanban; drop now-unused path imports

- kanban: Add clock: DateTime Function() field to TicketStore
  (defaults to DateTime.now); use clock().toUtc() in create() for
  deterministic timestamps in tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Hendrickson 2026-04-23 23:15:38 -04:00
parent 8d787235b9
commit 53f9493364
17 changed files with 43 additions and 29 deletions

View file

@ -14,6 +14,18 @@ class DewConfig {
factory DewConfig.fromYaml(YamlMap yaml) => DewConfig(raw: yaml); factory DewConfig.fromYaml(YamlMap yaml) => DewConfig(raw: yaml);
} }
/// Path helper for well-known directories under a project root.
///
/// Feature packages extend this via extension methods to expose their own
/// directories (e.g. [KanbanDirs.kanban]).
class ProjectDirs {
final String _root;
const ProjectDirs(this._root);
/// `.project/` directory.
String get project => p.join(_root, '.project');
}
/// Locates the nearest project root and exposes the parsed [DewConfig]. /// Locates the nearest project root and exposes the parsed [DewConfig].
class ProjectContext { class ProjectContext {
final String root; final String root;
@ -22,6 +34,9 @@ class ProjectContext {
const ProjectContext({required this.root, required this.config, required this.fs}); const ProjectContext({required this.root, required this.config, required this.fs});
/// Typed path helpers for this project's well-known directories.
ProjectDirs get dirs => ProjectDirs(root);
/// Walks up from [from] (defaults to [fs.currentDirectory]) until a /// Walks up from [from] (defaults to [fs.currentDirectory]) until a
/// `.project/dew.yaml` is found. /// `.project/dew.yaml` is found.
static Future<ProjectContext> find({ static Future<ProjectContext> find({

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -31,7 +30,7 @@ class AddCommentCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -1,8 +1,8 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../kanban_config.dart';
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -28,7 +28,7 @@ class ArchiveCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
final kanbanDir = p.join(context.root, '.project', 'kanban'); final kanbanDir = context.dirs.kanban;
final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs); final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs);
final ticket = await store.findById(id); final ticket = await store.findById(id);

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket.dart'; import '../ticket.dart';
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -35,7 +34,7 @@ class BoardCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: config.prefix, prefix: config.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -67,7 +66,7 @@ class CreateCommand extends DewCommand with DewToolCommand {
} }
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: config.prefix, prefix: config.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -32,7 +31,7 @@ class DeleteCommand extends DewCommand with DewToolCommand {
final id = (args['id'] as String).toUpperCase(); final id = (args['id'] as String).toUpperCase();
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket.dart'; import '../ticket.dart';
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -33,7 +32,7 @@ class GetCommand extends DewCommand with DewToolCommand {
final id = (args['id'] as String).toUpperCase(); final id = (args['id'] as String).toUpperCase();
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket.dart'; import '../ticket.dart';
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -44,7 +43,7 @@ class LinkCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket.dart'; import '../ticket.dart';
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -38,7 +37,7 @@ class ListCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -40,7 +39,7 @@ class MoveCommand extends DewCommand with DewToolCommand {
} }
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: config.prefix, prefix: config.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -44,7 +43,7 @@ class SearchCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -24,7 +23,7 @@ class StatsCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: config.prefix, prefix: config.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -1,8 +1,8 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../kanban_config.dart';
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -34,7 +34,7 @@ class UnarchiveCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final config = context.config.kanban; final config = context.config.kanban;
final kanbanDir = p.join(context.root, '.project', 'kanban'); final kanbanDir = context.dirs.kanban;
final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs); final store = TicketStore(kanbanDir: kanbanDir, prefix: config.prefix, fs: context.fs);
final ticket = await store.findById(id); final ticket = await store.findById(id);

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -31,7 +30,7 @@ class UnlinkCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: context.config.kanban.prefix, prefix: context.config.kanban.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -2,7 +2,6 @@ import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import '../kanban_config.dart'; import '../kanban_config.dart';
import 'package:path/path.dart' as p;
import '../ticket_store.dart'; import '../ticket_store.dart';
@ -79,7 +78,7 @@ class UpdateCommand extends DewCommand with DewToolCommand {
} }
final store = TicketStore( final store = TicketStore(
kanbanDir: p.join(context.root, '.project', 'kanban'), kanbanDir: context.dirs.kanban,
prefix: config.prefix, prefix: config.prefix,
fs: context.fs, fs: context.fs,
); );

View file

@ -1,4 +1,5 @@
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart'; import 'package:yaml/yaml.dart';
class TicketTypeConfig { class TicketTypeConfig {
@ -63,3 +64,9 @@ extension KanbanDewConfig on DewConfig {
); );
} }
} }
/// Extends [ProjectDirs] with the kanban board directory.
extension KanbanDirs on ProjectDirs {
/// Absolute path to `.project/kanban/`.
String get kanban => p.join(project, 'kanban');
}

View file

@ -9,10 +9,14 @@ class TicketStore {
final String prefix; final String prefix;
final FileSystem fs; final FileSystem fs;
/// Provides the current time for ticket creation. Injectable for testing.
final DateTime Function() clock;
const TicketStore({ const TicketStore({
required this.kanbanDir, required this.kanbanDir,
required this.prefix, required this.prefix,
this.fs = const LocalFileSystem(), this.fs = const LocalFileSystem(),
this.clock = DateTime.now,
}); });
Future<Ticket> create({ Future<Ticket> create({
@ -31,7 +35,7 @@ class TicketStore {
title: title, title: title,
type: type, type: type,
column: column, column: column,
created: DateTime.now().toUtc(), created: clock().toUtc(),
body: body, body: body,
comments: const [], comments: const [],
milestones: milestones, milestones: milestones,