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`: annotation tag (can be repeated).
85    #[arg(long)]
86    pub tag: Vec<String>,
87}
88
89#[derive(Clone, Debug, clap::ValueEnum)]
90pub enum ResolveModeArg {
91    ByEdit,
92    Dismiss,
93    IntoAnnotation,
94}
95
96#[derive(Clone, Debug, Args)]
97pub struct DiscussReopenArgs {
98    pub discussion_id: String,
99    /// Why the prior resolution no longer applies.
100    #[arg(long)]
101    pub reason: String,
102}
103
104#[derive(Clone, Debug, Args)]
105pub struct DiscussListArgs {
106    #[command(flatten)]
107    pub scope: CodeScopeArgs,
108    /// Filter by the state named in the discussion anchor.
109    #[command(flatten)]
110    pub revision: HistoricalRevisionArgs,
111    /// Status filter: `open`, `resolved`, `conflicted`, or `all`.
112    #[arg(long, default_value = "open")]
113    pub status: String,
114}
115
116#[derive(Clone, Debug, Args)]
117pub struct DiscussShowArgs {
118    /// Discussion id (short or full `disc-` id).
119    #[arg(value_name = "ID")]
120    pub discussion_id: String,
121}
122
123#[derive(Clone, Debug, Args)]
124pub struct DiscussWaitArgs {
125    /// Resume after this hosted event id. Defaults to the persisted watermark.
126    #[arg(long)]
127    pub after: Option<i64>,
128    #[command(flatten)]
129    pub remote_choice: RemoteChoiceArgs,
130    /// Restrict the subscription to this thread name.
131    #[arg(long)]
132    pub thread: Option<String>,
133    /// Internal helper for tests: stop after this many events (including ignored ones).
134    #[arg(long, hide = true)]
135    pub max_events: Option<usize>,
136}
137
138#[cfg(test)]
139mod tests {
140    use clap::Parser;
141
142    use crate::cli::{Cli, Commands, DiscussCommands, ResolveModeArg};
143
144    fn discuss(cli: Cli) -> crate::cli::DiscussArgs {
145        match cli.command {
146            Commands::Discuss(args) => args,
147            _ => panic!("expected discuss"),
148        }
149    }
150
151    #[test]
152    fn writes_are_subcommands() {
153        let opened = discuss(
154            Cli::try_parse_from([
155                "heddle",
156                "discuss",
157                "new",
158                "--path",
159                "src/lib.rs",
160                "--symbol",
161                "greet",
162                "--body",
163                "why greet?",
164            ])
165            .expect("discuss new"),
166        );
167        match opened.command {
168            DiscussCommands::New(args) => {
169                assert_eq!(args.scope.path.as_deref(), Some("src/lib.rs"));
170                assert_eq!(args.scope.symbol.as_deref(), Some("greet"));
171                assert_eq!(args.message.body.as_deref(), Some("why greet?"));
172            }
173            _ => panic!("expected discuss new"),
174        }
175
176        let reply = discuss(
177            Cli::try_parse_from([
178                "heddle",
179                "discuss",
180                "reply",
181                "disc-01a0afc6",
182                "--body",
183                "second thought",
184            ])
185            .expect("discuss reply"),
186        );
187        match reply.command {
188            DiscussCommands::Reply(args) => {
189                assert_eq!(args.discussion_id, "disc-01a0afc6");
190                assert_eq!(args.message.body.as_deref(), Some("second thought"));
191                assert!(args.turn.is_none());
192            }
193            _ => panic!("expected discuss reply"),
194        }
195
196        let threaded = discuss(
197            Cli::try_parse_from([
198                "heddle",
199                "discuss",
200                "reply",
201                "disc-01a0afc6",
202                "--turn",
203                "2",
204                "--body",
205                "reply to that turn",
206            ])
207            .expect("discuss reply --turn"),
208        );
209        match threaded.command {
210            DiscussCommands::Reply(args) => {
211                assert_eq!(args.turn, Some(2));
212                assert_eq!(args.message.body.as_deref(), Some("reply to that turn"));
213            }
214            _ => panic!("expected discuss reply"),
215        }
216
217        let from_file = discuss(
218            Cli::try_parse_from([
219                "heddle",
220                "discuss",
221                "new",
222                "--path",
223                "src/lib.rs",
224                "--file",
225                "why.md",
226            ])
227            .expect("discuss new --file"),
228        );
229        match from_file.command {
230            DiscussCommands::New(args) => {
231                assert_eq!(
232                    args.message.file.as_deref(),
233                    Some(std::path::Path::new("why.md"))
234                );
235                assert!(args.message.body.is_none());
236            }
237            _ => panic!("expected discuss new"),
238        }
239    }
240
241    #[test]
242    fn write_modes_on_parent_are_gone() {
243        for argv in [
244            [
245                "heddle",
246                "discuss",
247                "--new",
248                "--path",
249                "src/lib.rs",
250                "--body",
251                "x",
252            ]
253            .as_slice(),
254            ["heddle", "discuss", "--id", "disc-01a0afc6", "--body", "x"].as_slice(),
255            [
256                "heddle",
257                "discuss",
258                "open",
259                "src/lib.rs",
260                "greet",
261                "why greet?",
262            ]
263            .as_slice(),
264        ] {
265            assert!(
266                Cli::try_parse_from(argv).is_err(),
267                "{argv:?} must not parse"
268            );
269        }
270    }
271
272    #[test]
273    fn resolve_uses_mode_only() {
274        let resolved = discuss(
275            Cli::try_parse_from([
276                "heddle", "discuss", "resolve", "disc-id", "--mode", "dismiss", "--reason", "done",
277            ])
278            .expect("resolve --mode dismiss"),
279        );
280        match resolved.command {
281            DiscussCommands::Resolve(args) => {
282                assert!(matches!(args.mode, ResolveModeArg::Dismiss));
283                assert_eq!(args.reason.as_deref(), Some("done"));
284            }
285            _ => panic!("expected discuss resolve"),
286        }
287        assert!(
288            Cli::try_parse_from([
289                "heddle",
290                "discuss",
291                "resolve",
292                "disc-id",
293                "--dismiss",
294                "--reason",
295                "done",
296            ])
297            .is_err(),
298            "--dismiss synonym is removed"
299        );
300        assert!(
301            Cli::try_parse_from(["heddle", "discuss", "resolve", "disc-id", "--by-edit"]).is_err(),
302            "--by-edit synonym is removed"
303        );
304        let into = discuss(
305            Cli::try_parse_from([
306                "heddle",
307                "discuss",
308                "resolve",
309                "disc-id",
310                "--mode",
311                "into-annotation",
312                "--body",
313                "keep this",
314            ])
315            .expect("resolve into-annotation"),
316        );
317        match into.command {
318            DiscussCommands::Resolve(args) => {
319                assert!(matches!(args.mode, ResolveModeArg::IntoAnnotation));
320                assert_eq!(args.message.body.as_deref(), Some("keep this"));
321            }
322            _ => panic!("expected discuss resolve"),
323        }
324    }
325
326    #[test]
327    fn list_filters_by_path() {
328        let listed = discuss(
329            Cli::try_parse_from(["heddle", "discuss", "list", "--path", "src/lib.rs"])
330                .expect("list --path"),
331        );
332        match listed.command {
333            DiscussCommands::List(args) => {
334                assert_eq!(args.scope.path.as_deref(), Some("src/lib.rs"));
335            }
336            _ => panic!("expected discuss list"),
337        }
338        assert!(
339            Cli::try_parse_from(["heddle", "discuss", "list", "--file", "src/lib.rs"]).is_err(),
340            "list --file is not a path filter"
341        );
342    }
343}