todo-app/lib/cli/cli_args.dart
Krzysztof kuhy Rudnicki 9349747629
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Repo tasks: repo/done_check/by/evidence fields, filter chips, CLI close/list/search
Four optional note fields (repo, done_check, by, evidence) in the crdt_sync
record, written only when set, so editing a plain idea stays wire-identical
to v3 (note_repository_sync_compat_test). The log is schemaless: no stored
schema version; the legacy sqlite reader stays at v3.

NoteFilter gains a sources facet (ideas / every repo task / one repo);
search also matches the repo. The notes list shows only open ideas by
default (no badge); the filter sheet adds Ideas, Repo tasks and one chip
per repo when repo tasks exist. The per-note sheet shows the task fields
read-only. BACKLOG.md markers carry the new attributes, entity-escaped.

CLI: --repo --done-check --by on add, --close <id> --evidence (refused
without), --list [--repo] [--status], --search, and every id flag takes a
unique 8-character prefix. --list prints id8, status, priority, repo, title.

install_hooks.sh: the wrapper finds pre-commit's generated hook next to
itself, so commits from a git worktree (where .git is a file) work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 08:26:26 +01:00

238 lines
6.4 KiB
Dart

/// Hand-rolled argument parsing for `bin/todo_note.dart`.
///
/// Deliberately not `package:args`: a handful of flags do not justify a new
/// exact-pinned dependency under the freshness gate.
library;
import 'package:todo/data/note.dart';
export 'package:todo/cli/cli_usage.dart';
/// What the CLI was asked to do.
enum CliMode {
/// Create a note and push it.
add,
/// Print every live note, one per line.
list,
/// Print one note in full.
get,
/// Tombstone one note and push the deletion.
delete,
/// Change one note's status and push it.
setStatus,
/// Set a task done with evidence and push it.
close,
/// Print the notes matching a query, as [list] does.
search,
/// Print usage.
help,
}
/// Thrown for a malformed command line; maps to exit code 2.
class UsageError implements Exception {
/// Creates a [UsageError] carrying [message].
UsageError(this.message);
/// What was wrong with the arguments.
final String message;
@override
String toString() => message;
}
/// The parsed command line.
class CliArgs {
/// Creates parsed arguments; see [parse].
const CliArgs({
required this.mode,
this.text,
this.id,
this.priority = Priority.medium,
this.status,
this.dataDir,
this.opts = (
repo: null,
query: null,
doneCheck: null,
by: null,
evidence: null,
),
});
/// Parses [argv]. Throws [UsageError] on anything it does not understand.
factory CliArgs.parse(List<String> argv) {
final opts = <String, String>{};
String? id;
final modes = <CliMode>{};
for (var i = 0; i < argv.length; i++) {
final arg = argv[i];
String value() {
if (i + 1 >= argv.length) throw UsageError('$arg needs a value');
return argv[++i];
}
switch (arg) {
case '-h' || '--help':
modes.add(CliMode.help);
case '--text' || '--priority' || '--status' || '--data-dir':
case '--repo' || '--done-check' || '--by' || '--evidence':
opts[arg] = value();
case '--list':
modes.add(CliMode.list);
case '--search':
modes.add(CliMode.search);
opts[arg] = value();
case '--get' || '--delete' || '--close':
modes.add(CliMode.values.byName(arg.substring(2)));
id = value();
case '--set-status':
modes.add(CliMode.setStatus);
id = value();
opts[arg] = value();
default:
throw UsageError('unknown argument: $arg');
}
}
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
if (modes.length > 1) {
throw UsageError(
'--list, --search, --get, --delete, --set-status and --close are '
'mutually exclusive',
);
}
final mode = modes.isEmpty ? CliMode.add : modes.single;
_checkScopes(opts.keys, mode);
final rawStatus = opts['--set-status'] ?? opts['--status'];
return CliArgs(
mode: mode,
text: opts['--text'],
id: id,
priority: opts['--priority'] == null
? Priority.medium
: _byName(Priority.values, opts['--priority']!, '--priority'),
status: rawStatus == null
? null
: _byName(Status.values, rawStatus, '--status', _statusName),
dataDir: opts['--data-dir'],
opts: (
repo: _repo(opts['--repo']),
query: _nonBlank(opts['--search'], '--search'),
doneCheck: _nonBlank(opts['--done-check'], '--done-check'),
by: opts['--by'] == null ? null : _by(opts['--by']!),
evidence: _evidence(opts['--evidence'], mode),
),
);
}
/// The selected mode.
final CliMode mode;
/// Note text from `--text`; null means "read stdin" in [CliMode.add].
final String? text;
/// Full id or 8+ character prefix of the note an id mode acts on.
final String? id;
/// Priority of a new note.
final Priority priority;
/// New note's status, [CliMode.setStatus]'s target, or a list filter.
final Status? status;
/// Overrides the default data dir (a second, independent node).
final String? dataDir;
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
final ({
String? repo,
String? query,
String? doneCheck,
String? by,
String? evidence,
})
opts;
/// The command-line spelling of [status] (`in_progress`, not `inProgress`).
static String statusName(Status status) => _statusName(status);
static const _adding = 'when adding a note';
static const Map<String, (String, Set<CliMode>)> _scopes = {
'--text': (_adding, {CliMode.add}),
'--done-check': (_adding, {CliMode.add}),
'--by': (_adding, {CliMode.add}),
'--repo': (
'$_adding, to --list or to --search',
{CliMode.add, CliMode.list, CliMode.search},
),
'--evidence': ('to --close', {CliMode.close}),
};
static void _checkScopes(Iterable<String> flags, CliMode mode) {
for (final flag in flags) {
final scope = _scopes[flag];
if (scope != null && !scope.$2.contains(mode)) {
throw UsageError('$flag only applies ${scope.$1}');
}
}
}
static final _repoShape = RegExp(r'^[\w.-]+/[\w.-]+$');
static String? _repo(String? raw) {
if (raw == null || _repoShape.hasMatch(raw)) return raw;
throw UsageError(
'--repo must look like owner/name (e.g. kuhyx/todo), got "$raw"',
);
}
static String _by(String raw) {
if (kNoteAuthors.contains(raw)) return raw;
throw UsageError(
'--by must be one of ${kNoteAuthors.join('|')}, got "$raw"',
);
}
static String? _evidence(String? raw, CliMode mode) {
if (mode == CliMode.close && (raw == null || raw.trim().isEmpty)) {
throw UsageError(
'--close needs --evidence <commit sha + done check exit code>: '
'a task is only closed with proof',
);
}
return raw;
}
static String? _nonBlank(String? raw, String flag) {
if (raw != null && raw.trim().isEmpty) {
throw UsageError('$flag must not be empty');
}
return raw;
}
static String _statusName(Enum value) =>
value == Status.inProgress ? 'in_progress' : value.name;
static T _byName<T extends Enum>(
List<T> values,
String raw,
String flag, [
String Function(Enum) nameOf = _plainName,
]) {
for (final v in values) {
if (nameOf(v) == raw) return v;
}
throw UsageError(
'$flag must be one of ${values.map(nameOf).join('|')}, got "$raw"',
);
}
static String _plainName(Enum value) => value.name;
}