mamba 0.6.0
mamba: ^0.6.0 copied to clipboard
A list-defined command-line parser with typed declaration handles.
import 'dart:convert';
import 'dart:io';
import 'package:mamba/mamba.dart';
Future<void> main(List<String> args) {
final store = TaskStore(
File(
'${Directory.systemTemp.path}${Platform.pathSeparator}mamba_tasks.json',
),
);
return Executor('task-cli', 'Manage a persisted task list.', '1.0.0', [
CreateTaskCommand(store),
ListTaskCommand(store),
ReadTaskCommand(store),
UpdateTaskCommand(store),
DeleteTaskCommand(store),
CompleteTaskCommand(store),
ReopenTaskCommand(store),
ExportTasksCommand(store),
CompletionTaskCommand(),
]).create().execute(args);
}
final class Task {
const Task({
required this.id,
required this.title,
required this.description,
required this.completed,
});
final int id;
final String title;
final String description;
final bool completed;
Task copyWith({String? title, String? description, bool? completed}) => Task(
id: id,
title: title ?? this.title,
description: description ?? this.description,
completed: completed ?? this.completed,
);
Map<String, Object> toJson() => {
'id': id,
'title': title,
'description': description,
'completed': completed,
};
factory Task.fromJson(Map<String, dynamic> json) => Task(
id: json['id'] as int,
title: json['title'] as String,
description: json['description'] as String,
completed: json['completed'] as bool? ?? false,
);
}
final class TaskStore {
const TaskStore(this.file);
final File file;
({int nextId, List<Task> tasks}) _read() {
if (!file.existsSync()) return (nextId: 1, tasks: <Task>[]);
try {
final decoded = jsonDecode(file.readAsStringSync());
if (decoded case {
'nextId': final int nextId,
'tasks': final List<dynamic> taskData,
}) {
return (
nextId: nextId,
tasks: taskData
.map((item) => Task.fromJson(item as Map<String, dynamic>))
.toList(),
);
}
throw const FormatException('Unexpected task-list structure.');
} on FileSystemException catch (error) {
throw MambaException('Could not read the task list: ${error.message}.');
} on FormatException catch (error) {
throw MambaException('Could not read the task list: ${error.message}');
} on TypeError {
throw MambaException('Could not read the task list: invalid task data.');
}
}
List<Task> readAll() => List.unmodifiable(_read().tasks);
void _write(int nextId, List<Task> tasks) {
try {
file.parent.createSync(recursive: true);
file.writeAsStringSync(
JsonEncoder.withIndent(' ').convert({
'nextId': nextId,
'tasks': tasks.map((task) => task.toJson()).toList(),
}),
flush: true,
);
} on FileSystemException catch (error) {
throw MambaException('Could not save the task list: ${error.message}.');
}
}
Task? find(int id) => readAll().where((task) => task.id == id).firstOrNull;
Task add(String title, String description) {
final document = _read();
final task = Task(
id: document.nextId,
title: title,
description: description,
completed: false,
);
_write(document.nextId + 1, [...document.tasks, task]);
return task;
}
Task update(int id, {String? title, String? description, bool? completed}) {
final document = _read();
final index = document.tasks.indexWhere((task) => task.id == id);
if (index < 0) throw MambaException('Task $id was not found.');
final updated = document.tasks[index].copyWith(
title: title,
description: description,
completed: completed,
);
final tasks = [...document.tasks]..[index] = updated;
_write(document.nextId, tasks);
return updated;
}
void delete(int id) {
final document = _read();
final tasks = document.tasks.where((task) => task.id != id).toList();
if (tasks.length == document.tasks.length) {
throw MambaException('Task $id was not found.');
}
_write(document.nextId, tasks);
}
void setCompleted(int id, bool completed) => update(id, completed: completed);
}
String _validatedText(String field, String value) {
final text = value.trim();
if (text.isEmpty) throw MambaException('$field must not be empty.');
return text;
}
int _taskId(CommandInvocation invocation, NormalPositional input) =>
int.parse(invocation.valueOf(input));
final class CreateTaskCommand extends Command {
CreateTaskCommand(this.store) : super(options: [title, description]);
static final title = StringOption.required(
'title',
description: 'Task title.',
regex: RegExp(r'.+'),
);
static final description = StringOption.required(
'description',
description: 'Task description.',
regex: RegExp(r'.+'),
);
final TaskStore store;
@override
String get name => 'create';
@override
String get shortDescription => 'Create a task.';
@override
String run(CommandInvocation invocation, List<String> args) {
final task = store.add(
_validatedText('title', invocation.valueOf(title)),
_validatedText('description', invocation.valueOf(description)),
);
return 'Created task ${task.id}: ${task.title}';
}
}
enum TaskStatus { all, completed, pending }
final class ListTaskCommand extends Command {
ListTaskCommand(this.store) : super(options: [status]);
static final status = ChoiceOption.withDefault(
'status',
choices: TaskStatus.values,
defaultValue: TaskStatus.all,
description: 'Filter tasks by completion status.',
);
final TaskStore store;
@override
String get name => 'list';
@override
String get shortDescription =>
'List tasks, optionally filtered by completion.';
@override
String run(CommandInvocation invocation, List<String> args) {
final statusValue = invocation.valueOf(status);
final tasks = store.readAll().where((task) {
return switch (statusValue) {
TaskStatus.all => true,
TaskStatus.completed => task.completed,
TaskStatus.pending => !task.completed,
};
}).toList();
if (tasks.isEmpty) return 'No matching tasks.';
return tasks
.map(
(task) =>
'${task.completed ? '[x]' : '[ ]'} ${task.id}: ${task.title} — ${task.description}',
)
.join('\n');
}
}
abstract class TaskIdCommand extends Command {
TaskIdCommand() : super(mandatoryPositionals: [id]);
static final id = NormalPositional('id', regExp: RegExp(r'\d+'));
}
final class ReadTaskCommand extends TaskIdCommand {
ReadTaskCommand(this.store);
final TaskStore store;
@override
String get name => 'read';
@override
String get shortDescription => 'Read one task.';
@override
String run(CommandInvocation invocation, List<String> args) {
final task = store.find(_taskId(invocation, TaskIdCommand.id));
if (task == null) throw MambaException('Task not found.');
return '${task.completed ? '[x]' : '[ ]'} ${task.id}: ${task.title}\n${task.description}';
}
}
typedef TaskChanges = ({String title, String description});
final class UpdateTaskCommand extends Command {
UpdateTaskCommand(this.store)
: super(mandatoryPositionals: [id], pairedOptions: [changes]);
static final id = NormalPositional('id', regExp: RegExp(r'\d+'));
static final title = PairStringOption(
'title',
regex: RegExp(r'.+'),
description: 'Replacement title.',
);
static final description = PairStringOption(
'description',
regex: RegExp(r'.+'),
description: 'Replacement description.',
);
static final changes = PairedOptions.required(
[title, description],
(values) => (
title: values.valueOf(title),
description: values.valueOf(description),
),
);
final TaskStore store;
@override
String get name => 'update';
@override
String get shortDescription => 'Update a task.';
@override
String run(CommandInvocation invocation, List<String> args) {
final idValue = _taskId(invocation, id);
final changesValue = invocation.valueOf(changes);
store.update(
idValue,
title: _validatedText('title', changesValue.title),
description: _validatedText('description', changesValue.description),
);
return 'Updated task $idValue.';
}
}
final class DeleteTaskCommand extends TaskIdCommand {
DeleteTaskCommand(this.store);
final TaskStore store;
@override
String get name => 'delete';
@override
String get shortDescription => 'Delete a task.';
@override
String run(CommandInvocation invocation, List<String> args) {
final id = _taskId(invocation, TaskIdCommand.id);
store.delete(id);
return 'Deleted task $id.';
}
}
sealed class TaskExport {
const TaskExport(this.path);
final String path;
}
final class JsonTaskExport extends TaskExport {
const JsonTaskExport(super.path);
}
final class TextTaskExport extends TaskExport {
const TextTaskExport(super.path);
}
final class ExportTasksCommand extends Command {
ExportTasksCommand(this.store) : super(selectedOptions: [output]);
static final json = PairStringOption(
'json',
description: 'Write JSON to this path.',
);
static final text = PairStringOption(
'text',
description: 'Write text to this path.',
);
static final output = SelectedOptions.required([
SelectableOption(json, JsonTaskExport.new),
SelectableOption(text, TextTaskExport.new),
]);
final TaskStore store;
@override
String get name => 'export';
@override
String get shortDescription => 'Export every task.';
@override
String run(CommandInvocation invocation, List<String> args) {
final export = invocation.valueOf(output);
final content = switch (export) {
JsonTaskExport() => JsonEncoder.withIndent(
' ',
).convert(store.readAll().map((task) => task.toJson()).toList()),
TextTaskExport() =>
store.readAll().map((task) => '${task.id}: ${task.title}').join('\n'),
};
File(export.path).writeAsStringSync(content);
return 'Exported tasks to ${export.path}.';
}
}
final class CompleteTaskCommand extends TaskIdCommand {
CompleteTaskCommand(this.store);
final TaskStore store;
@override
String get name => 'complete';
@override
String get shortDescription => 'Mark a task as completed.';
@override
String run(CommandInvocation invocation, List<String> args) {
final id = _taskId(invocation, TaskIdCommand.id);
store.setCompleted(id, true);
return 'Completed task $id.';
}
}
final class ReopenTaskCommand extends TaskIdCommand {
ReopenTaskCommand(this.store);
final TaskStore store;
@override
String get name => 'reopen';
@override
String get shortDescription => 'Mark a task as pending.';
@override
String run(CommandInvocation invocation, List<String> args) {
final id = _taskId(invocation, TaskIdCommand.id);
store.setCompleted(id, false);
return 'Reopened task $id.';
}
}
final class CompletionTaskCommand extends CompletionCommand {
CompletionTaskCommand() : super(options: [output]);
static final output = StringOption.required(
'output',
description: 'Write the Carapace spec to this path.',
);
@override
String get name => 'completion';
@override
String get shortDescription => 'Generate the Carapace completion spec.';
@override
String run(CommandInvocation invocation, List<String> args) {
final path = invocation.valueOf(output);
CarapaceSpecWriter(
CarapaceSpecConverter(registryRecord),
outputPath: path,
).write();
return 'Wrote Carapace spec to $path.';
}
}