Skip to main content

heddle_cli_args/cli/cli_args/
commands_discuss.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `heddle discuss` — durable repository collaboration.
3
4use clap::{Args, Subcommand};
5
6use super::{AuthoredMessageArgs, CodeScopeArgs, HistoricalRevisionArgs, RemoteChoiceArgs};
7
8/// `heddle discuss` — every action is a subcommand.
9#[derive(Clone, Debug, Args)]
10pub struct DiscussArgs {
11    #[command(subcommand)]
12    pub command: DiscussCommands,
13}
14
15#[derive(Clone, Debug, Subcommand)]
16pub enum DiscussCommands {
17    /// Open a new discussion.
18    New(DiscussNewArgs),
19    /// Reply to an existing discussion.
20    Reply(DiscussReplyArgs),
21    /// Resolve a discussion.
22    Resolve(DiscussResolveArgs),
23    /// Reopen a resolved discussion.
24    Reopen(DiscussReopenArgs),
25    /// List repository discussions.
26    List(DiscussListArgs),
27    /// Show one discussion and its causal heads.
28    Show(DiscussShowArgs),
29    /// Replay hosted discussion events after the local watermark, then go live.
30    Wait(DiscussWaitArgs),
31}
32
33#[derive(Clone, Debug, Args)]
34pub struct DiscussNewArgs {
35    #[command(flatten)]
36    pub scope: CodeScopeArgs,
37    #[command(flatten)]
38    pub revision: HistoricalRevisionArgs,
39    #[command(flatten)]
40    pub message: AuthoredMessageArgs,
41    /// Human-readable summary. Defaults to the first line of the first turn.
42    #[arg(long)]
43    pub title: Option<String>,
44    /// Visibility: `public` | `internal` | `team:NAME` | `restricted:LABEL` | `private:LABEL`.
45    #[arg(long)]
46    pub visibility: Option<String>,
47    /// Attach the discussion to a thread ref while keeping its code anchor.
48    #[arg(long, value_name = "REF")]
49    pub thread: Option<String>,
50}
51
52#[derive(Clone, Debug, Args)]
53pub struct DiscussReplyArgs {
54    /// Discussion id (short or full `disc-` id).
55    #[arg(value_name = "ID")]
56    pub discussion_id: String,
57    #[command(flatten)]
58    pub message: AuthoredMessageArgs,
59    /// Parent turn number (1-indexed). Defaults to the latest head.
60    #[arg(long)]
61    pub turn: Option<u32>,
62}
63
64#[derive(Clone, Debug, Args)]
65pub struct DiscussResolveArgs {
66    /// Discussion id (short or full `disc-` id).
67    #[arg(value_name = "ID")]
68    pub discussion_id: String,
69    /// Resolution kind: `by-edit`, `dismiss`, or `into-annotation`.
70    #[arg(long, value_enum)]
71    pub mode: ResolveModeArg,
72    /// For `by-edit`: state containing the edit (defaults to HEAD).
73    #[command(flatten)]
74    pub revision: HistoricalRevisionArgs,
75    /// For `dismiss`: non-empty reason.
76    #[arg(long)]
77    pub reason: Option<String>,
78    /// For `into-annotation`: annotation content (`--body` or `--file`).
79    #[command(flatten)]
80    pub message: AuthoredMessageArgs,
81    /// For `into-annotation`: constraint, invariant, or rationale (defaults to rationale).
82    #[arg(long, value_parser = ["constraint", "invariant", "rationale"])]
83    pub kind: Option<String>,
84    /// For `into-annotation`: inherit discussion visibility, or explicitly narrow it.
85    #[arg(long, value_name = "VISIBILITY")]
86    pub visibility: Option<String>,
87    /// For `into-annotation`: annotation tag (can be repeated).
88    #[arg(long)]
89    pub tag: Vec<String>,
90}
91
92#[derive(Clone, Debug, clap::ValueEnum)]
93pub enum ResolveModeArg {
94    ByEdit,
95    Dismiss,
96    IntoAnnotation,
97}
98
99#[derive(Clone, Debug, Args)]
100pub struct DiscussReopenArgs {
101    pub discussion_id: String,
102    /// Why the prior resolution no longer applies.
103    #[arg(long)]
104    pub reason: String,
105}
106
107#[derive(Clone, Debug, Args)]
108pub struct DiscussListArgs {
109    #[command(flatten)]
110    pub scope: CodeScopeArgs,
111    /// Filter by the state named in the discussion anchor.
112    #[command(flatten)]
113    pub revision: HistoricalRevisionArgs,
114    /// Status filter: `open`, `resolved`, `conflicted`, or `all`.
115    #[arg(long, default_value = "open")]
116    pub status: String,
117}
118
119#[derive(Clone, Debug, Args)]
120pub struct DiscussShowArgs {
121    /// Discussion id (short or full `disc-` id).
122    #[arg(value_name = "ID")]
123    pub discussion_id: String,
124}
125
126#[derive(Clone, Debug, Args)]
127pub struct DiscussWaitArgs {
128    /// Resume after this hosted event id. Defaults to the persisted watermark.
129    #[arg(long)]
130    pub after: Option<i64>,
131    #[command(flatten)]
132    pub remote_choice: RemoteChoiceArgs,
133    /// Restrict the subscription to this thread name.
134    #[arg(long)]
135    pub thread: Option<String>,
136    /// Internal helper for tests: stop after this many events (including ignored ones).
137    #[arg(long, hide = true)]
138    pub max_events: Option<usize>,
139}
140
141#[cfg(test)]
142mod tests {
143    use clap::Parser;
144
145    use crate::cli::{Cli, Commands, DiscussCommands, ResolveModeArg};
146
147    fn discuss(cli: Cli) -> crate::cli::DiscussArgs {
148        match cli.command {
149            Commands::Discuss(args) => args,
150            _ => panic!("expected discuss"),
151        }
152    }
153
154    #[test]
155    fn writes_are_subcommands() {
156        let opened = discuss(
157            Cli::try_parse_from([
158                "heddle",
159                "discuss",
160                "new",
161                "--path",
162                "src/lib.rs",
163                "--symbol",
164                "greet",
165                "--body",
166                "why greet?",
167            ])
168            .expect("discuss new"),
169        );
170        match opened.command {
171            DiscussCommands::New(args) => {
172                assert_eq!(args.scope.path.as_deref(), Some("src/lib.rs"));
173                assert_eq!(args.scope.symbol.as_deref(), Some("greet"));
174                assert_eq!(args.message.body.as_deref(), Some("why greet?"));
175            }
176            _ => panic!("expected discuss new"),
177        }
178
179        let reply = discuss(
180            Cli::try_parse_from([
181                "heddle",
182                "discuss",
183                "reply",
184                "disc-01a0afc6",
185                "--body",
186                "second thought",
187            ])
188            .expect("discuss reply"),
189        );
190        match reply.command {
191            DiscussCommands::Reply(args) => {
192                assert_eq!(args.discussion_id, "disc-01a0afc6");
193                assert_eq!(args.message.body.as_deref(), Some("second thought"));
194                assert!(args.turn.is_none());
195            }
196            _ => panic!("expected discuss reply"),
197        }
198
199        let threaded = discuss(
200            Cli::try_parse_from([
201                "heddle",
202                "discuss",
203                "reply",
204                "disc-01a0afc6",
205                "--turn",
206                "2",
207                "--body",
208                "reply to that turn",
209            ])
210            .expect("discuss reply --turn"),
211        );
212        match threaded.command {
213            DiscussCommands::Reply(args) => {
214                assert_eq!(args.turn, Some(2));
215                assert_eq!(args.message.body.as_deref(), Some("reply to that turn"));
216            }
217            _ => panic!("expected discuss reply"),
218        }
219
220        let from_file = discuss(
221            Cli::try_parse_from([
222                "heddle",
223                "discuss",
224                "new",
225                "--path",
226                "src/lib.rs",
227                "--file",
228                "why.md",
229            ])
230            .expect("discuss new --file"),
231        );
232        match from_file.command {
233            DiscussCommands::New(args) => {
234                assert_eq!(
235                    args.message.file.as_deref(),
236                    Some(std::path::Path::new("why.md"))
237                );
238                assert!(args.message.body.is_none());
239            }
240            _ => panic!("expected discuss new"),
241        }
242    }
243
244    #[test]
245    fn write_modes_on_parent_are_gone() {
246        for argv in [
247            [
248                "heddle",
249                "discuss",
250                "--new",
251                "--path",
252                "src/lib.rs",
253                "--body",
254                "x",
255            ]
256            .as_slice(),
257            ["heddle", "discuss", "--id", "disc-01a0afc6", "--body", "x"].as_slice(),
258            [
259                "heddle",
260                "discuss",
261                "open",
262                "src/lib.rs",
263                "greet",
264                "why greet?",
265            ]
266            .as_slice(),
267        ] {
268            assert!(
269                Cli::try_parse_from(argv).is_err(),
270                "{argv:?} must not parse"
271            );
272        }
273    }
274
275    #[test]
276    fn resolve_uses_mode_only() {
277        let resolved = discuss(
278            Cli::try_parse_from([
279                "heddle", "discuss", "resolve", "disc-id", "--mode", "dismiss", "--reason", "done",
280            ])
281            .expect("resolve --mode dismiss"),
282        );
283        match resolved.command {
284            DiscussCommands::Resolve(args) => {
285                assert!(matches!(args.mode, ResolveModeArg::Dismiss));
286                assert_eq!(args.reason.as_deref(), Some("done"));
287            }
288            _ => panic!("expected discuss resolve"),
289        }
290        assert!(
291            Cli::try_parse_from([
292                "heddle",
293                "discuss",
294                "resolve",
295                "disc-id",
296                "--dismiss",
297                "--reason",
298                "done",
299            ])
300            .is_err(),
301            "--dismiss synonym is removed"
302        );
303        assert!(
304            Cli::try_parse_from(["heddle", "discuss", "resolve", "disc-id", "--by-edit"]).is_err(),
305            "--by-edit synonym is removed"
306        );
307        let into = discuss(
308            Cli::try_parse_from([
309                "heddle",
310                "discuss",
311                "resolve",
312                "disc-id",
313                "--mode",
314                "into-annotation",
315                "--body",
316                "keep this",
317            ])
318            .expect("resolve into-annotation"),
319        );
320        match into.command {
321            DiscussCommands::Resolve(args) => {
322                assert!(matches!(args.mode, ResolveModeArg::IntoAnnotation));
323                assert_eq!(args.message.body.as_deref(), Some("keep this"));
324            }
325            _ => panic!("expected discuss resolve"),
326        }
327    }
328
329    #[test]
330    fn list_filters_by_path() {
331        let listed = discuss(
332            Cli::try_parse_from(["heddle", "discuss", "list", "--path", "src/lib.rs"])
333                .expect("list --path"),
334        );
335        match listed.command {
336            DiscussCommands::List(args) => {
337                assert_eq!(args.scope.path.as_deref(), Some("src/lib.rs"));
338            }
339            _ => panic!("expected discuss list"),
340        }
341        assert!(
342            Cli::try_parse_from(["heddle", "discuss", "list", "--file", "src/lib.rs"]).is_err(),
343            "list --file is not a path filter"
344        );
345    }
346}