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}