use std::collections::HashSet;
use std::path::{Path, PathBuf};
use clap::ValueEnum;
use sparcli::{Select, SparcliError};
use crate::cli::context::CliContext;
use crate::cli::output::{CliError, CliResult, Streams, info, success};
use crate::domain::doctor;
use crate::domain::repo::{Repo, RepoKind};
use crate::service::repo_service::RepoService;
use crate::util::paths;
use crate::util::scan::{self, ScanOptions};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum KindArg {
Git,
Path,
}
impl From<KindArg> for RepoKind {
fn from(kind: KindArg) -> Self {
match kind {
KindArg::Git => RepoKind::Git,
KindArg::Path => RepoKind::Path,
}
}
}
pub struct AddRequest {
pub path: Option<PathBuf>,
pub slug: Option<String>,
pub section: Option<String>,
pub name: Option<String>,
pub kind: Option<KindArg>,
}
pub struct ScanRequest {
pub dir: Option<PathBuf>,
pub depth: Option<usize>,
pub nested: bool,
pub dry_run: bool,
pub assume_yes: bool,
}
pub fn add(
service: &mut RepoService,
request: AddRequest,
streams: &mut Streams,
) -> CliResult {
let raw = request.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(request.kind, absolute.join(".git").exists());
let mut repo = Repo::new(absolute.clone());
repo.name = request.name;
repo.slug = request.slug;
repo.section = request.section.clone();
repo.kind = kind;
service.add(repo)?;
if let Some(section) = &request.section {
service.ensure_section(kind, section)?;
}
success(
streams,
format!("Added {} ({})", absolute.display(), kind.as_config_value()),
);
Ok(())
}
fn resolve_kind(explicit: Option<KindArg>, has_git_dir: bool) -> RepoKind {
match explicit {
Some(kind) => kind.into(),
None if has_git_dir => RepoKind::Git,
None => RepoKind::Path,
}
}
pub fn scan(
service: &mut RepoService,
request: ScanRequest,
ctx: CliContext,
streams: &mut Streams,
) -> CliResult {
let raw = request.dir.clone().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() {
info(
streams,
format!("No new git repos under {}.", root.display()),
);
return Ok(());
}
if request.dry_run {
streams.line(&format!(
"{} new git repo(s) under {}:",
new.len(),
root.display()
))?;
for path in &new {
streams.line(&format!(" {}", path.display()))?;
}
if !duplicates.is_empty() {
info(streams, format!("({} already in hop)", duplicates.len()));
}
return Ok(());
}
if !duplicates.is_empty() {
info(streams, format!("{} already in hop.", duplicates.len()));
}
let chosen = choose_repos(&new, &root, &request, ctx, streams)?;
if chosen.is_empty() {
info(streams, "Nothing selected.");
return Ok(());
}
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();
service.add_many(repos)?;
success(streams, format!("Added {count} git repo(s)."));
Ok(())
}
fn choose_repos(
new: &[PathBuf],
root: &Path,
request: &ScanRequest,
ctx: CliContext,
streams: &mut Streams,
) -> Result<Vec<PathBuf>, CliError> {
if request.assume_yes {
return Ok(new.to_vec());
}
if !ctx.is_interactive {
return Err(CliError::usage(
"`hop scan` needs a terminal to choose the repos; pass --yes to \
import all of them, or --dry-run to only list them",
));
}
match pick_repos_to_import(new, root) {
Ok(Some(chosen)) => Ok(chosen),
Ok(None) => {
info(streams, "Cancelled.");
Ok(Vec::new())
}
Err(error) => Err(CliError::runtime(format!("terminal: {error}"))),
}
}
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())
}
pub fn run_doctor(service: &RepoService, streams: &mut Streams) -> CliResult {
let issues = doctor::diagnose(
service.repos(),
|path| path.exists(),
|path| path.join(".git").exists(),
);
if issues.is_empty() {
success(streams, "hop doctor: no issues.");
return Ok(());
}
for issue in &issues {
streams.line(&format!("{issue}"))?;
}
Err(CliError::runtime(format!(
"{} entry problem(s) found",
issues.len()
)))
}
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()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::output::EXIT_USAGE;
#[test]
fn partition_found_splits_new_from_known() {
let found = vec![PathBuf::from("/a"), PathBuf::from("/b")];
let known: HashSet<String> = ["/a".to_string()].into_iter().collect();
let (new, duplicates) =
partition_found(&found, &known, |p| p.display().to_string());
assert_eq!(new, vec![PathBuf::from("/b")]);
assert_eq!(duplicates, vec![PathBuf::from("/a")]);
}
#[test]
fn resolve_kind_honours_explicit_then_auto_detects() {
assert_eq!(resolve_kind(Some(KindArg::Path), true), RepoKind::Path);
assert_eq!(resolve_kind(Some(KindArg::Git), false), RepoKind::Git);
assert_eq!(resolve_kind(None, true), RepoKind::Git);
assert_eq!(resolve_kind(None, false), RepoKind::Path);
}
#[test]
fn scanning_without_a_terminal_is_a_usage_error_naming_the_flag() {
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
};
let request = ScanRequest {
dir: None,
depth: None,
nested: false,
dry_run: false,
assume_yes: false,
};
let ctx = CliContext {
is_interactive: false,
is_output_tty: false,
};
let error = choose_repos(
&[PathBuf::from("/a")],
Path::new("/"),
&request,
ctx,
&mut streams,
)
.expect_err("no terminal, no --yes: must fail");
assert_eq!(error.code(), EXIT_USAGE);
assert!(error.to_string().contains("--yes"));
}
#[test]
fn assume_yes_imports_everything_without_a_terminal() {
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
};
let request = ScanRequest {
dir: None,
depth: None,
nested: false,
dry_run: false,
assume_yes: true,
};
let ctx = CliContext {
is_interactive: false,
is_output_tty: false,
};
let found = vec![PathBuf::from("/a"), PathBuf::from("/b")];
let chosen =
choose_repos(&found, Path::new("/"), &request, ctx, &mut streams)
.unwrap();
assert_eq!(chosen, found);
}
}