Skip to main content

heddle_cli_args/cli/cli_args/
commands_context.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Context annotation subcommands.
3
4use super::{AuthoredMessageArgs, CodeScopeArgs, DryRunArgs, HistoricalRevisionArgs};
5
6/// Context subcommands.
7#[derive(Clone, Debug, clap::Subcommand)]
8pub enum ContextCommands {
9    /// Attach a context annotation to a file, symbol, line range, or state.
10    Set(ContextSetArgs),
11
12    /// Show current context annotations for a file or state target.
13    Get(ContextGetArgs),
14
15    /// List all active context targets.
16    List(ContextListArgs),
17
18    /// Show full revision history for one logical annotation.
19    History(ContextHistoryArgs),
20
21    /// Add a new revision to an existing logical annotation.
22    Edit(ContextEditArgs),
23
24    /// Create a replacement logical annotation and supersede an older one.
25    Supersede(ContextSupersedeArgs),
26
27    /// Remove context annotations.
28    Rm(ContextRmArgs),
29
30    /// Check annotation staleness against current code.
31    Check(ContextCheckArgs),
32
33    /// Suggest low-noise targets that may benefit from context.
34    Suggest(ContextSuggestArgs),
35
36    /// Audit stale, superseded, and duplicate context.
37    Audit(ContextAuditArgs),
38
39    /// Mine external sources for context annotations.
40    #[cfg(all(feature = "git-overlay", feature = "ingest"))]
41    Reason {
42        #[command(subcommand)]
43        command: ContextReasonCommands,
44    },
45}
46
47#[cfg(all(feature = "git-overlay", feature = "ingest"))]
48#[derive(Clone, Debug, clap::Subcommand)]
49pub enum ContextReasonCommands {
50    /// Mine Git-agent transcripts and attach reasoning as context annotations.
51    Git(ContextReasonGitArgs),
52}
53
54#[cfg(all(feature = "git-overlay", feature = "ingest"))]
55#[derive(Clone, Debug, clap::Args)]
56pub struct ContextReasonGitArgs {
57    /// Source git repository the transcripts are about.
58    #[arg(long)]
59    pub path: std::path::PathBuf,
60
61    /// Cap candidates per commit. Higher = more coverage at the cost of cross-attribution.
62    #[arg(long, default_value_t = 5)]
63    pub max_sessions_per_commit: usize,
64
65    /// Drop sessions below this confidence.
66    #[arg(long, default_value_t = 0.20)]
67    pub min_match_confidence: f32,
68
69    /// Limit how many commits the reason pass walks.
70    #[arg(long)]
71    pub limit: Option<usize>,
72
73    /// Override the Claude transcript store. Empty string disables.
74    #[arg(long = "claude-home")]
75    pub claude_home: Option<String>,
76
77    /// Override the Codex transcript store. Empty string disables.
78    #[arg(long = "codex-home")]
79    pub codex_home: Option<String>,
80
81    /// Override the OpenCode data dir. Empty string disables.
82    #[arg(long = "opencode-home")]
83    pub opencode_home: Option<String>,
84
85    #[command(flatten)]
86    pub dry_run: DryRunArgs,
87}
88
89/// Arguments for `heddle context set`.
90#[derive(Clone, Debug, clap::Args)]
91pub struct ContextSetArgs {
92    /// File path to annotate (alternative to `--path`).
93    #[arg(value_name = "PATH", conflicts_with = "path")]
94    pub path_positional: Option<String>,
95
96    #[command(flatten)]
97    pub scope: CodeScopeArgs,
98
99    /// State-level target when `--path` is omitted. Writes always land on HEAD.
100    #[command(flatten)]
101    pub revision: HistoricalRevisionArgs,
102
103    /// Primary annotation kind: constraint, invariant, or rationale.
104    #[arg(
105        long,
106        default_value = "rationale",
107        value_parser = ["constraint", "invariant", "rationale"]
108    )]
109    pub kind: String,
110
111    /// Explicit tags for categorization (can be repeated).
112    #[arg(long)]
113    pub tag: Vec<String>,
114
115    #[command(flatten)]
116    pub message: AuthoredMessageArgs,
117}
118
119impl ContextSetArgs {
120    /// Effective file path from positional PATH or `--path`.
121    pub fn resolved_path(&self) -> Option<&str> {
122        self.path_positional
123            .as_deref()
124            .or(self.scope.path.as_deref())
125    }
126}
127
128/// Arguments for `heddle context get`.
129#[derive(Clone, Debug, clap::Args)]
130pub struct ContextGetArgs {
131    #[command(flatten)]
132    pub scope: CodeScopeArgs,
133
134    #[command(flatten)]
135    pub revision: HistoricalRevisionArgs,
136
137    /// Filter by tag.
138    #[arg(long)]
139    pub tag: Option<String>,
140}
141
142/// Arguments for `heddle context list`.
143#[derive(Clone, Debug, clap::Args)]
144pub struct ContextListArgs {
145    /// Optional path prefix to filter file targets by.
146    #[arg(long)]
147    pub prefix: Option<String>,
148
149    /// Filter by tag.
150    #[arg(long)]
151    pub tag: Option<String>,
152
153    #[command(flatten)]
154    pub revision: HistoricalRevisionArgs,
155
156    /// Include superseded logical annotations in listings.
157    #[arg(long)]
158    pub include_superseded: bool,
159}
160
161#[derive(Clone, Debug, clap::Args)]
162pub struct ContextHistoryArgs {
163    /// Stable logical annotation ID. Omit when using `--path` / `--state`.
164    #[arg(required_unless_present_any = ["path", "state"])]
165    pub annotation_id: Option<String>,
166
167    #[command(flatten)]
168    pub scope: CodeScopeArgs,
169
170    #[command(flatten)]
171    pub revision: HistoricalRevisionArgs,
172}
173
174#[derive(Clone, Debug, clap::Args)]
175pub struct ContextEditArgs {
176    /// Stable logical annotation ID. Omit when using `--path` / `--state`.
177    #[arg(required_unless_present_any = ["path", "state"])]
178    pub annotation_id: Option<String>,
179
180    #[command(flatten)]
181    pub scope: CodeScopeArgs,
182
183    #[command(flatten)]
184    pub revision: HistoricalRevisionArgs,
185
186    /// Override the annotation kind for the new revision.
187    #[arg(long, value_parser = ["constraint", "invariant", "rationale"])]
188    pub kind: Option<String>,
189
190    /// Explicit tags for the new revision (can be repeated).
191    #[arg(long)]
192    pub tag: Vec<String>,
193
194    #[command(flatten)]
195    pub message: AuthoredMessageArgs,
196}
197
198#[derive(Clone, Debug, clap::Args)]
199pub struct ContextSupersedeArgs {
200    /// Stable logical annotation ID to supersede.
201    pub annotation_id: String,
202
203    #[command(flatten)]
204    pub scope: CodeScopeArgs,
205
206    #[command(flatten)]
207    pub revision: HistoricalRevisionArgs,
208
209    /// Replacement annotation kind: constraint, invariant, or rationale.
210    #[arg(
211        long,
212        default_value = "rationale",
213        value_parser = ["constraint", "invariant", "rationale"]
214    )]
215    pub kind: String,
216
217    /// Explicit tags for the replacement annotation.
218    #[arg(long)]
219    pub tag: Vec<String>,
220
221    #[command(flatten)]
222    pub message: AuthoredMessageArgs,
223}
224
225/// Arguments for `heddle context rm`.
226#[derive(Clone, Debug, clap::Args)]
227pub struct ContextRmArgs {
228    #[command(flatten)]
229    pub scope: CodeScopeArgs,
230
231    #[command(flatten)]
232    pub revision: HistoricalRevisionArgs,
233
234    /// Remove all annotations for this target.
235    #[arg(long)]
236    pub all: bool,
237}
238
239/// Arguments for `heddle context check`.
240#[derive(Clone, Debug, clap::Args)]
241pub struct ContextCheckArgs {
242    #[command(flatten)]
243    pub scope: CodeScopeArgs,
244
245    #[command(flatten)]
246    pub revision: HistoricalRevisionArgs,
247
248    /// Filter by tag.
249    #[arg(long)]
250    pub tag: Option<String>,
251}
252
253#[derive(Clone, Debug, clap::Args)]
254pub struct ContextSuggestArgs {
255    #[command(flatten)]
256    pub revision: HistoricalRevisionArgs,
257
258    /// Maximum suggestions to print.
259    #[arg(short = 'n', long, default_value = "10")]
260    pub limit: usize,
261}
262
263#[derive(Clone, Debug, clap::Args)]
264pub struct ContextAuditArgs {
265    #[command(flatten)]
266    pub revision: HistoricalRevisionArgs,
267}
268
269#[cfg(test)]
270mod tests {
271    use clap::Parser;
272
273    use crate::cli::{Cli, Commands, ContextCommands};
274
275    #[test]
276    fn history_and_edit_accept_path_or_id() {
277        match Cli::try_parse_from(["heddle", "context", "history", "ann-1"])
278            .expect("history id")
279            .command
280        {
281            Commands::Context {
282                command: ContextCommands::History(args),
283            } => {
284                assert_eq!(args.annotation_id.as_deref(), Some("ann-1"));
285                assert!(args.scope.path.is_none());
286            }
287            _ => panic!("expected context history"),
288        }
289        match Cli::try_parse_from(["heddle", "context", "history", "--path", "src/auth.rs"])
290            .expect("history path")
291            .command
292        {
293            Commands::Context {
294                command: ContextCommands::History(args),
295            } => {
296                assert!(args.annotation_id.is_none());
297                assert_eq!(args.scope.path.as_deref(), Some("src/auth.rs"));
298            }
299            _ => panic!("expected context history"),
300        }
301        match Cli::try_parse_from([
302            "heddle",
303            "context",
304            "edit",
305            "--path",
306            "src/auth.rs",
307            "-m",
308            "revised",
309        ])
310        .expect("edit path")
311        .command
312        {
313            Commands::Context {
314                command: ContextCommands::Edit(args),
315            } => {
316                assert!(args.annotation_id.is_none());
317                assert_eq!(args.scope.path.as_deref(), Some("src/auth.rs"));
318                assert_eq!(args.message.body.as_deref(), Some("revised"));
319            }
320            _ => panic!("expected context edit"),
321        }
322    }
323
324    #[test]
325    fn context_set_accepts_positional_path_body_and_file() {
326        match Cli::try_parse_from([
327            "heddle",
328            "context",
329            "set",
330            "src/auth.rs",
331            "--body",
332            "keep timing constant",
333        ])
334        .expect("positional path + body")
335        .command
336        {
337            Commands::Context {
338                command: ContextCommands::Set(args),
339            } => {
340                assert_eq!(args.resolved_path(), Some("src/auth.rs"));
341                assert_eq!(args.message.body.as_deref(), Some("keep timing constant"));
342            }
343            _ => panic!("expected context set"),
344        }
345        match Cli::try_parse_from([
346            "heddle",
347            "context",
348            "set",
349            "--path",
350            "src/auth.rs",
351            "--file",
352            "note.md",
353        ])
354        .expect("file body")
355        .command
356        {
357            Commands::Context {
358                command: ContextCommands::Set(args),
359            } => {
360                assert_eq!(args.resolved_path(), Some("src/auth.rs"));
361                assert_eq!(
362                    args.message.file.as_deref(),
363                    Some(std::path::Path::new("note.md"))
364                );
365            }
366            _ => panic!("expected context set"),
367        }
368        assert!(
369            Cli::try_parse_from([
370                "heddle",
371                "context",
372                "set",
373                "--path",
374                "src/auth.rs",
375                "--from-file",
376                "note.md",
377            ])
378            .is_err(),
379            "--from-file is not an alias of --file"
380        );
381    }
382
383    #[test]
384    fn context_set_accepts_path_symbol_and_line_together() {
385        match Cli::try_parse_from([
386            "heddle",
387            "context",
388            "set",
389            "--path",
390            "src/auth.rs",
391            "--symbol",
392            "verify",
393            "-m",
394            "keep timing constant",
395        ])
396        .expect("set --path --symbol")
397        .command
398        {
399            Commands::Context {
400                command: ContextCommands::Set(args),
401            } => {
402                assert_eq!(args.resolved_path(), Some("src/auth.rs"));
403                assert_eq!(args.scope.symbol.as_deref(), Some("verify"));
404                assert!(args.scope.line.is_none());
405            }
406            _ => panic!("expected context set"),
407        }
408        match Cli::try_parse_from([
409            "heddle",
410            "context",
411            "set",
412            "--path",
413            "src/auth.rs",
414            "--line",
415            "12",
416            "-m",
417            "guard this row",
418        ])
419        .expect("set --path --line")
420        .command
421        {
422            Commands::Context {
423                command: ContextCommands::Set(args),
424            } => {
425                assert_eq!(args.scope.line, Some(12));
426                assert!(args.scope.symbol.is_none());
427            }
428            _ => panic!("expected context set"),
429        }
430        match Cli::try_parse_from([
431            "heddle",
432            "context",
433            "set",
434            "--path",
435            "src/auth.rs",
436            "--symbol",
437            "verify",
438            "--line",
439            "12",
440            "-m",
441            "both",
442        ])
443        .expect("symbol and line together")
444        .command
445        {
446            Commands::Context {
447                command: ContextCommands::Set(args),
448            } => {
449                assert_eq!(args.scope.symbol.as_deref(), Some("verify"));
450                assert_eq!(args.scope.line, Some(12));
451            }
452            _ => panic!("expected context set"),
453        }
454        assert!(
455            Cli::try_parse_from([
456                "heddle",
457                "context",
458                "set",
459                "--path",
460                "src/auth.rs",
461                "--scope",
462                "symbol:verify",
463                "-m",
464                "legacy",
465            ])
466            .is_err(),
467            "hidden --scope alias is removed"
468        );
469    }
470}