use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::domain::error::{Error, Result};
use crate::domain::repo::{GitInfo, Repo};
use crate::domain::sections;
use crate::domain::slug;
use crate::storage::repository::RepoRepository;
use crate::storage::usage_state;
struct UndoSnapshot {
repos: Vec<Repo>,
label: String,
}
pub struct RepoService {
repository: Box<dyn RepoRepository>,
repos: Vec<Repo>,
sections: Vec<String>,
usage_path: PathBuf,
selected_repo_path: PathBuf,
undo: Option<UndoSnapshot>,
}
impl RepoService {
pub fn new(
repository: Box<dyn RepoRepository>,
usage_path: PathBuf,
selected_repo_path: PathBuf,
) -> Result<Self> {
let mut service = RepoService {
repos: repository.find_all()?,
sections: repository.find_sections()?,
repository,
usage_path,
selected_repo_path,
undo: None,
};
service.hydrate_usage();
Ok(service)
}
pub fn repos(&self) -> &[Repo] {
&self.repos
}
pub fn get(&self, index: usize) -> Option<&Repo> {
self.repos.get(index)
}
pub fn index_by_slug(&self, slug: &str) -> Option<usize> {
self.repos
.iter()
.position(|repo| repo.slug.as_deref() == Some(slug))
}
pub fn add(&mut self, repo: Repo) -> Result<()> {
if let Some(slug) = repo.slug.clone() {
self.check_slug(&slug, None)?;
}
self.mutate("add entry", |repos| {
repos.push(repo);
Ok(())
})
}
pub fn add_many(&mut self, repos: Vec<Repo>) -> Result<()> {
if repos.is_empty() {
return Ok(());
}
for repo in &repos {
if let Some(slug) = &repo.slug {
self.check_slug(slug, None)?;
}
}
self.mutate("add entries", |existing| {
existing.extend(repos);
Ok(())
})
}
pub fn update(&mut self, index: usize, repo: Repo) -> Result<()> {
self.ensure_index(index)?;
if let Some(slug) = repo.slug.clone() {
self.check_slug(&slug, Some(index))?;
}
self.mutate("edit entry", |repos| {
repos[index] = repo;
Ok(())
})
}
pub fn delete(&mut self, index: usize) -> Result<()> {
self.ensure_index(index)?;
self.mutate("delete entry", |repos| {
repos.remove(index);
Ok(())
})
}
pub fn toggle_fav(&mut self, index: usize) -> Result<()> {
self.ensure_index(index)?;
self.mutate("toggle favourite", |repos| {
repos[index].fav = !repos[index].fav;
Ok(())
})
}
pub fn set_archived(&mut self, index: usize, archived: bool) -> Result<()> {
self.ensure_index(index)?;
let label = if archived {
"archive entry"
} else {
"restore entry"
};
self.mutate(label, |repos| {
repos[index].archived = archived;
Ok(())
})
}
pub fn set_slug(
&mut self,
index: usize,
new_slug: Option<String>,
) -> Result<()> {
self.ensure_index(index)?;
if let Some(slug) = &new_slug {
self.check_slug(slug, Some(index))?;
}
self.mutate("set slug", |repos| {
repos[index].slug = new_slug;
Ok(())
})
}
pub fn delete_many(&mut self, indices: &[usize]) -> Result<()> {
for &index in indices {
self.ensure_index(index)?;
}
let mut sorted = indices.to_vec();
sorted.sort_unstable();
sorted.dedup();
self.mutate("delete entries", |repos| {
for &index in sorted.iter().rev() {
repos.remove(index);
}
Ok(())
})
}
pub fn set_archived_many(
&mut self,
indices: &[usize],
archived: bool,
) -> Result<()> {
for &index in indices {
self.ensure_index(index)?;
}
let label = if archived {
"archive entries"
} else {
"restore entries"
};
let indices = indices.to_vec();
self.mutate(label, |repos| {
for &index in &indices {
repos[index].archived = archived;
}
Ok(())
})
}
pub fn set_fav_many(&mut self, indices: &[usize], fav: bool) -> Result<()> {
for &index in indices {
self.ensure_index(index)?;
}
let indices = indices.to_vec();
self.mutate("set favourite", |repos| {
for &index in &indices {
repos[index].fav = fav;
}
Ok(())
})
}
pub fn swap_entries(&mut self, a: usize, b: usize) -> Result<()> {
self.ensure_index(a)?;
self.ensure_index(b)?;
self.mutate("reorder entry", |repos| {
repos.swap(a, b);
Ok(())
})
}
pub fn set_path(&mut self, index: usize, path: PathBuf) -> Result<()> {
self.ensure_index(index)?;
self.mutate("repair path", |repos| {
repos[index].path = path;
Ok(())
})
}
pub fn sections(&self) -> &[String] {
&self.sections
}
pub fn add_section(&mut self, name: &str) -> Result<()> {
let name = name.trim();
self.validate_section_name(name, None)?;
let mut next = self.sections.clone();
next.push(name.to_string());
self.persist_sections(next)
}
pub fn ensure_section(&mut self, name: &str) -> Result<()> {
let name = name.trim();
if name.is_empty() || self.section_index(name).is_some() {
return Ok(());
}
let mut next = self.sections.clone();
next.push(name.to_string());
self.persist_sections(next)
}
pub fn rename_section(&mut self, old: &str, new: &str) -> Result<()> {
let new = new.trim();
let pos = self
.section_index(old)
.ok_or_else(|| Error::NotFound(format!("section '{old}'")))?;
self.validate_section_name(new, Some(pos))?;
let old_name = self.sections[pos].clone();
let new_name = new.to_string();
self.mutate("rename section", |repos| {
for repo in repos.iter_mut() {
if repo.section.as_deref() == Some(old_name.as_str()) {
repo.section = Some(new_name.clone());
}
}
Ok(())
})?;
let mut next = self.sections.clone();
next[pos] = new.to_string();
self.persist_sections(next)
}
pub fn delete_section(&mut self, name: &str) -> Result<()> {
let pos = self
.section_index(name)
.ok_or_else(|| Error::NotFound(format!("section '{name}'")))?;
let removed = self.sections[pos].clone();
self.mutate("delete section", |repos| {
for repo in repos.iter_mut() {
if repo.section.as_deref() == Some(removed.as_str()) {
repo.section = None;
}
}
Ok(())
})?;
let mut next = self.sections.clone();
next.remove(pos);
self.persist_sections(next)
}
pub fn move_section(&mut self, from: usize, to: usize) -> Result<()> {
let len = self.sections.len();
if from >= len || to >= len {
return Err(Error::NotFound(format!("section index {from}/{to}")));
}
let mut next = self.sections.clone();
let name = next.remove(from);
next.insert(to, name);
self.persist_sections(next)
}
fn section_index(&self, name: &str) -> Option<usize> {
let name = name.trim();
self.sections
.iter()
.position(|section| section.eq_ignore_ascii_case(name))
}
fn validate_section_name(
&self,
name: &str,
except: Option<usize>,
) -> Result<()> {
if name.is_empty() {
return Err(Error::invalid("section name must not be empty"));
}
if name.eq_ignore_ascii_case(sections::UNGROUPED) {
return Err(Error::invalid("'Ungrouped' is a reserved name"));
}
let clash = self.sections.iter().enumerate().any(|(index, section)| {
Some(index) != except && section.eq_ignore_ascii_case(name)
});
if clash {
return Err(Error::invalid(format!(
"section '{name}' already exists"
)));
}
Ok(())
}
fn persist_sections(&mut self, next: Vec<String>) -> Result<()> {
let previous = std::mem::replace(&mut self.sections, next);
if let Err(error) = self.repository.save_sections(&self.sections) {
self.sections = previous;
return Err(error);
}
Ok(())
}
pub fn undo(&mut self) -> Result<Option<String>> {
let Some(snapshot) = self.undo.take() else {
return Ok(None);
};
self.repository.save_all(&snapshot.repos)?;
self.repos = snapshot.repos;
self.hydrate_usage();
Ok(Some(snapshot.label))
}
pub fn mark_used(&mut self, index: usize) -> Result<()> {
let repo = self
.repos
.get(index)
.ok_or_else(|| Error::NotFound(format!("index {index}")))?;
let path = repo.path.clone();
usage_state::record(&self.usage_path, &path)?;
if let Some(usage) =
usage_state::load(&self.usage_path).get(&path).copied()
{
let repo = &mut self.repos[index];
repo.last_used = usage.last_used;
repo.open_count = usage.open_count;
}
Ok(())
}
pub fn write_selected(&self, repo_path: &Path) -> Result<()> {
usage_state::write_selected_repo(&self.selected_repo_path, repo_path)
}
pub fn apply_git_infos(&mut self, infos: &HashMap<PathBuf, GitInfo>) {
for repo in &mut self.repos {
if let Some(info) = infos.get(&repo.path) {
repo.git_info = Some(info.clone());
}
}
}
pub fn set_git_info(&mut self, path: &Path, info: GitInfo) {
for repo in &mut self.repos {
if repo.path == path {
repo.git_info = Some(info.clone());
}
}
}
fn hydrate_usage(&mut self) {
let usage = usage_state::load(&self.usage_path);
for repo in &mut self.repos {
if let Some(entry) = usage.get(&repo.path) {
repo.last_used = entry.last_used;
repo.open_count = entry.open_count;
}
}
}
fn check_slug(&self, slug: &str, except: Option<usize>) -> Result<()> {
slug::validate_format(slug)?;
let clash = self.repos.iter().enumerate().any(|(index, repo)| {
Some(index) != except && repo.slug.as_deref() == Some(slug)
});
if clash {
return Err(Error::Slug(format!(
"slug '{slug}' is already in use"
)));
}
Ok(())
}
fn ensure_index(&self, index: usize) -> Result<()> {
if index >= self.repos.len() {
return Err(Error::NotFound(format!("index {index}")));
}
Ok(())
}
fn mutate<F>(&mut self, label: &str, f: F) -> Result<()>
where
F: FnOnce(&mut Vec<Repo>) -> Result<()>,
{
let snapshot = self.repos.clone();
if let Err(error) = f(&mut self.repos) {
self.repos = snapshot;
return Err(error);
}
if let Err(error) = self.repository.save_all(&self.repos) {
self.repos = snapshot;
return Err(error);
}
self.undo = Some(UndoSnapshot {
repos: snapshot,
label: label.to_string(),
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::repo::RepoKind;
use crate::storage::in_memory_repository::InMemoryRepoRepository;
fn service(initial: Vec<Repo>) -> RepoService {
let dir = std::env::temp_dir().join(format!(
"hop-svc-{}-{}",
std::process::id(),
initial.len()
));
RepoService::new(
Box::new(InMemoryRepoRepository::new(initial)),
dir.join("usage.toml"),
dir.join("selected.txt"),
)
.unwrap()
}
fn repo(name: &str) -> Repo {
let mut repo = Repo::new(PathBuf::from(format!("/code/{name}")));
repo.name = Some(name.to_string());
repo
}
#[test]
fn add_rejects_duplicate_slug() {
let mut a = repo("a");
a.slug = Some("x".to_string());
let mut svc = service(vec![a]);
let mut b = repo("b");
b.slug = Some("x".to_string());
assert!(matches!(svc.add(b), Err(Error::Slug(_))));
assert_eq!(svc.repos().len(), 1);
}
#[test]
fn delete_many_removes_all_and_undoes_as_one() {
let mut svc = service(vec![repo("a"), repo("b"), repo("c")]);
svc.delete_many(&[0, 2]).unwrap();
let names: Vec<_> =
svc.repos().iter().map(Repo::display_name).collect();
assert_eq!(names, vec!["b"]);
svc.undo().unwrap();
assert_eq!(svc.repos().len(), 3);
}
#[test]
fn set_archived_and_fav_many() {
let mut svc = service(vec![repo("a"), repo("b"), repo("c")]);
svc.set_archived_many(&[0, 1], true).unwrap();
assert!(svc.get(0).unwrap().archived);
assert!(svc.get(1).unwrap().archived);
assert!(!svc.get(2).unwrap().archived);
svc.set_fav_many(&[1, 2], true).unwrap();
assert!(svc.get(1).unwrap().fav);
assert!(svc.get(2).unwrap().fav);
}
#[test]
fn swap_entries_reorders() {
let mut svc = service(vec![repo("a"), repo("b"), repo("c")]);
svc.swap_entries(0, 2).unwrap();
let names: Vec<_> =
svc.repos().iter().map(Repo::display_name).collect();
assert_eq!(names, vec!["c", "b", "a"]);
}
#[test]
fn add_rejects_reserved_slug() {
let mut svc = service(vec![]);
let mut a = repo("a");
a.slug = Some("list".to_string());
assert!(matches!(svc.add(a), Err(Error::Slug(_))));
}
#[test]
fn archive_and_restore_round_trip() {
let mut svc = service(vec![repo("a")]);
svc.set_archived(0, true).unwrap();
assert!(svc.get(0).unwrap().archived);
svc.set_archived(0, false).unwrap();
assert!(!svc.get(0).unwrap().archived);
}
#[test]
fn toggle_fav_flips_flag() {
let mut svc = service(vec![repo("a")]);
svc.toggle_fav(0).unwrap();
assert!(svc.get(0).unwrap().fav);
}
#[test]
fn set_slug_then_lookup_by_slug() {
let mut svc = service(vec![repo("a"), repo("b")]);
svc.set_slug(1, Some("bee".to_string())).unwrap();
assert_eq!(svc.index_by_slug("bee"), Some(1));
}
#[test]
fn undo_reverts_last_change() {
let mut svc = service(vec![repo("a")]);
svc.add(repo("b")).unwrap();
assert_eq!(svc.repos().len(), 2);
let label = svc.undo().unwrap();
assert_eq!(label.as_deref(), Some("add entry"));
assert_eq!(svc.repos().len(), 1);
}
fn sectioned(name: &str, section: Option<&str>) -> Repo {
let mut repo = repo(name);
repo.section = section.map(str::to_string);
repo
}
#[test]
fn add_section_rejects_empty_reserved_and_duplicate() {
let mut svc = service(vec![]);
svc.add_section("Work").unwrap();
assert_eq!(svc.sections(), ["Work"]);
assert!(matches!(svc.add_section(" "), Err(Error::Invalid(_))));
assert!(matches!(
svc.add_section("Ungrouped"),
Err(Error::Invalid(_))
));
assert!(matches!(svc.add_section("work"), Err(Error::Invalid(_))));
}
#[test]
fn ensure_section_is_idempotent() {
let mut svc = service(vec![]);
svc.ensure_section("Work").unwrap();
svc.ensure_section("work").unwrap();
svc.ensure_section(" ").unwrap();
assert_eq!(svc.sections(), ["Work"]);
}
#[test]
fn rename_section_updates_entries() {
let mut svc =
service(vec![sectioned("a", Some("Work")), sectioned("b", None)]);
svc.add_section("Work").unwrap();
svc.rename_section("Work", "Job").unwrap();
assert_eq!(svc.sections(), ["Job"]);
assert_eq!(svc.get(0).unwrap().section.as_deref(), Some("Job"));
assert_eq!(svc.get(1).unwrap().section, None);
}
#[test]
fn delete_section_ungroups_entries() {
let mut svc = service(vec![sectioned("a", Some("Work"))]);
svc.add_section("Work").unwrap();
svc.delete_section("Work").unwrap();
assert!(svc.sections().is_empty());
assert_eq!(svc.get(0).unwrap().section, None);
}
#[test]
fn move_section_reorders() {
let mut svc = service(vec![]);
svc.add_section("Work").unwrap();
svc.add_section("Personal").unwrap();
svc.add_section("Misc").unwrap();
svc.move_section(2, 0).unwrap();
assert_eq!(svc.sections(), ["Misc", "Work", "Personal"]);
}
#[test]
fn delete_removes_entry() {
let mut svc = service(vec![repo("a"), repo("b")]);
svc.delete(0).unwrap();
assert_eq!(svc.repos().len(), 1);
assert_eq!(svc.get(0).unwrap().display_name(), "b");
}
#[test]
fn add_many_appends_all_in_one_action() {
let mut svc = service(vec![repo("a")]);
svc.add_many(vec![repo("b"), repo("c")]).unwrap();
let names: Vec<_> =
svc.repos().iter().map(Repo::display_name).collect();
assert_eq!(names, vec!["a", "b", "c"]);
svc.undo().unwrap();
assert_eq!(svc.repos().len(), 1);
svc.add_many(vec![]).unwrap();
assert_eq!(svc.repos().len(), 1);
}
#[test]
fn update_changes_kind() {
let mut svc = service(vec![repo("a")]);
let mut edited = repo("a");
edited.kind = RepoKind::Path;
svc.update(0, edited).unwrap();
assert_eq!(svc.get(0).unwrap().kind, RepoKind::Path);
}
}