pub mod context;
mod demo;
mod jump;
mod list;
pub mod output;
mod scan;
use std::io;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use crate::cli::context::CliContext;
use crate::cli::output::{
CliError, CliResult, EXIT_OK, Streams, apply_sparcli_theme, report,
};
use crate::cli::scan::{AddRequest, KindArg, ScanRequest};
use crate::config::Config;
use crate::config::loader::load_config;
use crate::domain::repo::RepoKind;
use crate::service::repo_service::RepoService;
use crate::storage::in_memory_repository::InMemoryRepoRepository;
use crate::storage::repository::RepoRepository;
use crate::storage::subprocess_git_client::SubprocessGitClient;
use crate::storage::toml_repo_repository::TomlRepoRepository;
use crate::tui::{self, App, RunOutcome, StartupStatus, Tui};
use crate::util::app_info::{APP_ABOUT, APP_NAME};
use crate::util::opener::{
launch_git_tool, open_default_app, open_in_editor, resolve_editor,
};
use crate::util::paths;
const CONFIG_ENV: &str = "HOP_CONFIG";
const EXAMPLES: &str = "\
Examples:
hop Opens the interactive picker (the default)
hop myrepo Jumps to the entry with the slug 'myrepo'
hop add ~/code/project Registers a path, detecting whether it is a repo
hop scan ~/code --yes Imports every git repo found, without asking
hop list --json | jq . Prints the entries as machine-readable JSON
Exit codes:
0 success
1 runtime error
2 usage error (unknown option, missing value, no terminal to prompt on)
130 interrupted with Ctrl+C";
#[derive(Debug, Parser)]
#[command(
name = APP_NAME,
version,
about = APP_ABOUT,
after_help = EXAMPLES,
)]
pub struct Cli {
#[arg(short = 'C', long, global = true, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(short = 's', long, global = true, hide = true)]
save_only: bool,
#[arg(long, global = true, overrides_with = "no_fetch")]
fetch: bool,
#[arg(long, global = true)]
no_fetch: bool,
#[arg(long, global = true)]
cached: bool,
#[arg(long, global = true)]
demo: bool,
#[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
verbose: u8,
#[arg(short = 'q', long, global = true, conflicts_with = "verbose")]
quiet: bool,
#[arg(long, global = true)]
no_color: bool,
#[command(subcommand)]
command: Option<Command>,
}
impl Cli {
pub fn log_level(&self) -> log::LevelFilter {
if self.quiet {
return log::LevelFilter::Error;
}
match self.verbose {
0 => log::LevelFilter::Info,
1 => log::LevelFilter::Debug,
_ => log::LevelFilter::Trace,
}
}
pub fn is_color_disabled(&self) -> bool {
self.no_color
}
}
#[derive(Debug, Subcommand)]
enum Command {
List {
#[arg(long)]
json: bool,
},
#[command(name = "show-config", alias = "config-path")]
ShowConfig,
Add {
path: Option<PathBuf>,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
section: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long, value_enum)]
kind: Option<KindArg>,
},
Scan {
dir: Option<PathBuf>,
#[arg(long)]
depth: Option<usize>,
#[arg(long)]
nested: bool,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
},
Doctor,
Completions {
#[arg(value_enum)]
shell: Shell,
},
Man,
#[command(external_subcommand)]
Jump(Vec<String>),
}
#[must_use]
pub fn run(cli: Cli) -> ExitCode {
let stdout = io::stdout();
let stderr = io::stderr();
let mut out = stdout.lock();
let mut err = stderr.lock();
let mut streams = Streams {
out: &mut out,
err: &mut err,
};
match dispatch(&cli, &mut streams) {
Ok(Outcome::Code(code)) => ExitCode::from(code),
Err(error) => report(&error, &mut io::stderr().lock()),
}
}
enum Outcome {
Code(u8),
}
fn dispatch(cli: &Cli, streams: &mut Streams) -> Result<Outcome, CliError> {
let config_path = resolve_config_path(cli);
let ctx = CliContext::detect();
match &cli.command {
Some(Command::ShowConfig) => {
streams.line(&config_path.display().to_string())?;
return Ok(Outcome::Code(EXIT_OK));
}
Some(Command::Completions { shell }) => {
let mut command = Cli::command();
clap_complete::generate(
*shell,
&mut command,
APP_NAME,
&mut streams.out,
);
return Ok(Outcome::Code(EXIT_OK));
}
Some(Command::Man) => {
clap_mangen::Man::new(Cli::command())
.render(&mut streams.out)
.map_err(CliError::from)?;
return Ok(Outcome::Code(EXIT_OK));
}
_ => {}
}
let config = load_config(&config_path)?;
apply_sparcli_theme(&config);
if cli.command.is_none() && cli.demo {
return run_demo(config).map(Outcome::Code);
}
let mut service = build_service(&config_path)?;
match &cli.command {
None => {
let startup = startup_status(cli, &config);
run_tui(config, service, startup).map(Outcome::Code)
}
Some(Command::List { json }) => {
list::run(&service, ctx, *json, streams)?;
Ok(Outcome::Code(EXIT_OK))
}
Some(Command::Jump(args)) => {
jump::run(&config, &mut service, args, cli.fetch)?;
Ok(Outcome::Code(EXIT_OK))
}
Some(Command::Add {
path,
slug,
section,
name,
kind,
}) => {
scan::add(
&mut service,
AddRequest {
path: path.clone(),
slug: slug.clone(),
section: section.clone(),
name: name.clone(),
kind: *kind,
},
streams,
)?;
Ok(Outcome::Code(EXIT_OK))
}
Some(Command::Scan {
dir,
depth,
nested,
dry_run,
yes,
}) => {
scan::scan(
&mut service,
ScanRequest {
dir: dir.clone(),
depth: *depth,
nested: *nested,
dry_run: *dry_run,
assume_yes: *yes,
},
ctx,
streams,
)?;
Ok(Outcome::Code(EXIT_OK))
}
Some(Command::Doctor) => {
scan::run_doctor(&service, streams)?;
Ok(Outcome::Code(EXIT_OK))
}
Some(
Command::ShowConfig | Command::Completions { .. } | Command::Man,
) => Ok(Outcome::Code(EXIT_OK)),
}
}
fn startup_status(cli: &Cli, config: &Config) -> StartupStatus {
if cli.cached {
return StartupStatus::Cached;
}
let fetch = if cli.no_fetch {
false
} else {
cli.fetch || config.fetch_on_start
};
StartupStatus::Refresh { fetch }
}
fn resolve_config_path(cli: &Cli) -> PathBuf {
if let Some(path) = &cli.config {
return path.clone();
}
if let Ok(value) = std::env::var(CONFIG_ENV)
&& !value.is_empty()
{
return PathBuf::from(value);
}
paths::config_file()
}
fn build_service(config_path: &Path) -> Result<RepoService, CliError> {
let repository = TomlRepoRepository::new(config_path.to_path_buf());
RepoService::new(
Box::new(repository),
paths::usage_file(),
paths::selected_repo_file(),
)
.map_err(CliError::from)
}
fn run_tui(
config: Config,
service: RepoService,
startup: StartupStatus,
) -> Result<u8, CliError> {
let git_client =
Arc::new(SubprocessGitClient::new(config.github_username.clone()));
let app = App::new(
config.clone(),
service,
git_client,
paths::cache_file(),
paths::ui_state_file(),
startup,
);
run_app(&config, app)
}
fn run_demo(mut config: Config) -> Result<u8, CliError> {
config.example_mode = true;
let dir = std::env::temp_dir().join("hop-demo");
let repository = InMemoryRepoRepository::new(demo::repos());
repository.save_sections(RepoKind::Git, &demo::git_sections())?;
repository.save_sections(RepoKind::Path, &demo::sections())?;
let service = RepoService::new(
Box::new(repository),
dir.join("usage.toml"),
dir.join("selected-repo.txt"),
)?;
let git_client =
Arc::new(SubprocessGitClient::new(config.github_username.clone()));
let app = App::new(
config.clone(),
service,
git_client,
dir.join("git-info-cache.toml"),
dir.join("ui-state.toml"),
StartupStatus::Cached,
);
run_app(&config, app)
}
fn run_app(config: &Config, app: App) -> Result<u8, CliError> {
let mut tui = Tui::new()
.map_err(|error| CliError::runtime(format!("terminal: {error}")))?;
let outcome = tui::run(app, &mut tui);
drop(tui);
let outcome = outcome
.map_err(|error| CliError::runtime(format!("terminal: {error}")))?;
perform_outcome(config, outcome)
}
fn perform_outcome(
config: &Config,
outcome: RunOutcome,
) -> Result<u8, CliError> {
match outcome {
RunOutcome::Quit | RunOutcome::Jumped => Ok(EXIT_OK),
RunOutcome::LaunchGitTool(path) => {
launch_tool(config, &path)?;
Ok(EXIT_OK)
}
RunOutcome::LaunchGitToolInline(_) => Ok(EXIT_OK),
RunOutcome::OpenFile(path) => {
let editor = resolve_editor(config.editor.as_deref());
open_in_editor(&editor, &path)?;
Ok(EXIT_OK)
}
RunOutcome::OpenWith(path) => {
open_default_app(&path)?;
Ok(EXIT_OK)
}
}
}
fn launch_tool(config: &Config, dir: &Path) -> CliResult {
let Some(program) = &config.git_program else {
return Ok(());
};
launch_git_tool(program, dir).map(|_| ()).map_err(|error| {
CliError::runtime(format!("could not launch {program}: {error}"))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::slug::RESERVED;
#[test]
fn the_parser_definition_is_internally_consistent() {
Cli::command().debug_assert();
}
#[test]
fn every_subcommand_name_is_a_reserved_slug() {
for command in Cli::command().get_subcommands() {
let name = command.get_name();
if name == "help" {
continue;
}
assert!(
RESERVED.contains(&name),
"subcommand '{name}' is missing from domain::slug::RESERVED"
);
}
}
#[test]
fn verbosity_flags_pick_the_log_level() {
let quiet = Cli::parse_from(["hop", "--quiet"]);
assert_eq!(quiet.log_level(), log::LevelFilter::Error);
let plain = Cli::parse_from(["hop"]);
assert_eq!(plain.log_level(), log::LevelFilter::Info);
let verbose = Cli::parse_from(["hop", "-v"]);
assert_eq!(verbose.log_level(), log::LevelFilter::Debug);
let louder = Cli::parse_from(["hop", "-vv"]);
assert_eq!(louder.log_level(), log::LevelFilter::Trace);
}
#[test]
fn the_command_line_overrides_fetch_on_start_in_both_directions() {
let mut config = Config {
fetch_on_start: true,
..Config::default()
};
let off = Cli::parse_from(["hop", "--no-fetch"]);
assert!(matches!(
startup_status(&off, &config),
StartupStatus::Refresh { fetch: false }
));
config.fetch_on_start = false;
let on = Cli::parse_from(["hop", "--fetch"]);
assert!(matches!(
startup_status(&on, &config),
StartupStatus::Refresh { fetch: true }
));
}
#[test]
fn the_legacy_config_path_name_still_resolves() {
let cli = Cli::parse_from(["hop", "config-path"]);
assert!(matches!(cli.command, Some(Command::ShowConfig)));
}
#[test]
fn an_unknown_kind_is_rejected_rather_than_silently_becoming_a_path() {
assert!(
Cli::try_parse_from(["hop", "add", "--kind", "gti", "."]).is_err()
);
let ok = Cli::parse_from(["hop", "add", "--kind", "git", "."]);
assert!(matches!(
ok.command,
Some(Command::Add {
kind: Some(KindArg::Git),
..
})
));
}
}