diff --git a/lib/cli/cli_args.dart b/lib/cli/cli_args.dart index 1ba53f9..a345ebf 100644 --- a/lib/cli/cli_args.dart +++ b/lib/cli/cli_args.dart @@ -63,6 +63,9 @@ class CliArgs { case '--get' || '--delete' || '--close': modes.add(CliMode.values.byName(arg.substring(2))); id = value(); + case '--set-text': + modes.add(CliMode.setText); + id = value(); case '--set-status': modes.add(CliMode.setStatus); id = value(); @@ -74,7 +77,8 @@ class CliArgs { if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help); if (modes.length > 1) { throw UsageError( - '--list, --search, --get, --delete, --set-status, --close and ' + '--list, --search, --get, --delete, --set-status, --set-text, ' + '--close and ' '--backfill-created-by are mutually exclusive', ); } @@ -110,7 +114,8 @@ class CliArgs { /// The selected mode. final CliMode mode; - /// Note text from `--text`; null means "read stdin" in [CliMode.add]. + /// Note text from `--text`; null means "read stdin" in [CliMode.add] and + /// [CliMode.setText]. final String? text; /// Full id or 8+ character prefix of the note an id mode acts on. @@ -146,7 +151,7 @@ class CliArgs { static const _adding = 'when adding a note'; static const Map)> _scopes = { - '--text': (_adding, {CliMode.add}), + '--text': ('$_adding or to --set-text', {CliMode.add, CliMode.setText}), '--done-check': (_adding, {CliMode.add}), '--by': (_adding, {CliMode.add}), '--repo': ( diff --git a/lib/cli/cli_commands.dart b/lib/cli/cli_commands.dart index a00ec68..f185800 100644 --- a/lib/cli/cli_commands.dart +++ b/lib/cli/cli_commands.dart @@ -136,6 +136,15 @@ class CliCommands { return 0; } + /// Replaces a note's whole text (title is its first line) and pushes it. + Future setText(String ref, String text) async { + final note = await _pullAndFind(ref); + await _save(note.copyWith(text: text)); + if (!await _push('text change')) return 1; + _out.writeln('${note.id}\ttext'); + return 0; + } + /// Marks a note done with [evidence], keeping it, and pushes it. Future close(String ref, String evidence) async { final note = await _pullAndFind(ref); diff --git a/lib/cli/cli_mode.dart b/lib/cli/cli_mode.dart index b1671fa..0125bea 100644 --- a/lib/cli/cli_mode.dart +++ b/lib/cli/cli_mode.dart @@ -18,6 +18,9 @@ enum CliMode { /// Change one note's status and push it. setStatus, + /// Replace one note's whole text and push it. + setText, + /// Set a task done with evidence and push it. close, diff --git a/lib/cli/cli_runner.dart b/lib/cli/cli_runner.dart index d29af6f..886bf04 100644 --- a/lib/cli/cli_runner.dart +++ b/lib/cli/cli_runner.dart @@ -54,7 +54,7 @@ Future runCli( return 0; } String? text; - if (args.mode == CliMode.add) { + if (args.mode == CliMode.add || args.mode == CliMode.setText) { text = args.text ?? await readStdin(); if (text.trim().isEmpty) { err.writeln('error: empty note (pass --text or pipe text on stdin)'); @@ -79,6 +79,7 @@ Future runCli( CliMode.list || CliMode.search => await cli.list(args), CliMode.get => await cli.get(args.id!), CliMode.setStatus => await cli.setStatus(args.id!, args.status!), + CliMode.setText => await cli.setText(args.id!, text!), CliMode.close => await cli.close(args.id!, args.opts.evidence!), CliMode.backfill => await cli.backfillCreatedBy( claudeNodeIds(env), diff --git a/lib/cli/cli_usage.dart b/lib/cli/cli_usage.dart index f02f673..2758338 100644 --- a/lib/cli/cli_usage.dart +++ b/lib/cli/cli_usage.dart @@ -22,6 +22,8 @@ A note with --repo is a repo task; one without is a plain idea. --get pull, then print one note in full (exit 1 if absent) --delete pull, tombstone the note, push --set-status pull, change the note's status (as --status), push + --set-text pull, replace the note's whole text with --text (or + stdin; first line = title), keep its other fields, push --close --evidence pull, set status done + evidence (commit sha + done check exit code), push; refused without --evidence diff --git a/pubspec.yaml b/pubspec.yaml index 019bf52..a123c84 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.71+1 +version: 1.0.72+1 environment: sdk: ^3.12.2 diff --git a/test/cli/cli_harness.dart b/test/cli/cli_harness.dart index aa9d1c2..cc2cd5d 100644 --- a/test/cli/cli_harness.dart +++ b/test/cli/cli_harness.dart @@ -32,7 +32,11 @@ class CliHarness { String dir(String name) => '${tmp.path}/$name'; - Future run(List argv, {SessionOpener? opener}) async { + Future run( + List argv, { + SessionOpener? opener, + String stdin = '', + }) async { out.clear(); err.clear(); return runCli( @@ -40,7 +44,7 @@ class CliHarness { env: env, out: out, err: err, - readStdin: () async => '', + readStdin: () async => stdin, open: opener ?? (d, {required seedCredentials}) => open(d, seedCredentials), ); diff --git a/test/cli/cli_set_text_test.dart b/test/cli/cli_set_text_test.dart new file mode 100644 index 0000000..7f1a97c --- /dev/null +++ b/test/cli/cli_set_text_test.dart @@ -0,0 +1,83 @@ +// --set-text: replace a note's whole text, keeping every other field. +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/cli/cli_args.dart'; +import 'package:todo/cli/cli_mode.dart'; +import 'package:todo/cli/cli_session.dart'; + +import 'cli_harness.dart'; +import 'scripted_session.dart'; + +void main() { + late CliHarness h; + setUp(() => h = CliHarness()); + tearDown(() => h.dispose()); + + test('--set-text replaces the text and keeps repo and status', () async { + final id = await h.add('Old title\nold body', ['--repo', 'kuhyx/todo']); + expect(await h.run(['--set-status', id, 'in_progress']), 0); + expect( + await h.run([ + '--set-text', + id.substring(0, 8), + '--text', + 'New title\nnew body', + '--data-dir', + h.dir('a'), + ]), + 0, + ); + expect(h.out.toString().trim(), '$id\ttext'); + expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0); + expect( + h.out.toString(), + allOf( + contains('New title\nnew body'), + isNot(contains('old body')), + contains('repo: kuhyx/todo'), + contains('in_progress'), + ), + ); + }); + + test('--set-text reads stdin when --text is absent', () async { + final id = await h.add('Old'); + expect(await h.run(['--set-text', id], stdin: 'From stdin'), 0); + expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0); + expect(h.out.toString(), contains('From stdin')); + }); + + test('--set-text refuses empty text and changes nothing', () async { + final id = await h.add('Keep me'); + expect(await h.run(['--set-text', id, '--text', ' ']), 2); + expect(h.err.toString(), contains('empty note')); + expect(await h.run(['--get', id]), 0); + expect(h.out.toString(), contains('Keep me')); + }); + + test('--set-text on a missing id exits 1', () async { + expect(await h.run(['--set-text', 'ffffffff', '--text', 'x']), 1); + expect(h.err.toString(), contains('no live note with id ffffffff')); + }); + + test('--set-text fails when the push reports nothing published', () async { + Future opener(String d, {required String seedCredentials}) => + ScriptedSession.create(h.db.client, [true, false], noteId: 'stuck'); + expect( + await h.run(['--set-text', 'stuck', '--text', 'x'], opener: opener), + 1, + ); + expect(h.err.toString(), contains('text change saved locally')); + }); + + test('parse: --set-text takes an id and allows --text', () { + final args = CliArgs.parse(['--set-text', 'abc', '--text', 'T']); + expect(args.mode, CliMode.setText); + expect(args.id, 'abc'); + expect(args.text, 'T'); + expect(() => CliArgs.parse(['--set-text']), throwsA(isA())); + expect( + () => CliArgs.parse(['--set-text', 'a', '--repo', 'kuhyx/todo']), + throwsA(isA()), + ); + }); +}