Skip to main content

blotter/
cli.rs

1use crate::Severity;
2use clap::{Args, Parser, Subcommand, ValueEnum};
3use std::path::PathBuf;
4
5#[derive(Debug, Parser)]
6#[command(
7    name = "blotter",
8    version,
9    about,
10    long_about = None,
11    arg_required_else_help = true,
12    subcommand_required = true,
13    rename_all = "kebab-case"
14)]
15pub struct Cli {
16    #[arg(
17        long,
18        global = true,
19        value_name = "PATH",
20        help = "Override log-file discovery for this invocation"
21    )]
22    pub file: Option<PathBuf>,
23
24    #[arg(
25        long,
26        global = true,
27        help = "Indent the JSON envelope for human reading"
28    )]
29    pub pretty: bool,
30
31    #[command(subcommand)]
32    pub command: Command,
33}
34
35impl Cli {
36    pub fn is_hook_exec(&self) -> bool {
37        matches!(
38            &self.command,
39            Command::Hook(HookArgs {
40                command: HookCommand::Exec(_)
41            })
42        )
43    }
44}
45
46#[derive(Debug, Subcommand)]
47pub enum Command {
48    #[command(alias = "log")]
49    Add(AddArgs),
50    #[command(alias = "idea")]
51    Dogear(DogearArgs),
52    List(ListArgs),
53    Export(ExportArgs),
54    Triage(TriageArgs),
55    Verify(VerifyArgs),
56    Retrospect(RetrospectArgs),
57    Digest(DigestArgs),
58    Sweep(SweepArgs),
59    Resolve(ResolveArgs),
60    Archive(ArchiveArgs),
61    Hook(HookArgs),
62    Schema {
63        #[arg(
64            value_enum,
65            default_value_t = SchemaTarget::All,
66            help = "Contract section to emit"
67        )]
68        target: SchemaTarget,
69    },
70    Doctor(DoctorArgs),
71}
72
73#[derive(Debug, Args)]
74pub struct AddArgs {
75    #[arg(
76        value_name = "TEXT",
77        help = "Cut text; omit or use - to read from stdin"
78    )]
79    pub text: Option<String>,
80    #[arg(long, help = "Agent name; overrides BLOTTER_AGENT")]
81    pub agent: Option<String>,
82    #[arg(long = "tag", help = "Tag the cut; repeatable")]
83    pub tags: Vec<String>,
84    #[arg(
85        long,
86        value_enum,
87        default_value_t = Severity::Minor,
88        help = "How much did it hurt? blocker: could not proceed; major: lost real time; minor: a papercut"
89    )]
90    pub severity: Severity,
91    #[arg(
92        long,
93        allow_hyphen_values = true,
94        value_name = "TEXT",
95        help = "Command that failed"
96    )]
97    pub cmd: Option<String>,
98    #[arg(long = "exit", value_name = "N", help = "Command exit status")]
99    pub exit_code: Option<i32>,
100    #[arg(
101        long,
102        value_name = "PATH",
103        help = "Read regular UTF-8 PATH (<=1 MiB); best-effort redaction; store sanitized value <=4096 bytes"
104    )]
105    pub stderr_file: Option<PathBuf>,
106    #[arg(
107        long,
108        allow_hyphen_values = true,
109        value_name = "TEXT",
110        help = "Additional evidence or filing note"
111    )]
112    pub evidence: Option<String>,
113    #[arg(long, help = "Validate without appending")]
114    pub dry_run: bool,
115}
116
117#[derive(Debug, Args)]
118pub struct DogearArgs {
119    #[arg(
120        value_name = "TEXT",
121        help = "Dogear text; omit or use - to read from stdin"
122    )]
123    pub text: Option<String>,
124    #[arg(long, help = "Agent name; overrides BLOTTER_AGENT")]
125    pub agent: Option<String>,
126    #[arg(long = "tag", help = "Tag the dogear; repeatable")]
127    pub tags: Vec<String>,
128    #[arg(
129        long,
130        allow_hyphen_values = true,
131        value_name = "TEXT",
132        help = "Optional research note; leading hyphens accepted"
133    )]
134    pub evidence: Option<String>,
135    #[arg(long, help = "Validate without appending")]
136    pub dry_run: bool,
137}
138
139#[derive(Debug, Args)]
140pub struct ListArgs {
141    #[arg(
142        long,
143        value_enum,
144        default_value_t = ListKind::Cut,
145        help = "Record kind to list"
146    )]
147    pub kind: ListKind,
148    #[arg(
149        long,
150        value_enum,
151        default_value_t = StatusFilter::Open,
152        help = "Filter by lifecycle status"
153    )]
154    pub status: StatusFilter,
155    #[arg(long, help = "Filter by agent")]
156    pub agent: Option<String>,
157    #[arg(long, help = "Filter by tag")]
158    pub tag: Option<String>,
159    #[arg(long, help = "Include records tagged auto")]
160    pub include_auto: bool,
161    #[arg(long, value_enum, help = "Filter cuts by severity")]
162    pub severity: Option<Severity>,
163    #[arg(long, help = "Filter since an RFC3339 timestamp or Nd/Nh duration")]
164    pub since: Option<String>,
165    #[arg(long, default_value_t = 50, help = "Maximum records to return")]
166    pub limit: usize,
167    #[arg(
168        long,
169        value_enum,
170        default_value_t = OutputFormat::Json,
171        help = "Output format"
172    )]
173    pub format: OutputFormat,
174}
175
176#[derive(Debug, Args)]
177pub struct ExportArgs {
178    #[arg(long, value_enum, help = "Output bridge format; required: otlp-json")]
179    pub format: Option<ExportFormat>,
180    #[arg(long, help = "Filter since an RFC3339 timestamp or Nd/Nh duration")]
181    pub since: Option<String>,
182    #[arg(long, help = "Include records tagged auto")]
183    pub include_auto: bool,
184}
185
186#[derive(Debug, Args)]
187pub struct TriageArgs {
188    #[arg(
189        long,
190        default_value_t = 3,
191        value_name = "N",
192        help = "Minimum similar open cuts per cluster"
193    )]
194    pub min_count: usize,
195    #[arg(long, help = "Include records tagged auto")]
196    pub include_auto: bool,
197}
198
199#[derive(Debug, Args)]
200pub struct VerifyArgs {
201    #[arg(long, help = "Include records tagged auto")]
202    pub include_auto: bool,
203}
204
205#[derive(Debug, Args)]
206pub struct RetrospectArgs {}
207
208#[derive(Debug, Args)]
209pub struct DigestArgs {
210    #[arg(
211        long,
212        default_value = "7d",
213        help = "Report since an RFC3339 timestamp or Nd/Nh duration"
214    )]
215    pub since: String,
216    #[arg(long, help = "Include records tagged auto")]
217    pub include_auto: bool,
218    #[arg(
219        long,
220        value_enum,
221        default_value_t = OutputFormat::Json,
222        help = "Output format"
223    )]
224    pub format: OutputFormat,
225}
226
227#[derive(Debug, Args)]
228pub struct ArchiveArgs {
229    #[arg(
230        long,
231        required = true,
232        value_name = "VALUE",
233        help = "Archive closed groups before an RFC3339 timestamp or Nd/Nh duration"
234    )]
235    pub before: String,
236    #[arg(long, help = "Plan archive retention without writing")]
237    pub dry_run: bool,
238}
239
240#[derive(Debug, Args)]
241pub struct DoctorArgs {
242    #[arg(long, help = "Repair safe doctor findings")]
243    pub fix: bool,
244    #[arg(long, requires = "fix", help = "Plan doctor repairs without writing")]
245    pub dry_run: bool,
246    #[arg(
247        long,
248        conflicts_with = "fix",
249        help = "Scan raw physical lines for home-path leaks"
250    )]
251    pub leaks: bool,
252    #[arg(
253        long,
254        value_name = "LITERAL",
255        requires = "leaks",
256        help = "Flag a literal raw-line leak; repeatable; requires --leaks"
257    )]
258    pub deny: Vec<String>,
259}
260
261#[derive(Debug, Args)]
262pub struct SweepArgs {
263    #[arg(
264        value_name = "PATH",
265        help = "Repository directory or direct JSONL log file; repeatable"
266    )]
267    pub paths: Vec<PathBuf>,
268    #[arg(
269        long,
270        value_name = "FILE",
271        help = "User-owned file with one path per line; blank lines and # comments ignored"
272    )]
273    pub registry: Option<PathBuf>,
274    #[arg(long, help = "Filter since an RFC3339 timestamp or Nd/Nh duration")]
275    pub since: Option<String>,
276    #[arg(
277        long,
278        value_enum,
279        default_value_t = ListKind::Cut,
280        help = "Record kind to include in items"
281    )]
282    pub kind: ListKind,
283    #[arg(long, help = "Include records tagged auto")]
284    pub include_auto: bool,
285}
286
287#[derive(Debug, Args)]
288pub struct ResolveArgs {
289    #[arg(
290        value_name = "ID",
291        num_args = 1..,
292        required = true,
293        help = "One or more IDs or unique prefixes"
294    )]
295    pub ids: Vec<String>,
296    #[arg(
297        long,
298        allow_hyphen_values = true,
299        help = "Resolution note; leading hyphens accepted"
300    )]
301    pub note: Option<String>,
302    #[arg(long, help = "Resolving agent; overrides BLOTTER_AGENT")]
303    pub agent: Option<String>,
304    #[arg(long, value_name = "ID", help = "Graduation task ID")]
305    pub task: Option<String>,
306    #[arg(long, value_name = "URL", help = "Graduation pull request URL")]
307    pub pr: Option<String>,
308    #[arg(long, value_name = "SHA", help = "Graduation commit SHA")]
309    pub commit: Option<String>,
310    #[arg(
311        long,
312        value_name = "URL",
313        conflicts_with = "dropped",
314        help = "Published destination (dogear records only)"
315    )]
316    pub url: Option<String>,
317    #[arg(long, help = "Mark dropped (dogear records only)")]
318    pub dropped: bool,
319    #[arg(long, help = "Append a correction to an existing resolved record")]
320    pub amend: bool,
321    #[arg(long, help = "Validate without appending a resolution")]
322    pub dry_run: bool,
323}
324
325#[derive(Debug, Args)]
326pub struct HookArgs {
327    #[command(subcommand)]
328    pub command: HookCommand,
329}
330
331#[derive(Debug, Subcommand)]
332pub enum HookCommand {
333    Install(HookInstallArgs),
334    Exec(HookExecArgs),
335}
336
337#[derive(Debug, Args)]
338pub struct HookInstallArgs {
339    #[arg(value_enum, help = "Hook integration to install")]
340    pub target: HookTarget,
341    #[arg(
342        long,
343        value_name = "PATH",
344        conflicts_with = "global",
345        help = "Explicit Claude Code settings file"
346    )]
347    pub settings: Option<PathBuf>,
348    #[arg(
349        long,
350        conflicts_with = "settings",
351        help = "Use ~/.claude/settings.json"
352    )]
353    pub global: bool,
354    #[arg(long, help = "Report changes without writing settings")]
355    pub dry_run: bool,
356}
357
358#[derive(Debug, Args)]
359pub struct HookExecArgs {
360    #[arg(value_enum, help = "Hook integration payload to process")]
361    pub target: HookTarget,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
365pub enum HookTarget {
366    ClaudeCode,
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
370pub enum StatusFilter {
371    Open,
372    Resolved,
373    All,
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
377pub enum ListKind {
378    Cut,
379    Dogear,
380    All,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
384pub enum OutputFormat {
385    Json,
386    Md,
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
390pub enum ExportFormat {
391    OtlpJson,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
395pub enum SchemaTarget {
396    All,
397    Record,
398    Error,
399    ExitCodes,
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use clap::CommandFactory;
406
407    fn assert_all_arguments_have_help(command: &clap::Command) {
408        for argument in command.get_arguments() {
409            assert!(
410                argument.get_help().is_some() || argument.get_long_help().is_some(),
411                "{} argument {:?} is missing help text",
412                command.get_name(),
413                argument.get_id()
414            );
415        }
416        for subcommand in command.get_subcommands() {
417            assert_all_arguments_have_help(subcommand);
418        }
419    }
420
421    #[test]
422    fn parser_covers_defaults_aliases_and_globals() {
423        let cli =
424            Cli::try_parse_from(["blotter", "--file", "x", "log", "ouch", "--pretty"]).unwrap();
425        assert!(cli.pretty);
426        assert_eq!(cli.file, Some(PathBuf::from("x")));
427        let Command::Add(args) = cli.command else {
428            panic!("expected add")
429        };
430        assert_eq!(args.text.as_deref(), Some("ouch"));
431        assert_eq!(args.severity, Severity::Minor);
432
433        let cli = Cli::try_parse_from(["blotter", "list"]).unwrap();
434        let Command::List(args) = cli.command else {
435            panic!("expected list")
436        };
437        assert_eq!(args.kind, ListKind::Cut);
438        assert_eq!(args.status, StatusFilter::Open);
439        assert_eq!(args.limit, 50);
440        assert_eq!(args.format, OutputFormat::Json);
441
442        let cli = Cli::try_parse_from(["blotter", "export", "--format", "otlp-json"]).unwrap();
443        let Command::Export(args) = cli.command else {
444            panic!("expected export")
445        };
446        assert_eq!(args.format, Some(ExportFormat::OtlpJson));
447
448        let cli = Cli::try_parse_from(["blotter", "triage"]).unwrap();
449        let Command::Triage(args) = cli.command else {
450            panic!("expected triage")
451        };
452        assert_eq!(args.min_count, 3);
453
454        let cli = Cli::try_parse_from(["blotter", "verify"]).unwrap();
455        assert!(matches!(cli.command, Command::Verify(_)));
456
457        let cli = Cli::try_parse_from(["blotter", "retrospect"]).unwrap();
458        assert!(matches!(cli.command, Command::Retrospect(_)));
459
460        let cli = Cli::try_parse_from(["blotter", "digest"]).unwrap();
461        let Command::Digest(args) = cli.command else {
462            panic!("expected digest")
463        };
464        assert_eq!(args.since, "7d");
465        assert_eq!(args.format, OutputFormat::Json);
466
467        let cli = Cli::try_parse_from([
468            "blotter",
469            "sweep",
470            "repo",
471            "--registry",
472            "repos.txt",
473            "--since",
474            "1d",
475            "--kind",
476            "all",
477        ])
478        .unwrap();
479        let Command::Sweep(args) = cli.command else {
480            panic!("expected sweep")
481        };
482        assert_eq!(args.paths, [PathBuf::from("repo")]);
483        assert_eq!(args.registry, Some(PathBuf::from("repos.txt")));
484        assert_eq!(args.since.as_deref(), Some("1d"));
485        assert_eq!(args.kind, ListKind::All);
486    }
487
488    #[test]
489    fn parser_rejects_bad_values_and_missing_required_id() {
490        assert!(Cli::try_parse_from(["blotter", "list", "--format", "jsonl"]).is_err());
491        assert!(Cli::try_parse_from(["blotter", "digest", "--format", "jsonl"]).is_err());
492        assert!(Cli::try_parse_from(["blotter", "sweep", "--kind", "other"]).is_err());
493        assert!(Cli::try_parse_from(["blotter", "add", "x", "--severity", "critical"]).is_err());
494        assert!(Cli::try_parse_from(["blotter", "resolve"]).is_err());
495        assert!(
496            Cli::try_parse_from([
497                "blotter",
498                "hook",
499                "install",
500                "claude-code",
501                "--global",
502                "--settings",
503                "x",
504            ])
505            .is_err()
506        );
507        assert!(Cli::try_parse_from(["blotter"]).is_err());
508    }
509
510    #[test]
511    fn parser_accepts_every_command_and_stdin_marker() {
512        for args in [
513            vec!["blotter", "add", "-"],
514            vec!["blotter", "idea", "-"],
515            vec!["blotter", "list", "--status", "all"],
516            vec!["blotter", "list", "--kind", "dogear"],
517            vec!["blotter", "export", "--format", "otlp-json"],
518            vec!["blotter", "triage", "--min-count", "2"],
519            vec!["blotter", "verify"],
520            vec!["blotter", "digest"],
521            vec!["blotter", "sweep", "repo"],
522            vec!["blotter", "resolve", "abcd"],
523            vec!["blotter", "archive", "--before", "1d"],
524            vec!["blotter", "hook", "install", "claude-code"],
525            vec!["blotter", "schema", "record"],
526            vec!["blotter", "doctor"],
527        ] {
528            assert!(Cli::try_parse_from(args).is_ok());
529        }
530    }
531
532    #[test]
533    fn parser_rejects_removed_codex_hook_target() {
534        assert!(Cli::try_parse_from(["blotter", "hook", "exec", "codex"]).is_err());
535    }
536
537    #[test]
538    fn parser_accepts_leading_hyphen_text_values_without_swallowing_following_options() {
539        let cli = Cli::try_parse_from([
540            "blotter",
541            "add",
542            "text",
543            "--cmd",
544            "-tool arg",
545            "--evidence",
546            "--detail note",
547            "--agent",
548            "tester",
549        ])
550        .unwrap();
551        let Command::Add(args) = cli.command else {
552            panic!("expected add")
553        };
554        assert_eq!(args.cmd.as_deref(), Some("-tool arg"));
555        assert_eq!(args.evidence.as_deref(), Some("--detail note"));
556        assert_eq!(args.agent.as_deref(), Some("tester"));
557
558        let cli = Cli::try_parse_from([
559            "blotter",
560            "resolve",
561            "abcd1234",
562            "--note",
563            "--retry after timeout",
564            "--agent",
565            "fixer",
566        ])
567        .unwrap();
568        let Command::Resolve(args) = cli.command else {
569            panic!("expected resolve")
570        };
571        assert_eq!(args.note.as_deref(), Some("--retry after timeout"));
572        assert_eq!(args.agent.as_deref(), Some("fixer"));
573    }
574
575    #[test]
576    fn every_argument_has_help_text() {
577        let mut command = Cli::command();
578        command.build();
579        assert_all_arguments_have_help(&command);
580    }
581}