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                for_thread: None,
285                command: Some(ContextCommands::History(args)),
286            } => {
287                assert_eq!(args.annotation_id.as_deref(), Some("ann-1"));
288                assert!(args.scope.path.is_none());
289            }
290            _ => panic!("expected context history"),
291        }
292        match Cli::try_parse_from(["heddle", "context", "history", "--path", "src/auth.rs"])
293            .expect("history path")
294            .command
295        {
296            Commands::Context {
297                for_thread: None,
298                command: Some(ContextCommands::History(args)),
299            } => {
300                assert!(args.annotation_id.is_none());
301                assert_eq!(args.scope.path.as_deref(), Some("src/auth.rs"));
302            }
303            _ => panic!("expected context history"),
304        }
305        match Cli::try_parse_from([
306            "heddle",
307            "context",
308            "edit",
309            "--path",
310            "src/auth.rs",
311            "-m",
312            "revised",
313        ])
314        .expect("edit path")
315        .command
316        {
317            Commands::Context {
318                for_thread: None,
319                command: Some(ContextCommands::Edit(args)),
320            } => {
321                assert!(args.annotation_id.is_none());
322                assert_eq!(args.scope.path.as_deref(), Some("src/auth.rs"));
323                assert_eq!(args.message.body.as_deref(), Some("revised"));
324            }
325            _ => panic!("expected context edit"),
326        }
327    }
328
329    #[test]
330    fn context_set_accepts_positional_path_body_and_file() {
331        match Cli::try_parse_from([
332            "heddle",
333            "context",
334            "set",
335            "src/auth.rs",
336            "--body",
337            "keep timing constant",
338        ])
339        .expect("positional path + body")
340        .command
341        {
342            Commands::Context {
343                for_thread: None,
344                command: Some(ContextCommands::Set(args)),
345            } => {
346                assert_eq!(args.resolved_path(), Some("src/auth.rs"));
347                assert_eq!(args.message.body.as_deref(), Some("keep timing constant"));
348            }
349            _ => panic!("expected context set"),
350        }
351        match Cli::try_parse_from([
352            "heddle",
353            "context",
354            "set",
355            "--path",
356            "src/auth.rs",
357            "--file",
358            "note.md",
359        ])
360        .expect("file body")
361        .command
362        {
363            Commands::Context {
364                for_thread: None,
365                command: Some(ContextCommands::Set(args)),
366            } => {
367                assert_eq!(args.resolved_path(), Some("src/auth.rs"));
368                assert_eq!(
369                    args.message.file.as_deref(),
370                    Some(std::path::Path::new("note.md"))
371                );
372            }
373            _ => panic!("expected context set"),
374        }
375        assert!(
376            Cli::try_parse_from([
377                "heddle",
378                "context",
379                "set",
380                "--path",
381                "src/auth.rs",
382                "--from-file",
383                "note.md",
384            ])
385            .is_err(),
386            "--from-file is not an alias of --file"
387        );
388    }
389
390    #[test]
391    fn context_set_accepts_path_symbol_and_line_together() {
392        match Cli::try_parse_from([
393            "heddle",
394            "context",
395            "set",
396            "--path",
397            "src/auth.rs",
398            "--symbol",
399            "verify",
400            "-m",
401            "keep timing constant",
402        ])
403        .expect("set --path --symbol")
404        .command
405        {
406            Commands::Context {
407                for_thread: None,
408                command: Some(ContextCommands::Set(args)),
409            } => {
410                assert_eq!(args.resolved_path(), Some("src/auth.rs"));
411                assert_eq!(args.scope.symbol.as_deref(), Some("verify"));
412                assert!(args.scope.line.is_none());
413            }
414            _ => panic!("expected context set"),
415        }
416        match Cli::try_parse_from([
417            "heddle",
418            "context",
419            "set",
420            "--path",
421            "src/auth.rs",
422            "--line",
423            "12",
424            "-m",
425            "guard this row",
426        ])
427        .expect("set --path --line")
428        .command
429        {
430            Commands::Context {
431                for_thread: None,
432                command: Some(ContextCommands::Set(args)),
433            } => {
434                assert_eq!(args.scope.line, Some(12));
435                assert!(args.scope.symbol.is_none());
436            }
437            _ => panic!("expected context set"),
438        }
439        match Cli::try_parse_from([
440            "heddle",
441            "context",
442            "set",
443            "--path",
444            "src/auth.rs",
445            "--symbol",
446            "verify",
447            "--line",
448            "12",
449            "-m",
450            "both",
451        ])
452        .expect("symbol and line together")
453        .command
454        {
455            Commands::Context {
456                for_thread: None,
457                command: Some(ContextCommands::Set(args)),
458            } => {
459                assert_eq!(args.scope.symbol.as_deref(), Some("verify"));
460                assert_eq!(args.scope.line, Some(12));
461            }
462            _ => panic!("expected context set"),
463        }
464        assert!(
465            Cli::try_parse_from([
466                "heddle",
467                "context",
468                "set",
469                "--path",
470                "src/auth.rs",
471                "--scope",
472                "symbol:verify",
473                "-m",
474                "legacy",
475            ])
476            .is_err(),
477            "hidden --scope alias is removed"
478        );
479    }
480}