1use std::path::{Path, PathBuf};
9
10use clap::{Args, ValueEnum};
11
12#[derive(Clone, Debug, Default, Args)]
17pub struct CodeScopeArgs {
18 #[arg(long)]
20 pub path: Option<String>,
21
22 #[arg(long, requires = "path")]
24 pub symbol: Option<String>,
25
26 #[arg(long, requires = "path")]
28 pub line: Option<u32>,
29}
30
31impl CodeScopeArgs {
32 pub fn is_set(&self) -> bool {
34 self.path.is_some() || self.symbol.is_some() || self.line.is_some()
35 }
36}
37
38#[derive(Clone, Debug, Default, Args)]
44pub struct HistoricalRevisionArgs {
45 #[arg(long)]
47 pub state: Option<String>,
48}
49
50impl HistoricalRevisionArgs {
51 pub fn as_deref(&self) -> Option<&str> {
52 self.state.as_deref()
53 }
54}
55
56pub fn split_path_and_revision<'a>(
62 path: Option<&'a str>,
63 state: Option<&'a str>,
64) -> (Option<&'a str>, Option<&'a str>, Option<&'a str>) {
65 match (path, state) {
66 (Some(path), state) => (Some(path), None, state),
67 (None, Some(state)) => (None, Some(state), None),
68 (None, None) => (None, None, None),
69 }
70}
71
72#[derive(Clone, Debug, Default, Args)]
78pub struct RemoteChoiceArgs {
79 #[arg(long)]
81 pub remote: Option<String>,
82}
83
84impl RemoteChoiceArgs {
85 pub fn requested(&self) -> Option<&str> {
87 self.remote
88 .as_deref()
89 .map(str::trim)
90 .filter(|value| !value.is_empty())
91 }
92}
93
94#[derive(Clone, Debug, Default, Args)]
99pub struct AuthoredMessageArgs {
100 #[arg(short = 'm', long = "body")]
102 pub body: Option<String>,
103
104 #[arg(long, value_name = "PATH", conflicts_with = "body")]
106 pub file: Option<PathBuf>,
107}
108
109impl AuthoredMessageArgs {
110 pub fn is_set(&self) -> bool {
111 self.body.is_some() || self.file.is_some()
112 }
113}
114
115#[derive(Clone, Debug, Default, Args)]
117pub struct HostedServerArgs {
118 #[arg(long)]
120 pub server: Option<String>,
121}
122
123impl HostedServerArgs {
124 pub fn as_deref(&self) -> Option<&str> {
125 self.server.as_deref()
126 }
127}
128
129#[derive(Clone, Debug, Default, Args)]
131pub struct DryRunArgs {
132 #[arg(long)]
134 pub dry_run: bool,
135}
136
137impl DryRunArgs {
138 pub fn enabled(&self) -> bool {
139 self.dry_run
140 }
141}
142
143#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
145pub enum CloneSourceArg {
146 Git,
147 Heddle,
148}
149
150impl CloneSourceArg {
151 pub fn as_str(self) -> &'static str {
152 match self {
153 Self::Git => "git",
154 Self::Heddle => "heddle",
155 }
156 }
157}
158
159pub fn safe_clone_destination_basename(remote: &str) -> Option<String> {
165 let path_part = if let Some(rest) = remote
166 .strip_prefix("https://")
167 .or_else(|| remote.strip_prefix("http://"))
168 .or_else(|| remote.strip_prefix("ssh://"))
169 .or_else(|| remote.strip_prefix("git://"))
170 .or_else(|| remote.strip_prefix("file://"))
171 {
172 let rest = rest.strip_prefix("git@").unwrap_or(rest);
173 match rest.split_once('/') {
174 Some((_, path)) if !path.is_empty() => path,
175 _ => return None,
176 }
177 } else {
178 match remote.find(':') {
179 Some(colon_pos) => {
180 let prefix = &remote[..colon_pos];
181 let rest = &remote[colon_pos + 1..];
182 let is_windows_drive = prefix.len() == 1
183 && prefix
184 .chars()
185 .next()
186 .is_some_and(|c| c.is_ascii_alphabetic())
187 && (rest.starts_with('\\') || rest.starts_with('/'));
188 let prefix_has_separator = prefix.contains('/') || prefix.contains('\\');
189 if is_windows_drive || prefix_has_separator {
190 remote
191 } else if rest.is_empty() {
192 return None;
193 } else {
194 rest
195 }
196 }
197 None => remote,
198 }
199 };
200 let is_sep = |c: char| c == '/' || c == '\\';
201 let segment = path_part
202 .trim_end_matches(is_sep)
203 .rsplit(is_sep)
204 .find(|part| !part.is_empty())
205 .unwrap_or("");
206 let name = segment.strip_suffix(".git").unwrap_or(segment);
207 if name.is_empty()
208 || name == "."
209 || name == ".."
210 || name == "~"
211 || name == "/"
212 || name.contains('/')
213 || name.contains('\\')
214 || name.contains('\0')
215 || Path::new(name).is_absolute()
216 {
217 return None;
218 }
219 Some(name.to_string())
220}
221
222#[cfg(test)]
223mod tests {
224 use clap::{CommandFactory, Parser};
225
226 use super::*;
227 use crate::cli::{
228 Cli, Commands, ContextCommands, DiscussCommands, ReviewCommands, ThreadCommands,
229 };
230
231 #[test]
232 fn remote_choice_has_no_origin_default() {
233 match Cli::try_parse_from(["heddle", "review", "approve", "feat"])
234 .expect("approve without --remote")
235 .command
236 {
237 Commands::Review {
238 command: ReviewCommands::Approve(args),
239 } => {
240 assert!(args.remote_choice.requested().is_none());
241 }
242 _ => panic!("expected review approve"),
243 }
244 match Cli::try_parse_from([
245 "heddle", "review", "approve", "feat", "--remote", "upstream",
246 ])
247 .expect("approve with --remote")
248 .command
249 {
250 Commands::Review {
251 command: ReviewCommands::Approve(args),
252 } => {
253 assert_eq!(args.remote_choice.requested(), Some("upstream"));
254 }
255 _ => panic!("expected review approve"),
256 }
257 }
258
259 #[test]
260 fn no_remote_flag_injects_origin() {
261 let cmd = Cli::command();
262 fn walk(command: &clap::Command) {
263 for arg in command.get_arguments() {
264 if arg.get_long() == Some("remote")
265 && (arg.get_short().is_some() || arg.get_long().is_some())
266 && !arg.is_positional()
267 {
268 let defaults: Vec<_> = arg
269 .get_default_values()
270 .iter()
271 .map(|v| v.to_string_lossy().into_owned())
272 .collect();
273 assert!(
274 !defaults.iter().any(|v| v == "origin"),
275 "`{} --remote` must not default to origin (got {defaults:?})",
276 command.get_name()
277 );
278 }
279 }
280 for sub in command.get_subcommands() {
281 walk(sub);
282 }
283 }
284 walk(&cmd);
285 }
286
287 #[test]
288 fn scope_and_state_mean_the_same_on_discuss_context_and_blame() {
289 match Cli::try_parse_from([
290 "heddle",
291 "discuss",
292 "new",
293 "--path",
294 "src/auth.rs",
295 "--symbol",
296 "verify",
297 "--line",
298 "12",
299 "--state",
300 "HEAD~1",
301 "--body",
302 "why?",
303 ])
304 .expect("discuss new scope")
305 .command
306 {
307 Commands::Discuss(args) => match args.command {
308 DiscussCommands::New(new_args) => {
309 assert_eq!(new_args.scope.path.as_deref(), Some("src/auth.rs"));
310 assert_eq!(new_args.scope.symbol.as_deref(), Some("verify"));
311 assert_eq!(new_args.scope.line, Some(12));
312 assert_eq!(new_args.revision.state.as_deref(), Some("HEAD~1"));
313 assert_eq!(new_args.message.body.as_deref(), Some("why?"));
314 }
315 _ => panic!("expected discuss new"),
316 },
317 _ => panic!("expected discuss"),
318 }
319
320 match Cli::try_parse_from([
321 "heddle",
322 "context",
323 "get",
324 "--path",
325 "src/auth.rs",
326 "--symbol",
327 "verify",
328 "--line",
329 "12",
330 "--state",
331 "HEAD~1",
332 ])
333 .expect("context get scope")
334 .command
335 {
336 Commands::Context {
337 for_thread: None,
338 command: Some(ContextCommands::Get(args)),
339 } => {
340 assert_eq!(args.scope.path.as_deref(), Some("src/auth.rs"));
341 assert_eq!(args.scope.symbol.as_deref(), Some("verify"));
342 assert_eq!(args.scope.line, Some(12));
343 assert_eq!(args.revision.state.as_deref(), Some("HEAD~1"));
344 let (path, state_target, historical) = split_path_and_revision(
345 args.scope.path.as_deref(),
346 args.revision.state.as_deref(),
347 );
348 assert_eq!(path, Some("src/auth.rs"));
349 assert!(state_target.is_none());
350 assert_eq!(historical, Some("HEAD~1"));
351 }
352 _ => panic!("expected context get"),
353 }
354
355 match Cli::try_parse_from(["heddle", "blame", "src/auth.rs", "--state", "HEAD~1"])
356 .expect("blame --state")
357 .command
358 {
359 Commands::Blame(args) => {
360 assert_eq!(args.path, "src/auth.rs");
361 assert_eq!(args.revision.state.as_deref(), Some("HEAD~1"));
362 }
363 _ => panic!("expected blame"),
364 }
365
366 assert!(
367 Cli::try_parse_from([
368 "heddle",
369 "context",
370 "get",
371 "--path",
372 "src/auth.rs",
373 "--ref",
374 "HEAD",
375 ])
376 .is_err(),
377 "historical selector is --state, not --ref"
378 );
379 }
380
381 #[test]
382 fn dry_run_is_the_no_mutation_flag() {
383 match Cli::try_parse_from(["heddle", "undo", "--dry-run"])
384 .expect("undo --dry-run")
385 .command
386 {
387 Commands::Undo(args) => assert!(args.dry_run.enabled()),
388 _ => panic!("expected undo"),
389 }
390 match Cli::try_parse_from(["heddle", "thread", "absorb", "child", "--dry-run"])
391 .expect("absorb --dry-run")
392 .command
393 {
394 Commands::Thread {
395 command: ThreadCommands::Absorb(args),
396 } => assert!(args.dry_run.enabled()),
397 _ => panic!("expected absorb"),
398 }
399 assert!(
400 Cli::try_parse_from(["heddle", "undo", "--preview"]).is_err(),
401 "--preview is not a dry-run alias"
402 );
403 assert!(
404 Cli::try_parse_from(["heddle", "thread", "absorb", "child", "--preview"]).is_err(),
405 "--preview is not a dry-run alias"
406 );
407 }
408
409 #[test]
410 fn safe_clone_basename_is_unambiguous_or_none() {
411 assert_eq!(
412 safe_clone_destination_basename("https://host/acme/widgets.git").as_deref(),
413 Some("widgets")
414 );
415 assert_eq!(
416 safe_clone_destination_basename("git@host:acme/widgets.git").as_deref(),
417 Some("widgets")
418 );
419 assert!(safe_clone_destination_basename("https://host/").is_none());
420 assert!(safe_clone_destination_basename(".").is_none());
421 assert!(safe_clone_destination_basename("..").is_none());
422 }
423}