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