mod demo;
mod output;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use clap::{Parser, Subcommand};
use sparcli::{
Alert, Cell, Color as UiColor, Column, Renderable, Select, SparcliError,
Style as SpStyle, Table, Theme, set_theme,
};
use crate::config::Config;
use crate::config::loader::load_config;
use crate::domain::doctor;
use crate::domain::repo::{Repo, RepoKind, is_dir_target};
use crate::service::repo_service::RepoService;
use crate::storage::git_client::GitClient;
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::theme::{Color, GlyphVariant};
use crate::tui::{self, App, RunOutcome, StartupStatus, Tui};
use crate::util::app_info::{APP_ABOUT, APP_NAME, APP_VERSION};
use crate::util::opener::{
launch_git_tool, open_default_app, open_in_editor, resolve_editor,
};
use crate::util::paths;
use crate::util::scan::{self, ScanOptions};
const CONFIG_ENV: &str = "HOP_CONFIG";
#[derive(Debug, Parser)]
#[command(name = APP_NAME, version = APP_VERSION, about = APP_ABOUT)]
pub struct Cli {
#[arg(short = 'C', long, global = true, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(short = 's', long, global = true)]
save_only: bool,
#[arg(long, global = true)]
fetch: bool,
#[arg(long, global = true)]
cached: bool,
#[arg(long, global = true)]
demo: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
List,
#[command(name = "config-path")]
ConfigPath,
Add {
path: Option<PathBuf>,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
section: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
kind: Option<String>,
},
Scan {
dir: Option<PathBuf>,
#[arg(long)]
depth: Option<usize>,
#[arg(long)]
nested: bool,
#[arg(long)]
dry_run: bool,
},
Doctor,
#[command(external_subcommand)]
Jump(Vec<String>),
}
#[must_use]
pub fn run(cli: Cli) -> ExitCode {
let config_path = resolve_config_path(&cli);
match &cli.command {
Some(Command::ConfigPath) => {
println!("{}", config_path.display());
ExitCode::SUCCESS
}
_ => run_with_service(cli, config_path),
}
}
fn run_with_service(cli: Cli, config_path: PathBuf) -> ExitCode {
let config = match load_config(&config_path) {
Ok(config) => config,
Err(error) => return output::report_error(&error),
};
apply_sparcli_theme(&config);
if cli.command.is_none() && cli.demo {
return run_demo(config);
}
let service = match build_service(&config_path) {
Ok(service) => service,
Err(error) => return output::report_error(&error),
};
match &cli.command {
None => {
let startup = startup_status(&cli, &config);
run_tui(config, service, startup)
}
Some(Command::List) => cmd_list(&config, &service, &cli),
Some(Command::Jump(args)) => cmd_jump(&config, service, args, &cli),
Some(Command::Add {
path,
slug,
section,
name,
kind,
}) => cmd_add(service, path.clone(), slug, section, name, kind),
Some(Command::Scan {
dir,
depth,
nested,
dry_run,
}) => cmd_scan(
service,
ScanRequest {
dir: dir.clone(),
depth: *depth,
nested: *nested,
dry_run: *dry_run,
},
),
Some(Command::Doctor) => cmd_doctor(&service),
Some(Command::ConfigPath) => ExitCode::SUCCESS,
}
}
fn startup_status(cli: &Cli, config: &Config) -> StartupStatus {
if cli.cached {
return StartupStatus::Cached;
}
StartupStatus::Refresh {
fetch: cli.fetch || config.fetch_on_start,
}
}
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,
) -> crate::domain::error::Result<RepoService> {
let repository = TomlRepoRepository::new(config_path.to_path_buf());
RepoService::new(
Box::new(repository),
paths::usage_file(),
paths::selected_repo_file(),
)
}
fn run_tui(
config: Config,
service: RepoService,
startup: StartupStatus,
) -> ExitCode {
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) -> ExitCode {
config.example_mode = true;
let dir = std::env::temp_dir().join("hop-demo");
let repository = InMemoryRepoRepository::new(demo::repos());
let _ = repository.save_sections(&demo::sections());
let service = match RepoService::new(
Box::new(repository),
dir.join("usage.toml"),
dir.join("selected-repo.txt"),
) {
Ok(service) => service,
Err(error) => return output::report_error(&error),
};
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) -> ExitCode {
let mut tui = match Tui::new() {
Ok(tui) => tui,
Err(error) => return output::fail(&format!("terminal: {error}")),
};
let outcome = tui::run(app, &mut tui);
drop(tui);
match outcome {
Ok(outcome) => perform_outcome(config, outcome),
Err(error) => output::fail(&format!("terminal: {error}")),
}
}
fn perform_outcome(config: &Config, outcome: RunOutcome) -> ExitCode {
match outcome {
RunOutcome::Quit | RunOutcome::Jumped => ExitCode::SUCCESS,
RunOutcome::LaunchGitTool(path) => {
launch_tool(config, &path);
ExitCode::SUCCESS
}
RunOutcome::LaunchGitToolInline(_) => ExitCode::SUCCESS,
RunOutcome::OpenFile(path) => {
let editor = resolve_editor(config.editor.as_deref());
let _ = open_in_editor(&editor, &path);
ExitCode::SUCCESS
}
RunOutcome::OpenWith(path) => {
let _ = open_default_app(&path);
ExitCode::SUCCESS
}
}
}
fn launch_tool(config: &Config, dir: &Path) {
let Some(program) = &config.git_program else {
return;
};
if let Err(error) = launch_git_tool(program, dir) {
eprintln!("{APP_NAME}: could not launch {program}: {error}");
}
}
fn cmd_list(_config: &Config, service: &RepoService, cli: &Cli) -> ExitCode {
let repos = service.repos();
if repos.is_empty() {
let _ = Alert::info(
"No entries yet. Run hop to add some, or hop add <path>.",
)
.print();
return ExitCode::SUCCESS;
}
if sparcli::terminal::is_output_tty() {
print_list_table(repos);
} else {
for repo in repos {
println!("{}", list_line(repo));
}
}
let _ = (cli.fetch, cli.cached);
ExitCode::SUCCESS
}
fn print_list_table(repos: &[Repo]) {
let mut table = Table::new().columns([
Column::new("Slug"),
Column::new("Name"),
Column::new("Kind"),
Column::new("Path"),
Column::new("Flags"),
]);
for repo in repos {
table = table.row([
Cell::new(repo.slug.clone().unwrap_or_default()),
Cell::new(repo.display_name().to_string()),
Cell::new(repo.kind.as_config_value().to_string()),
Cell::new(repo.path.display().to_string()),
Cell::new(entry_flags(repo)),
]);
}
let _ = table.print();
}
fn entry_flags(repo: &Repo) -> String {
let mut flags = Vec::new();
if repo.fav {
flags.push("fav");
}
if repo.archived {
flags.push("archived");
}
flags.join(", ")
}
fn list_line(repo: &Repo) -> String {
let slug = repo
.slug
.as_deref()
.map(|s| format!("[{s}] "))
.unwrap_or_default();
let flags = entry_flags(repo);
let flags = if flags.is_empty() {
String::new()
} else {
format!(" ({flags})")
};
format!(
"{slug}{}\t{}\t{}{flags}",
repo.display_name(),
repo.kind.as_config_value(),
repo.path.display(),
)
}
fn apply_sparcli_theme(config: &Config) {
let palette = config.palette();
let mut theme = Theme {
accent: map_color(palette.accent),
unicode: matches!(config.appearance.glyphs, GlyphVariant::Unicode),
..Theme::default()
};
theme.success = SpStyle::new().fg(map_color(palette.success));
theme.error = SpStyle::new().fg(map_color(palette.error));
theme.warning = SpStyle::new().fg(map_color(palette.warning));
theme.info = SpStyle::new().fg(map_color(palette.info));
theme.secondary = SpStyle::new().fg(map_color(palette.foreground_dim));
set_theme(theme);
}
fn map_color(color: Color) -> UiColor {
match color.rgb() {
Some((red, green, blue)) => UiColor::Rgb(red, green, blue),
None => UiColor::Reset,
}
}
fn cmd_add(
mut service: RepoService,
path: Option<PathBuf>,
slug: &Option<String>,
section: &Option<String>,
name: &Option<String>,
kind: &Option<String>,
) -> ExitCode {
let raw = path.unwrap_or_else(|| PathBuf::from("."));
let expanded = paths::expand_tilde(&raw.to_string_lossy());
let absolute = std::path::absolute(&expanded).unwrap_or(expanded);
let kind = resolve_kind(kind.as_deref(), absolute.join(".git").exists());
let mut repo = Repo::new(absolute.clone());
repo.name = name.clone();
repo.slug = slug.clone();
repo.section = section.clone();
repo.kind = kind;
if let Err(error) = service.add(repo) {
return output::report_error(&error);
}
if let Some(section) = section {
let _ = service.ensure_section(section);
}
let _ = Alert::success(format!(
"Added {} ({})",
absolute.display(),
kind.as_config_value()
))
.print();
ExitCode::SUCCESS
}
fn resolve_kind(explicit: Option<&str>, has_git_dir: bool) -> RepoKind {
match explicit {
Some(value) => RepoKind::from_config_value(value),
None if has_git_dir => RepoKind::Git,
None => RepoKind::Path,
}
}
struct ScanRequest {
dir: Option<PathBuf>,
depth: Option<usize>,
nested: bool,
dry_run: bool,
}
fn cmd_scan(mut service: RepoService, request: ScanRequest) -> ExitCode {
let raw = request.dir.unwrap_or_else(|| PathBuf::from("."));
let expanded = paths::expand_tilde(&raw.to_string_lossy());
let root = std::path::absolute(&expanded).unwrap_or(expanded);
let found = scan::find_git_repos(
&root,
ScanOptions {
max_depth: request.depth,
nested: request.nested,
},
);
let known: HashSet<String> =
service.repos().iter().map(|r| canon_key(&r.path)).collect();
let (new, duplicates) = partition_found(&found, &known, canon_key);
if new.is_empty() {
let _ =
Alert::info(format!("No new git repos under {}.", root.display()))
.print();
return ExitCode::SUCCESS;
}
if request.dry_run {
println!("{} new git repo(s) under {}:", new.len(), root.display());
for path in &new {
println!(" {}", path.display());
}
if !duplicates.is_empty() {
println!("({} already in hop)", duplicates.len());
}
return ExitCode::SUCCESS;
}
if !duplicates.is_empty() {
let _ = Alert::info(format!("{} already in hop.", duplicates.len()))
.print();
}
let chosen = match pick_repos_to_import(&new, &root) {
Ok(Some(chosen)) => chosen,
Ok(None) => {
let _ = Alert::info("Cancelled.").print();
return ExitCode::SUCCESS;
}
Err(SparcliError::NoTerminal) => {
return output::fail(
"`hop scan` needs a terminal to choose the repos; \
pass --dry-run to only list them",
);
}
Err(error) => return output::fail(&format!("terminal: {error}")),
};
if chosen.is_empty() {
let _ = Alert::info("Nothing selected.").print();
return ExitCode::SUCCESS;
}
let count = chosen.len();
let repos: Vec<Repo> = chosen
.into_iter()
.map(|path| {
let mut repo = Repo::new(path);
repo.kind = RepoKind::Git;
repo
})
.collect();
if let Err(error) = service.add_many(repos) {
return output::report_error(&error);
}
let _ = Alert::success(format!("Added {count} git repo(s).")).print();
ExitCode::SUCCESS
}
fn pick_repos_to_import(
found: &[PathBuf],
root: &Path,
) -> Result<Option<Vec<PathBuf>>, SparcliError> {
let prompt =
format!("{} new git repo(s) under {}", found.len(), root.display());
let options = found.iter().map(|path| scan_option_label(path));
let outcome = Select::new(prompt)
.options(options)
.checked(0..found.len())
.multi()
.run_multi()?;
let Some(indices) = outcome.submitted() else {
return Ok(None);
};
Ok(Some(
indices
.into_iter()
.map(|index| found[index].clone())
.collect(),
))
}
fn scan_option_label(path: &Path) -> String {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
if name.is_empty() {
return path.display().to_string();
}
format!("{name} {}", path.display())
}
fn cmd_doctor(service: &RepoService) -> ExitCode {
let issues = doctor::diagnose(
service.repos(),
|path| path.exists(),
|path| path.join(".git").exists(),
);
if issues.is_empty() {
let _ = Alert::success("hop doctor: no issues.").print();
return ExitCode::SUCCESS;
}
let _ = Alert::warning(format!("hop doctor: {} issue(s)", issues.len()))
.print();
for issue in &issues {
println!(" {issue}");
}
ExitCode::FAILURE
}
fn partition_found(
found: &[PathBuf],
known: &HashSet<String>,
canon: impl Fn(&Path) -> String,
) -> (Vec<PathBuf>, Vec<PathBuf>) {
let mut new = Vec::new();
let mut duplicates = Vec::new();
for path in found {
if known.contains(&canon(path)) {
duplicates.push(path.clone());
} else {
new.push(path.clone());
}
}
(new, duplicates)
}
fn canon_key(path: &Path) -> String {
std::fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.into_owned()
}
fn cmd_jump(
config: &Config,
mut service: RepoService,
args: &[String],
cli: &Cli,
) -> ExitCode {
let (slug, _save_only) = parse_jump_args(args, cli.save_only);
let Some(slug) = slug else {
return output::fail("no slug given");
};
let Some(index) = service.index_by_slug(&slug) else {
return output::fail(&format!(
"unknown command or slug '{slug}' (see `hop --help`)"
));
};
let Some(repo) = service.get(index).cloned() else {
return output::fail("entry vanished");
};
if let Err(error) = service.mark_used(index) {
return output::report_error(&error);
}
if cli.fetch && repo.kind == RepoKind::Git {
SubprocessGitClient::new(config.github_username.clone())
.fetch(&repo.path);
}
perform_jump(&service, &repo)
}
fn perform_jump(service: &RepoService, repo: &Repo) -> ExitCode {
let target = jump_target(repo);
if let Err(error) = service.write_selected(&target) {
return output::report_error(&error);
}
ExitCode::SUCCESS
}
fn jump_target(repo: &Repo) -> PathBuf {
if repo.kind == RepoKind::Path && !is_dir_target(&repo.path) {
return repo
.path
.parent()
.map_or_else(|| repo.path.clone(), Path::to_path_buf);
}
repo.path.clone()
}
fn parse_jump_args(
args: &[String],
cli_save_only: bool,
) -> (Option<String>, bool) {
let mut slug = None;
let mut save_only = cli_save_only;
for arg in args {
if arg == "-s" || arg == "--save-only" {
save_only = true;
} else if slug.is_none() && !arg.starts_with('-') {
slug = Some(arg.clone());
}
}
(slug, save_only)
}
#[cfg(test)]
mod tests {
use clap::CommandFactory;
use super::*;
use crate::domain::slug;
#[test]
fn every_subcommand_name_is_a_reserved_slug() {
for sub in Cli::command().get_subcommands() {
let name = sub.get_name();
assert!(
slug::RESERVED.contains(&name),
"subcommand '{name}' is not in slug::RESERVED"
);
}
}
fn entry(name: &str, kind: RepoKind, path: &str) -> Repo {
let mut repo = Repo::new(PathBuf::from(path));
repo.name = Some(name.to_string());
repo.kind = kind;
repo
}
#[test]
fn parse_jump_args_extracts_slug_and_save_only() {
assert_eq!(
parse_jump_args(&["hop".to_string()], false),
(Some("hop".to_string()), false)
);
assert_eq!(
parse_jump_args(&["-s".to_string(), "hop".to_string()], false),
(Some("hop".to_string()), true)
);
assert_eq!(
parse_jump_args(&["hop".to_string()], true),
(Some("hop".to_string()), true)
);
assert_eq!(parse_jump_args(&[], false), (None, false));
}
#[test]
fn jump_target_uses_parent_only_for_files() {
let git = entry("hop", RepoKind::Git, "/code/hop");
assert_eq!(jump_target(&git), PathBuf::from("/code/hop"));
let folder = entry("docs", RepoKind::Path, "/code/docs/");
assert_eq!(jump_target(&folder), PathBuf::from("/code/docs/"));
let file = entry("readme", RepoKind::Path, "/code/hop/README.md");
assert_eq!(jump_target(&file), PathBuf::from("/code/hop"));
}
#[test]
fn list_line_includes_slug_kind_and_flags() {
let mut repo = entry("hop", RepoKind::Git, "/code/hop");
repo.slug = Some("hp".to_string());
repo.fav = true;
let line = list_line(&repo);
assert!(line.starts_with("[hp] hop\t"));
assert!(line.contains("\tgit\t/code/hop"));
assert!(line.ends_with("(fav)"));
}
#[test]
fn partition_found_splits_new_from_known() {
let found = vec![
PathBuf::from("/code/a"),
PathBuf::from("/code/b"),
PathBuf::from("/code/c"),
];
let known: HashSet<String> =
["/code/b".to_string()].into_iter().collect();
let (new, dups) = partition_found(&found, &known, |p| {
p.to_string_lossy().into_owned()
});
assert_eq!(
new,
vec![PathBuf::from("/code/a"), PathBuf::from("/code/c")]
);
assert_eq!(dups, vec![PathBuf::from("/code/b")]);
}
#[test]
fn resolve_kind_honours_explicit_then_auto_detects() {
assert_eq!(resolve_kind(Some("git"), false), RepoKind::Git);
assert_eq!(resolve_kind(Some("path"), true), RepoKind::Path);
assert_eq!(resolve_kind(Some("folder"), false), RepoKind::Path);
assert_eq!(resolve_kind(None, true), RepoKind::Git);
assert_eq!(resolve_kind(None, false), RepoKind::Path);
}
}