use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RepoKind {
#[default]
Git,
Path,
}
impl RepoKind {
pub fn from_config_value(value: &str) -> Self {
match value.trim().to_lowercase().as_str() {
"folder" | "dir" | "file" | "path" => RepoKind::Path,
_ => RepoKind::Git,
}
}
pub fn as_config_value(self) -> &'static str {
match self {
RepoKind::Git => "git",
RepoKind::Path => "path",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathClass {
Folder,
TextFile,
OtherFile,
}
pub fn classify_path(path: &Path, editor_extensions: &[String]) -> PathClass {
if is_dir_target(path) {
return PathClass::Folder;
}
if is_text_file(path, editor_extensions) {
PathClass::TextFile
} else {
PathClass::OtherFile
}
}
pub fn is_dir_target(path: &Path) -> bool {
if path.is_dir() {
return true;
}
if path.is_file() {
return false;
}
path.to_string_lossy().ends_with(['/', '\\'])
}
pub fn is_text_file(path: &Path, editor_extensions: &[String]) -> bool {
match path.extension() {
None => true,
Some(extension) => {
let extension = extension.to_string_lossy();
editor_extensions
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(&extension))
}
}
}
pub const PATH_NOT_FOUND: &str = "path not found";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GitInfo {
pub valid: bool,
pub error: Option<String>,
pub current_branch_name: Option<String>,
pub changes: Option<u32>,
pub ahead: Option<u32>,
pub behind: Option<u32>,
pub github_repo_name: Option<String>,
pub raw_status: Option<String>,
}
impl GitInfo {
pub fn is_clean(&self) -> bool {
self.raw_status.is_none()
&& self.changes.unwrap_or(0) == 0
&& self.ahead.unwrap_or(0) == 0
&& self.behind.unwrap_or(0) == 0
}
pub fn is_path_missing(&self) -> bool {
!self.valid && self.error.as_deref() == Some(PATH_NOT_FOUND)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
pub name: Option<String>,
pub path: PathBuf,
pub slug: Option<String>,
pub fav: bool,
pub archived: bool,
pub section: Option<String>,
pub include_in_backup: bool,
pub kind: RepoKind,
pub example_git_info: Option<GitInfo>,
pub git_info: Option<GitInfo>,
pub last_used: Option<i64>,
pub open_count: u64,
}
impl Repo {
pub fn new(path: PathBuf) -> Self {
Repo {
name: None,
path,
slug: None,
fav: false,
archived: false,
section: None,
include_in_backup: true,
kind: RepoKind::Git,
example_git_info: None,
git_info: None,
last_used: None,
open_count: 0,
}
}
pub fn display_name(&self) -> String {
if let Some(name) = &self.name
&& !name.trim().is_empty()
{
return name.clone();
}
basename(&self.path)
}
pub fn entry_error(&self) -> Option<String> {
if self.kind != RepoKind::Git {
return None;
}
let info = self.git_info.as_ref()?;
if info.valid {
return None;
}
Some(
info.error
.clone()
.unwrap_or_else(|| "not a git repository".to_string()),
)
}
pub fn example_error(&self) -> Option<String> {
let info = self.example_git_info.as_ref()?;
if info.valid {
return None;
}
Some(
info.error
.clone()
.unwrap_or_else(|| "not a git repository".to_string()),
)
}
}
pub fn basename(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_name_prefers_explicit_name() {
let mut repo = Repo::new(PathBuf::from("/a/b/project"));
repo.name = Some("My Project".to_string());
assert_eq!(repo.display_name(), "My Project");
}
#[test]
fn display_name_falls_back_to_basename() {
let repo = Repo::new(PathBuf::from("/a/b/project"));
assert_eq!(repo.display_name(), "project");
}
#[test]
fn display_name_ignores_trailing_slash() {
let repo = Repo::new(PathBuf::from("/a/b/project/"));
assert_eq!(repo.display_name(), "project");
}
#[test]
fn display_name_ignores_blank_name() {
let mut repo = Repo::new(PathBuf::from("/a/b/project"));
repo.name = Some(" ".to_string());
assert_eq!(repo.display_name(), "project");
}
#[test]
fn entry_error_flags_missing_and_invalid() {
let pending = Repo::new(PathBuf::from("/nope/does-not-exist-xyz"));
assert!(pending.entry_error().is_none());
let mut missing = Repo::new(PathBuf::from("/nope"));
missing.git_info = Some(GitInfo {
valid: false,
error: Some(PATH_NOT_FOUND.to_string()),
..GitInfo::default()
});
assert_eq!(missing.entry_error().as_deref(), Some(PATH_NOT_FOUND));
assert!(missing.git_info.as_ref().unwrap().is_path_missing());
let mut invalid = Repo::new(PathBuf::from("/"));
invalid.git_info = Some(GitInfo {
valid: false,
error: Some("not a git repository".to_string()),
..GitInfo::default()
});
assert_eq!(
invalid.entry_error().as_deref(),
Some("not a git repository")
);
assert!(!invalid.git_info.as_ref().unwrap().is_path_missing());
let mut folder = Repo::new(PathBuf::from("/"));
folder.kind = RepoKind::Path;
assert!(folder.entry_error().is_none());
}
#[test]
fn repo_kind_round_trips_through_config_value() {
for kind in [RepoKind::Git, RepoKind::Path] {
assert_eq!(
RepoKind::from_config_value(kind.as_config_value()),
kind
);
}
assert_eq!(RepoKind::from_config_value("unknown"), RepoKind::Git);
assert_eq!(RepoKind::from_config_value("folder"), RepoKind::Path);
assert_eq!(RepoKind::from_config_value("file"), RepoKind::Path);
}
#[test]
fn classify_path_splits_folder_text_and_other() {
let exts = vec!["rs".to_string(), "md".to_string()];
assert_eq!(classify_path(Path::new("/"), &exts), PathClass::Folder);
assert_eq!(
classify_path(Path::new("/nope/dir/"), &exts),
PathClass::Folder
);
assert_eq!(
classify_path(Path::new("/x/main.rs"), &exts),
PathClass::TextFile
);
assert_eq!(
classify_path(Path::new("/x/Makefile"), &exts),
PathClass::TextFile
);
assert_eq!(
classify_path(Path::new("/x/photo.png"), &exts),
PathClass::OtherFile
);
}
}