onetaskgraph 0.2.36

One interface over the ticketing systems your work lives in.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
//! The command line, and the configuration layer it contributes.
//!
//! Flags are the highest of the three layers, and every setting is reachable here —
//! including every field of every named source, through `--set`. That is what makes
//! the product scriptable: a caller who can name a setting in a document can name the
//! same setting on the command line, at the same dotted path.

use std::num::NonZeroU32;

use clap::{Args, Parser, Subcommand, ValueEnum};
use onetaskgraph_core::config::{Layer, Origin, Setting, SettingPath, value_from_text};
use onetaskgraph_core::{OutputFormat, PluginKind, SearchKind};
use onetaskgraph_plugin_api::{Direction, NativeId, StatusCategory, TextFields};
use serde_json::Value;

/// One interface over the ticketing systems your work lives in.
///
/// Exit codes: `0` on success, `1` when a command failed while running, `2` when the
/// invocation itself was wrong (clap's own code for that), `4` when a query succeeded
/// for some sources and failed for others without `--allow-partial`, or when a write landed
/// and a task it delivers could not be kept in step with it. `0` means success
/// and nothing else: a run that reached no source, or lost one, never exits `0` unless
/// you asked for a partial answer.
#[derive(Debug, Parser)]
// `bin_name` is pinned rather than left to clap, which takes it from argv[0] — and on
// Windows argv[0] is `onetaskgraph.exe`, so the usage line would name a different command
// there than the one this declares and than the one the documentation tells a user to type.
#[command(name = "onetaskgraph", bin_name = "onetaskgraph", version)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Command,

    #[command(flatten)]
    pub overrides: Overrides,
}

/// The verbs this binary answers.
#[derive(Debug, Subcommand)]
pub enum Command {
    /// Host one compiled-in source over the stdio plugin protocol.
    #[command(hide = true)]
    PluginServe {
        /// The compiled-in plugin kind to host.
        source: PluginKind,
    },

    /// Print the JSON Schema bundle the contract types generate.
    ///
    /// Both SDKs are generated from this document, so it is emitted from the
    /// running binary rather than committed: the schema and the types that
    /// serialise cannot drift when they are the same types.
    Schema,

    /// Work with the configuration this command reads.
    Config {
        #[command(subcommand)]
        command: ConfigCommand,
    },

    /// Work with the sources this configuration names.
    Sources {
        #[command(subcommand)]
        command: SourcesCommand,
    },

    /// List, show and walk tasks.
    Task {
        #[command(subcommand)]
        command: TaskCommand,
    },

    /// List, show and walk projects.
    Project {
        #[command(subcommand)]
        command: ProjectCommand,
    },

    /// List, show and copy documents.
    ///
    /// A document is not work: it has no status and takes part in no dependency graph, so
    /// this group carries no `--status` filter and no `deps` verb.
    Document {
        #[command(subcommand)]
        command: DocumentCommand,
    },

    /// List the labels the sources know.
    Label {
        #[command(subcommand)]
        command: LabelCommand,
    },

    /// Search tasks, projects, or both.
    Search(SearchArgs),
}

/// What `onetaskgraph sources` can do.
#[derive(Debug, Subcommand)]
pub enum SourcesCommand {
    /// List every configured source, its plugin, and what it declares it can do.
    ///
    /// A source that could not be built is listed too, with the reason — one broken
    /// credential is a source you can see is broken, not a command that stops working.
    List,
    /// Safely report or add configured GitHub Projects board Status options.
    ///
    /// The default is a read-only plan. `--apply` preserves every existing option id and
    /// verifies every existing item assignment after GitHub replaces the option list.
    StatusOptions(StatusOptionsArgs),
}

/// Which configured source to inspect, and whether to apply its plan.
#[derive(Debug, Args)]
pub struct StatusOptionsArgs {
    /// The configured `github-projects` source name.
    // llmlint: ignore[invalid_states_unrepresentable] Clap collects this token as text;
    // the command converts it to `SourceName` before the configuration lookup or I/O.
    pub source: String,
    /// Add missing configured options and verify existing ids and assignments afterwards.
    #[arg(long)]
    // llmlint: ignore[invalid_states_unrepresentable] A presence-only CLI flag is
    // intrinsically boolean; the command immediately maps it to `StatusOptionsMode`.
    pub apply: bool,
}

/// What `onetaskgraph task` can do.
#[derive(Debug, Subcommand)]
pub enum TaskCommand {
    /// List tasks across the selected sources.
    List(TaskListArgs),
    /// Show one task by its qualified id, `<source>:<native-id>`.
    Show(ShowArgs),
    /// Walk one task's dependency edges.
    Deps(DependencyArgs),
    /// Copy tasks into another configured source, by qualified id.
    Copy(TaskCopyArgs),
    /// Add, list, edit and delete one task's comments.
    ///
    /// A copy never reads or writes a comment, at either end: these verbs are the only way
    /// one is written.
    Comment {
        #[command(subcommand)]
        command: CommentCommand,
    },
    /// Set one task's status, and nothing else about it.
    ///
    /// Every task it delivers is kept in step with it afterwards, and each is reported.
    Status {
        #[command(subcommand)]
        command: StatusCommand,
    },
    /// Set one key of one task's metadata, and nothing else about it.
    ///
    /// Metadata is not status: no task it delivers is re-evaluated.
    Metadata {
        #[command(subcommand)]
        command: MetadataCommand,
    },
}

/// What `onetaskgraph task metadata`, `project metadata` and `document metadata` can do.
#[derive(Debug, Subcommand)]
pub enum MetadataCommand {
    /// Set one metadata key to one JSON value, adding the key or replacing what it holds.
    ///
    /// Every other key, and every other field of the record, is left as it was; a key already
    /// holding the value is not written at all.
    Set(MetadataSetArgs),
}

/// `onetaskgraph <task|project|document> metadata set`.
#[derive(Debug, Args)]
pub struct MetadataSetArgs {
    /// The record's qualified id, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `StatusSetArgs::id`: a `GlobalId`
    /// here would refuse an unqualified id as a bad invocation under clap's wording, and
    /// `qualified` in `main` converts it immediately with the next action a user needs.
    #[arg(value_name = "ID")]
    pub id: String,

    /// The key, `<namespace>.<name>`: two or more non-empty dot-separated segments, never in
    /// the `onetaskgraph.` namespace.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `id` above: `metadata_key` in
    /// `main` converts it through `MetadataKey::new` before anything is built, and a clap
    /// value parser would report the same refusal as a bad invocation without its next action.
    #[arg(value_name = "KEY")]
    pub key: String,

    /// The value, as exactly one JSON value: `null`, `true`, `3`, `"text"`, `[…]` or `{…}`.
    ///
    /// Parsed strictly as JSON and never as YAML, so a bare word such as `yes` or a date such
    /// as `2026-01-01` is refused rather than silently given a type; quote a string.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `key` above: `metadata_value` in
    /// `main` parses it before anything is built, naming the parse error and the next action.
    #[arg(value_name = "VALUE", allow_hyphen_values = true)]
    pub value: String,
}

/// What `onetaskgraph task status` can do.
#[derive(Debug, Subcommand)]
pub enum StatusCommand {
    /// Set one task's status to a category, and nothing else about it.
    Set(StatusSetArgs),
}

/// `onetaskgraph task status set`.
#[derive(Debug, Args)]
pub struct StatusSetArgs {
    /// The task's qualified id, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `CommentAddArgs::id`: a `GlobalId`
    /// here would refuse an unqualified id as a bad invocation under clap's wording, and
    /// `qualified` in `main` converts it immediately with the next action a user needs.
    #[arg(value_name = "ID")]
    pub id: String,

    /// The status category to set.
    #[arg(value_name = "CATEGORY")]
    pub category: StatusArg,
}

/// What `onetaskgraph task comment` can do.
///
/// A body is read from `--body-file`, or from standard input when that flag is absent, and
/// never from a word of the command line: a comment quoting a command must not pass through
/// a shell to reach the source.
#[derive(Debug, Subcommand)]
pub enum CommentCommand {
    /// Add a comment to a task. The body comes from --body-file, or from standard input.
    Add(CommentAddArgs),
    /// List a task's comments, oldest first.
    List(CommentListArgs),
    /// Replace one comment's body. The body comes from --body-file, or from standard input.
    Edit(CommentEditArgs),
    /// Delete one comment from a task.
    Delete(CommentDeleteArgs),
}

/// `onetaskgraph task comment add`.
#[derive(Debug, Args)]
pub struct CommentAddArgs {
    /// The task's qualified id, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `ShowArgs::id`: a `GlobalId`
    /// here would refuse an unqualified id as a bad invocation under clap's wording, and
    /// `qualified` in `main` converts it immediately with the next action a user needs.
    #[arg(value_name = "ID")]
    pub id: String,

    /// Read the body from this file rather than from standard input, byte for byte.
    #[arg(long = "body-file", value_name = "PATH")]
    pub body_file: Option<std::path::PathBuf>,

    /// Who wrote it. A source that records the author itself refuses this, saying why.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — an author is each source's own
    /// spelling of a person, so there is no narrower type every source agrees on; the
    /// source refuses what it cannot record, naming why, as `NewComment::author` records.
    #[arg(long, value_name = "NAME")]
    pub author: Option<String>,
}

/// `onetaskgraph task comment list`.
#[derive(Debug, Args)]
pub struct CommentListArgs {
    /// The task's qualified id, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `CommentAddArgs::id`.
    #[arg(value_name = "ID")]
    pub id: String,
}

/// `onetaskgraph task comment edit`.
#[derive(Debug, Args)]
pub struct CommentEditArgs {
    /// The task's qualified id, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `CommentAddArgs::id`.
    #[arg(value_name = "ID")]
    pub id: String,

    /// The comment's own id, exactly as `list` or `add` reported it.
    #[arg(value_name = "COMMENT-ID", value_parser = native_id)]
    pub comment_id: NativeId,

    /// Read the new body from this file rather than from standard input, byte for byte.
    #[arg(long = "body-file", value_name = "PATH")]
    pub body_file: Option<std::path::PathBuf>,
}

/// `onetaskgraph task comment delete`.
#[derive(Debug, Args)]
pub struct CommentDeleteArgs {
    /// The task's qualified id, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `CommentAddArgs::id`.
    #[arg(value_name = "ID")]
    pub id: String,

    /// The comment's own id, exactly as `list` or `add` reported it.
    #[arg(value_name = "COMMENT-ID", value_parser = native_id)]
    pub comment_id: NativeId,
}

/// A source's own id, as a command line hands one over.
///
/// Never refused: a native id is whatever its source issued, so the one thing this does is
/// give the value the contract's own type where it enters.
fn native_id(value: &str) -> Result<NativeId, std::convert::Infallible> {
    Ok(NativeId::from(value))
}

/// What `onetaskgraph project` can do.
#[derive(Debug, Subcommand)]
pub enum ProjectCommand {
    /// List projects across the selected sources.
    List(ProjectListArgs),
    /// Show one project by its qualified id, `<source>:<native-id>`.
    Show(ShowArgs),
    /// Walk one project's dependency edges.
    Deps(DependencyArgs),
    /// Copy one project, and the tasks in it, into another configured source.
    Copy(ProjectCopyArgs),
    /// Set one key of one project's metadata, and nothing else about it.
    Metadata {
        #[command(subcommand)]
        command: MetadataCommand,
    },
}

/// What `onetaskgraph document` can do.
///
/// No `deps`: a document takes part in no dependency graph, so there is nothing for a
/// dependency verb here to walk.
#[derive(Debug, Subcommand)]
pub enum DocumentCommand {
    /// List documents across the selected sources.
    List(DocumentListArgs),
    /// Show one document by its qualified id, `<source>:<native-id>`.
    Show(ShowArgs),
    /// Copy documents into another configured source, by qualified id.
    Copy(DocumentCopyArgs),
    /// Set one key of one document's metadata, and nothing else about it.
    Metadata {
        #[command(subcommand)]
        command: MetadataCommand,
    },
}

/// What `onetaskgraph label` can do.
#[derive(Debug, Subcommand)]
pub enum LabelCommand {
    /// List every label the selected sources know.
    List(LabelListArgs),
}

/// Which sources a query addresses.
#[derive(Debug, Args)]
pub struct SelectionArgs {
    /// Address this source. Repeat for several; omit for the configured selection.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — a `SourceName` here would move
    /// the refusal into clap, which reports it as an invalid *invocation* (exit 2) under
    /// clap's own wording. A name that cannot be a source name and one that names no
    /// configured source are the same typo to the user, and both owe the same next
    /// action; `selection` in `main` converts through `SourceName::new` immediately and
    /// attaches it, at the exit code the documented table gives that mistake.
    #[arg(long = "source", value_name = "S")]
    pub source: Vec<String>,
}

/// The filters the list verbs share.
#[derive(Debug, Args)]
pub struct FilterArgs {
    /// Keep items carrying this label. Repeat to require several at once.
    #[arg(long = "label", value_name = "L")]
    pub label: Vec<String>,

    /// Drop items carrying this label. Repeat for several.
    #[arg(long = "not-label", value_name = "L")]
    pub not_label: Vec<String>,

    /// Keep items in this status category. Repeat for several.
    #[arg(long = "status", value_name = "S")]
    pub status: Vec<StatusArg>,

    /// Keep items matching this text.
    #[arg(long, value_name = "TEXT")]
    pub search: Option<String>,

    /// Which fields --search looks in.
    #[arg(long = "in", value_name = "FIELDS", default_value = "both")]
    pub fields: FieldsArg,
}

/// The filters a document list carries.
///
/// [`FilterArgs`] without `--status`, and its own type rather than a shared one for the
/// reason [`DocumentFilters`](onetaskgraph_core::DocumentFilters) is: a document has no
/// status, so a status flag here could only be accepted and ignored.
#[derive(Debug, Args)]
pub struct DocumentFilterArgs {
    /// Keep documents carrying this label. Repeat to require several at once.
    #[arg(long = "label", value_name = "L")]
    pub label: Vec<String>,

    /// Drop documents carrying this label. Repeat for several.
    #[arg(long = "not-label", value_name = "L")]
    pub not_label: Vec<String>,

    /// Keep documents matching this text.
    #[arg(long, value_name = "TEXT")]
    pub search: Option<String>,

    /// Which fields --search looks in.
    #[arg(long = "in", value_name = "FIELDS", default_value = "both")]
    pub fields: FieldsArg,
}

/// How much of a result set to return, and how much to say about it.
#[derive(Debug, Args)]
pub struct PageArgs {
    /// How many items this page holds. Defaults to the `page_size` setting.
    ///
    /// A `NonZeroU32` rather than a range-checked `u32`: a page of no rows is not a page,
    /// and typing it should be refused where it was typed rather than carried inwards as
    /// a number some later layer has to remember to check.
    #[arg(long, value_name = "N")]
    pub limit: Option<NonZeroU32>,

    /// Resume from a token a previous page reported.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — a `PageToken` here would only
    /// move the *encoding* check to parse time, and a token is refused for three reasons
    /// beyond its encoding — a configuration it cannot address, a query it did not come
    /// from, a stream it does not resume — none of which is decidable before the
    /// configuration is loaded. Splitting one mistake ("I pasted the wrong token") across
    /// clap's exit 2 and the run's exit 1 is what that would buy.
    #[arg(long = "page", value_name = "TOKEN")]
    pub page: Option<String>,

    /// Report what each source was asked and what the engine did itself.
    #[arg(long)]
    pub explain: bool,

    /// Accept an answer some sources could not contribute to, and exit 0.
    #[arg(long = "allow-partial")]
    pub allow_partial: bool,
}

/// `onetaskgraph task list`.
#[derive(Debug, Args)]
pub struct TaskListArgs {
    #[command(flatten)]
    pub selection: SelectionArgs,

    #[command(flatten)]
    pub filters: FilterArgs,

    /// Keep tasks in this project, qualified (`work:PROJ-1`) or by native id.
    ///
    /// A qualified id names one project of one source, so it narrows the query to that
    /// source. A bare id is asked of every selected source.
    #[arg(long, value_name = "P", conflicts_with = "no_project")]
    pub project: Option<String>,

    /// Keep only tasks belonging to no project at all.
    #[arg(long = "no-project")]
    pub no_project: bool,

    #[command(flatten)]
    pub paging: PageArgs,
}

/// `onetaskgraph project list`.
#[derive(Debug, Args)]
pub struct ProjectListArgs {
    #[command(flatten)]
    pub selection: SelectionArgs,

    #[command(flatten)]
    pub filters: FilterArgs,

    #[command(flatten)]
    pub paging: PageArgs,
}

/// `onetaskgraph document list`.
#[derive(Debug, Args)]
pub struct DocumentListArgs {
    #[command(flatten)]
    pub selection: SelectionArgs,

    #[command(flatten)]
    pub filters: DocumentFilterArgs,

    /// Keep documents in this project, qualified (`work:PROJ-1`) or by native id.
    ///
    /// A qualified id names one project of one source, so it narrows the query to that
    /// source. A bare id is asked of every selected source.
    #[arg(long, value_name = "P", conflicts_with = "no_project")]
    pub project: Option<String>,

    /// Keep only documents belonging to no project at all.
    #[arg(long = "no-project")]
    pub no_project: bool,

    #[command(flatten)]
    pub paging: PageArgs,
}

/// `onetaskgraph label list`.
#[derive(Debug, Args)]
pub struct LabelListArgs {
    #[command(flatten)]
    pub selection: SelectionArgs,

    #[command(flatten)]
    pub paging: PageArgs,
}

/// `onetaskgraph task show` and `onetaskgraph project show`.
#[derive(Debug, Args)]
pub struct ShowArgs {
    /// The qualified id, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — a `GlobalId` here would refuse an
    /// unqualified id as a bad invocation, under clap's wording. `qualified` in `main`
    /// converts through `GlobalId::from_str` immediately and says what a qualified id is
    /// and where to read the configured names, which is the answer a user typing `T-1`
    /// needs and the one this repository's failure journeys assert on.
    #[arg(value_name = "ID")]
    pub id: String,

    /// Report what the source was asked.
    #[arg(long)]
    pub explain: bool,

    /// Accept an answer the source could not contribute to, and exit 0.
    #[arg(long = "allow-partial")]
    pub allow_partial: bool,
}

/// `onetaskgraph task deps` and `onetaskgraph project deps`.
#[derive(Debug, Args)]
pub struct DependencyArgs {
    /// The qualified id, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — a `GlobalId` here would refuse an
    /// unqualified id as a bad invocation, under clap's wording. `qualified` in `main`
    /// converts through `GlobalId::from_str` immediately and says what a qualified id is
    /// and where to read the configured names, which is the answer a user typing `T-1`
    /// needs and the one this repository's failure journeys assert on.
    #[arg(value_name = "ID")]
    pub id: String,

    /// Which way to walk. Reverse is emulated for a forward-only source.
    #[arg(long, value_name = "DIRECTION", default_value = "depends-on")]
    pub direction: DirectionArg,

    #[command(flatten)]
    pub paging: PageArgs,
}

/// The arguments both copy verbs share.
///
/// A copy is one write into one destination, so there is no paging, no `--explain` and no
/// `--allow-partial` here: a partial write is not an answer a caller could act on.
#[derive(Debug, Args)]
pub struct CopyArgs {
    /// The configured source to copy into. A source name, never a qualified id.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — a `SourceName` here would move
    /// the refusal into clap, which reports it as an invalid *invocation* (exit 2) under
    /// clap's own wording, for the reason recorded on `SelectionArgs::source`. A name
    /// that cannot be a source name and one that names no configured source are the same
    /// typo, and both owe the same next action.
    #[arg(long, value_name = "SOURCE")]
    pub to: String,

    /// Re-establish a lost correspondence by matching on `title` or on a metadata key.
    #[arg(long = "match-by", value_name = "KEY")]
    pub match_by: Option<String>,

    /// Create a new destination item when a recorded origin names nothing there.
    #[arg(long)]
    pub recreate: bool,

    /// Perform every read, write nothing, and report what would have happened.
    ///
    /// A dry run keeps no delivered task in step, so its `delivered` list is always empty —
    /// which says nothing about whether any delivered task would have moved.
    #[arg(long = "dry-run")]
    pub dry_run: bool,
}

/// `onetaskgraph task copy`.
#[derive(Debug, Args)]
pub struct TaskCopyArgs {
    /// The qualified ids to copy, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — a `GlobalId` here would refuse an
    /// unqualified id as a bad invocation, under clap's wording, for the reason recorded
    /// on `ShowArgs::id`. `qualified` in `main` converts through `GlobalId::from_str` and
    /// says what a qualified id is and where to read the configured names.
    #[arg(value_name = "ID", required = true)]
    pub id: Vec<String>,

    #[command(flatten)]
    pub copy: CopyArgs,
}

/// `onetaskgraph project copy`.
#[derive(Debug, Args)]
pub struct ProjectCopyArgs {
    /// The qualified id to copy, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `TaskCopyArgs::id`.
    #[arg(value_name = "ID")]
    pub id: String,

    /// Copy the project alone, leaving the tasks in it where they are.
    #[arg(long = "no-tasks")]
    pub no_tasks: bool,

    /// Copy the project and exactly this task of it, `<source>:<native-id>`; repeat it to
    /// name more. A task not named is not read at the destination, not written and not
    /// reported, and nothing is reported orphaned.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `TaskCopyArgs::id`.
    #[arg(long = "member", value_name = "TASK-ID", conflicts_with = "no_tasks")]
    pub member: Vec<String>,

    #[command(flatten)]
    pub copy: CopyArgs,
}

/// `onetaskgraph document copy`.
#[derive(Debug, Args)]
pub struct DocumentCopyArgs {
    /// The qualified ids to copy, `<source>:<native-id>`.
    ///
    /// llmlint: ignore[invalid_states_unrepresentable] — as `TaskCopyArgs::id`.
    #[arg(value_name = "ID", required = true)]
    pub id: Vec<String>,

    #[command(flatten)]
    pub copy: CopyArgs,
}

/// `onetaskgraph search`.
#[derive(Debug, Args)]
pub struct SearchArgs {
    /// What to look for.
    #[arg(value_name = "TEXT")]
    pub text: String,

    /// Which fields to look in.
    #[arg(long = "in", value_name = "FIELDS", default_value = "both")]
    pub fields: FieldsArg,

    /// Which entities to search.
    #[arg(long, value_name = "KIND", default_value = "both")]
    pub kind: KindArg,

    #[command(flatten)]
    pub selection: SelectionArgs,

    #[command(flatten)]
    pub paging: PageArgs,
}

/// A status category, as the command line spells it.
///
/// A command-line mirror of [`StatusCategory`] rather than that type itself, for the
/// reason [`Format`] carries: deriving clap's `ValueEnum` on a contract type would put
/// clap into the plugin contract's dependencies for the sake of one flag.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum StatusArg {
    /// Written down but not yet committed to as work.
    Draft,
    /// Known about, not yet accepted as ready to work.
    Backlog,
    /// Accepted and ready to be picked up, and nothing has claimed it.
    Todo,
    /// Claimed by work that will do it, and not yet started.
    Queued,
    /// Being worked on.
    InProgress,
    /// Finished.
    Done,
    /// Abandoned.
    Cancelled,
    /// The source reported a status this vocabulary cannot place.
    Unknown,
}

impl StatusArg {
    /// The contract's own category.
    #[must_use]
    pub fn category(self) -> StatusCategory {
        match self {
            Self::Draft => StatusCategory::Draft,
            Self::Backlog => StatusCategory::Backlog,
            Self::Todo => StatusCategory::Todo,
            Self::Queued => StatusCategory::Queued,
            Self::InProgress => StatusCategory::InProgress,
            Self::Done => StatusCategory::Done,
            Self::Cancelled => StatusCategory::Cancelled,
            Self::Unknown => StatusCategory::Unknown,
        }
    }
}

/// Which fields a search covers, as the command line spells it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum FieldsArg {
    /// Titles only.
    Title,
    /// Bodies only.
    Content,
    /// Either one matching is a match.
    Both,
}

impl FieldsArg {
    /// The contract's own field selector.
    #[must_use]
    pub fn fields(self) -> TextFields {
        match self {
            Self::Title => TextFields::Title,
            Self::Content => TextFields::Content,
            Self::Both => TextFields::TitleOrContent,
        }
    }
}

/// Which way a dependency walk goes, as the command line spells it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum DirectionArg {
    /// What this item depends on.
    DependsOn,
    /// What depends on this item.
    DependedOnBy,
}

impl DirectionArg {
    /// The contract's own direction.
    #[must_use]
    pub fn direction(self) -> Direction {
        match self {
            Self::DependsOn => Direction::DependsOn,
            Self::DependedOnBy => Direction::DependedOnBy,
        }
    }
}

/// Which entities a search covers, as the command line spells it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum KindArg {
    /// Tasks only.
    Task,
    /// Projects only.
    Project,
    /// Both, interleaved.
    Both,
}

impl KindArg {
    /// The engine's own search scope.
    #[must_use]
    pub fn kind(self) -> SearchKind {
        match self {
            Self::Task => SearchKind::Tasks,
            Self::Project => SearchKind::Projects,
            Self::Both => SearchKind::Both,
        }
    }
}

/// What `onetaskgraph config` can do.
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
    /// Show every setting, with the layer its value came from.
    ///
    /// The layer is named exactly: which document, which environment variable, or
    /// which flag. Rendered as JSON when `output` is `json` — which `--json` sets.
    Show,
}

/// The command-line layer: any setting, at the top of the stack.
#[derive(Debug, Args)]
pub struct Overrides {
    /// Set any setting: --set sources.work.config.root=/tmp/tasks
    ///
    /// The path is the same dotted path a document uses and the same one the
    /// `ONETASKGRAPH_` variables encode, so one name works at all three layers.
    #[arg(long = "set", value_name = "PATH=VALUE", global = true)]
    pub set: Vec<String>,

    /// How many items one page holds.
    ///
    /// Refused at zero here rather than at load: this flag parses on every verb, so a
    /// value only the configuration loader would have caught is one the verbs that do
    /// not load a configuration would accept in silence.
    #[arg(
        long,
        value_name = "N",
        global = true,
        value_parser = clap::value_parser!(u32).range(1..)
    )]
    pub page_size: Option<u32>,

    /// Which sources answer when a command names none.
    #[arg(long, value_name = "NAMES", value_delimiter = ',', global = true)]
    pub default_sources: Option<Vec<String>>,

    /// How output is rendered.
    #[arg(long, value_name = "FORMAT", global = true, conflicts_with = "json")]
    pub output: Option<Format>,

    /// Shorthand for --output json.
    #[arg(long, global = true)]
    pub json: bool,
}

impl Overrides {
    /// These flags as one configuration layer.
    ///
    /// # Errors
    ///
    /// Returns a message naming the flag when a `--set` argument is not
    /// `PATH=VALUE`, or its path addresses nothing.
    pub fn layer(&self) -> Result<Layer, String> {
        let mut settings = Vec::new();

        if let Some(page_size) = self.page_size {
            settings.push(at("page_size", Value::from(page_size), "--page-size"));
        }
        if let Some(names) = &self.default_sources {
            let names: Vec<Value> = names.iter().map(|name| Value::from(name.clone())).collect();
            settings.push(at(
                "default_sources",
                Value::Array(names),
                "--default-sources",
            ));
        }
        if let Some(format) = self.output {
            settings.push(at("output", format.setting(), "--output"));
        }
        if self.json {
            settings.push(at("output", Value::from("json"), "--json"));
        }

        // Last, so `--set output=text` beats `--json`: the general form is the more
        // specific instruction, and a caller who spells a path out means it.
        for assignment in &self.set {
            settings.push(assignment_setting(assignment)?);
        }

        Ok(Layer::new(settings))
    }
}

/// How output is rendered, as the command line accepts it.
///
/// A command-line mirror of [`OutputFormat`] rather than that type itself, because
/// deriving clap's `ValueEnum` on it would put clap into the engine's dependencies for
/// the sake of one flag. The two cannot disagree about *spelling*: [`Format::setting`]
/// produces its value by serialising the `OutputFormat` it stands for, so what reaches
/// the configuration is whatever the engine's own type writes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Format {
    /// For a person reading a terminal.
    Text,
    /// For a program.
    Json,
}

impl Format {
    /// This format as the `output` setting's value.
    #[must_use]
    pub fn setting(self) -> Value {
        let format = match self {
            Self::Text => OutputFormat::Text,
            Self::Json => OutputFormat::Json,
        };
        serde_json::to_value(format).expect("an output format renders as JSON")
    }
}

/// One setting at a path this binary spells itself.
fn at(key: &str, value: Value, flag: &str) -> Setting {
    Setting {
        key: SettingPath::parse(key).expect("a path this binary spells has no empty segment"),
        value,
        origin: Origin::Flag {
            flag: flag.to_owned(),
        },
    }
}

/// One `--set PATH=VALUE` argument.
fn assignment_setting(assignment: &str) -> Result<Setting, String> {
    let Some((path, value)) = assignment.split_once('=') else {
        return Err(format!(
            "--set {assignment}: that is not an assignment\n\
             next: write it as --set PATH=VALUE, for example --set page_size=10."
        ));
    };
    Ok(Setting {
        key: SettingPath::parse(path).map_err(|error| format!("--set {error}"))?,
        value: value_from_text(value),
        origin: Origin::Flag {
            flag: format!("--set {path}"),
        },
    })
}