1use std::io::{self, Write};
2use std::path::{Path, PathBuf};
3use std::process::ExitCode;
4
5use clap::builder::styling::{AnsiColor, Effects, Styles};
6use clap::{ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
7
8use crate::channel::{
9 ChannelError, ChannelTransfer, ChannelTransferKind, DEFAULT_REVIEW_CHANNEL, ReviewChannel,
10};
11use crate::commands::{
12 DiffTarget, DirtyPathHandling, Gate, MarkOptions, StatusMode, StatusTarget, diff_path,
13 list_channels, mark_paths, remove_channel, status, sync_notes, transfer_channel_notes,
14 unmark_paths,
15};
16use crate::error::AppError;
17use crate::git::Git;
18use crate::manpage;
19use crate::notes::{GitNotesChannelStore, GitNotesStore, NotesStore};
20use crate::sync_progress::SyncProgressReporter;
21use crate::ui::{ColorMode, ColorPolicy};
22
23const CLI_STYLES: Styles = Styles::styled()
24 .header(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
25 .usage(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
26 .literal(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD))
27 .placeholder(AnsiColor::Cyan.on_default())
28 .error(AnsiColor::BrightRed.on_default().effects(Effects::BOLD))
29 .valid(AnsiColor::BrightGreen.on_default())
30 .invalid(AnsiColor::BrightYellow.on_default());
31
32#[derive(Parser, Debug)]
33#[command(
34 name = "git-vet",
35 version,
36 about = "Track human review state for Git-tracked file contents",
37 styles = CLI_STYLES,
38 after_help = "Typical workflow:\n git vet status\n git vet diff <path>\n git vet mark <path>\n git vet status --check"
39)]
40pub struct Cli {
41 #[arg(long, global = true)]
43 channel: Option<String>,
44 #[arg(long, value_enum, global = true)]
46 color: Option<CliColorMode>,
47 #[command(subcommand)]
48 command: CommandKind,
49}
50
51#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
52enum CliColorMode {
53 Auto,
54 Always,
55 Never,
56}
57
58impl From<CliColorMode> for ColorMode {
59 fn from(mode: CliColorMode) -> Self {
60 match mode {
61 CliColorMode::Auto => Self::Auto,
62 CliColorMode::Always => Self::Always,
63 CliColorMode::Never => Self::Never,
64 }
65 }
66}
67
68#[derive(Subcommand, Debug)]
69enum CommandKind {
70 InstallMan {
72 #[arg(long, hide = true)]
74 channel: Option<String>,
75 #[arg(long, value_name = "DIRECTORY")]
77 man_dir: Option<PathBuf>,
78 },
79 Channel {
81 #[command(subcommand)]
82 command: ChannelCommand,
83 },
84 Mark {
86 #[arg(long)]
88 allow_dirty: bool,
89 #[arg(required = true)]
90 paths: Vec<PathBuf>,
91 },
92 Unmark {
94 #[arg(required = true)]
95 paths: Vec<PathBuf>,
96 },
97 Status {
99 #[arg(long)]
101 json: bool,
102 #[arg(long)]
104 all: bool,
105 #[arg(long)]
107 check: bool,
108 #[arg(long)]
110 workspace: bool,
111 #[arg(value_name = "PATHSPEC")]
113 paths: Vec<PathBuf>,
114 },
115 Diff {
117 #[arg(long)]
119 workspace: bool,
120 path: PathBuf,
121 },
122 Sync {
124 #[arg(long)]
126 remote: Option<String>,
127 },
128 Prune,
130}
131
132#[derive(Subcommand, Debug)]
133enum ChannelCommand {
134 List {
136 #[arg(long)]
138 json: bool,
139 },
140 Copy {
142 #[arg(value_name = "SOURCE")]
144 source: String,
145 #[arg(value_name = "DESTINATION")]
147 destination: String,
148 },
149 Move {
151 #[arg(value_name = "SOURCE")]
153 source: String,
154 #[arg(value_name = "DESTINATION")]
156 destination: String,
157 },
158 Remove {
160 #[arg(value_name = "CHANNEL")]
162 target: String,
163 #[arg(long)]
165 force: bool,
166 },
167}
168
169pub struct CliError {
170 error: AppError,
171 color_error: bool,
172}
173
174impl CliError {
175 #[must_use]
176 pub const fn error(&self) -> &AppError {
177 &self.error
178 }
179
180 #[must_use]
181 pub const fn color_error(&self) -> bool {
182 self.color_error
183 }
184}
185
186pub fn run_cli() -> Result<ExitCode, CliError> {
187 let environment_color = ColorPolicy::resolve(None);
188 let Cli {
189 channel,
190 color,
191 command,
192 } = parse_cli(environment_color);
193 let color = ColorPolicy::resolve(color.map(Into::into));
194
195 if let CommandKind::InstallMan {
196 channel: command_channel,
197 man_dir,
198 } = &command
199 {
200 return install_man_page(
201 channel.as_deref().or(command_channel.as_deref()),
202 man_dir.as_deref(),
203 )
204 .map_err(|error| CliError {
205 error,
206 color_error: color.stderr(),
207 });
208 }
209
210 let git = Git::discover().map_err(|error| CliError {
211 error,
212 color_error: color.stderr(),
213 })?;
214 run_cli_inner(&git, channel.as_deref(), command, color).map_err(|error| CliError {
215 error,
216 color_error: color.stderr(),
217 })
218}
219
220fn parse_cli(color: ColorPolicy) -> Cli {
221 let command = Cli::command().color(if color.stderr() {
222 ColorChoice::Always
223 } else {
224 ColorChoice::Never
225 });
226 let matches = command.get_matches();
227 Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit())
228}
229
230fn run_cli_inner(
231 git: &Git,
232 channel: Option<&str>,
233 command: CommandKind,
234 color: ColorPolicy,
235) -> Result<ExitCode, AppError> {
236 match command {
237 CommandKind::InstallMan {
238 channel: command_channel,
239 man_dir,
240 } => install_man_page(channel.or(command_channel.as_deref()), man_dir.as_deref()),
241 CommandKind::Channel { command } => run_channel_command(git, channel, command),
242 CommandKind::Mark { paths, allow_dirty } => {
243 with_selected_channel(git, channel, |_, notes| {
244 let dirty_paths = if allow_dirty {
245 DirtyPathHandling::Allow
246 } else {
247 DirtyPathHandling::Prompt
248 };
249 mark_paths(git, notes, &paths, MarkOptions { dirty_paths })?;
250 Ok(ExitCode::SUCCESS)
251 })
252 }
253 CommandKind::Unmark { paths } => with_selected_channel(git, channel, |_, notes| {
254 unmark_paths(git, notes, &paths)?;
255 Ok(ExitCode::SUCCESS)
256 }),
257 CommandKind::Status {
258 json,
259 all,
260 check,
261 workspace,
262 paths,
263 } => with_selected_channel(git, channel, |channel, notes| {
264 let target = if workspace {
265 StatusTarget::Workspace
266 } else {
267 StatusTarget::Head
268 };
269 match status(
270 git,
271 notes,
272 channel,
273 StatusMode {
274 json,
275 all: all || !paths.is_empty(),
276 check,
277 target,
278 color,
279 },
280 &paths,
281 )? {
282 Gate::Open => Ok(ExitCode::SUCCESS),
283 Gate::Closed => Ok(ExitCode::from(1)),
284 }
285 }),
286 CommandKind::Diff { path, workspace } => with_selected_channel(git, channel, |_, notes| {
287 let target = if workspace {
288 DiffTarget::Workspace
289 } else {
290 DiffTarget::Head
291 };
292 diff_path(git, notes, &path, target, color)?;
293 Ok(ExitCode::SUCCESS)
294 }),
295 CommandKind::Sync { remote } => with_selected_channel(git, channel, |channel, notes| {
296 let remote = git.select_sync_remote(remote.as_deref())?;
297 let mut progress = SyncProgressReporter::from_environment(color.stderr());
298 sync_notes(notes, channel, &remote, &mut progress)?;
299 Ok(ExitCode::SUCCESS)
300 }),
301 CommandKind::Prune => with_selected_channel(git, channel, |_, notes| {
302 notes.prune()?;
303 Ok(ExitCode::SUCCESS)
304 }),
305 }
306}
307
308fn install_man_page(
309 explicit_channel: Option<&str>,
310 man_dir: Option<&Path>,
311) -> Result<ExitCode, AppError> {
312 if explicit_channel.is_some() {
313 return Err(AppError::ChannelOptionNotAllowedForInstallMan);
314 }
315
316 let destination = manpage::install(man_dir).map_err(|error| AppError::ManPageInstall {
317 details: error.to_string(),
318 })?;
319 writeln!(
320 io::stdout().lock(),
321 "Installed git-vet(1) at {}",
322 destination.display()
323 )?;
324 Ok(ExitCode::SUCCESS)
325}
326
327fn run_channel_command(
328 git: &Git,
329 explicit_channel: Option<&str>,
330 command: ChannelCommand,
331) -> Result<ExitCode, AppError> {
332 let (command_name, kind, source, destination) = match command {
333 ChannelCommand::List { json } => {
334 if explicit_channel.is_some() {
335 return Err(AppError::ChannelOptionNotAllowedForList);
336 }
337
338 let channels = GitNotesChannelStore::new(git);
339 list_channels(&channels, json)?;
340 return Ok(ExitCode::SUCCESS);
341 }
342 ChannelCommand::Copy {
343 source,
344 destination,
345 } => (
346 "channel copy",
347 ChannelTransferKind::Copy,
348 source,
349 destination,
350 ),
351 ChannelCommand::Move {
352 source,
353 destination,
354 } => (
355 "channel move",
356 ChannelTransferKind::Move,
357 source,
358 destination,
359 ),
360 ChannelCommand::Remove { target, force } => {
361 if explicit_channel.is_some() {
362 return Err(AppError::ChannelOptionNotAllowedForRemove);
363 }
364
365 let channel = review_channel_from_input(&target, ReviewChannelSource::RemoveTarget)?;
366 let channels = GitNotesChannelStore::new(git);
367 remove_channel(&channels, &channel, force)?;
368 return Ok(ExitCode::SUCCESS);
369 }
370 };
371
372 if explicit_channel.is_some() {
373 return Err(AppError::ChannelOptionNotAllowed {
374 command: command_name,
375 });
376 }
377
378 let source = review_channel_from_input(&source, ReviewChannelSource::TransferSource)?;
379 let destination =
380 review_channel_from_input(&destination, ReviewChannelSource::TransferDestination)?;
381 let transfer = ChannelTransfer::new(kind, source, destination)?;
382 let channels = GitNotesChannelStore::new(git);
383 transfer_channel_notes(git, &channels, &transfer)?;
384 Ok(ExitCode::SUCCESS)
385}
386
387fn with_selected_channel<T>(
388 git: &Git,
389 explicit_channel: Option<&str>,
390 operation: impl FnOnce(&ReviewChannel, &GitNotesStore<'_>) -> Result<T, AppError>,
391) -> Result<T, AppError> {
392 let channel = review_channel_from_selection(explicit_channel, git)?;
393 let notes = GitNotesStore::new(git, channel.notes_ref().clone());
394 operation(&channel, ¬es)
395}
396
397fn review_channel_from_selection(
398 explicit: Option<&str>,
399 git: &Git,
400) -> Result<ReviewChannel, AppError> {
401 match explicit {
402 Some(input) => review_channel_from_input(input, ReviewChannelSource::Cli),
403 None => git.configured_review_channel()?.map_or_else(
404 || {
405 review_channel_from_input(
406 DEFAULT_REVIEW_CHANNEL,
407 ReviewChannelSource::BuiltInDefault,
408 )
409 },
410 |input| review_channel_from_input(&input, ReviewChannelSource::Config),
411 ),
412 }
413}
414
415#[derive(Clone, Copy, Debug)]
416enum ReviewChannelSource {
417 Cli,
418 Config,
419 BuiltInDefault,
420 TransferSource,
421 TransferDestination,
422 RemoveTarget,
423}
424
425fn review_channel_from_input(
426 input: &str,
427 source: ReviewChannelSource,
428) -> Result<ReviewChannel, AppError> {
429 ReviewChannel::new(input).map_err(|error| channel_error_from_source(input, &error, source))
430}
431
432fn channel_error_from_source(
433 input: &str,
434 error: &ChannelError,
435 source: ReviewChannelSource,
436) -> AppError {
437 AppError::InvalidChannel {
438 channel: input.to_owned(),
439 details: details_from_source(error.to_string(), source),
440 }
441}
442
443fn details_from_source(details: String, source: ReviewChannelSource) -> String {
444 match source {
445 ReviewChannelSource::Config => format!("from git config vet.channel: {details}"),
446 ReviewChannelSource::TransferSource => format!("from SOURCE argument: {details}"),
447 ReviewChannelSource::TransferDestination => {
448 format!("from DESTINATION argument: {details}")
449 }
450 ReviewChannelSource::RemoveTarget => format!("from CHANNEL argument: {details}"),
451 ReviewChannelSource::Cli | ReviewChannelSource::BuiltInDefault => details,
452 }
453}