use crate::error::Result;
use crate::repo::Repository;
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Permissions {
pub owner_read: bool,
pub owner_write: bool,
pub owner_exec: bool,
pub group_read: bool,
pub group_write: bool,
pub group_exec: bool,
pub other_read: bool,
pub other_write: bool,
pub other_exec: bool,
}
impl Permissions {
pub fn from_octal(mode: u32) -> Self {
Self {
owner_read: mode & 0o400 != 0,
owner_write: mode & 0o200 != 0,
owner_exec: mode & 0o100 != 0,
group_read: mode & 0o040 != 0,
group_write: mode & 0o020 != 0,
group_exec: mode & 0o010 != 0,
other_read: mode & 0o004 != 0,
other_write: mode & 0o002 != 0,
other_exec: mode & 0o001 != 0,
}
}
pub fn to_octal(&self) -> u32 {
let mut mode = 0u32;
if self.owner_read {
mode |= 0o400;
}
if self.owner_write {
mode |= 0o200;
}
if self.owner_exec {
mode |= 0o100;
}
if self.group_read {
mode |= 0o040;
}
if self.group_write {
mode |= 0o020;
}
if self.group_exec {
mode |= 0o010;
}
if self.other_read {
mode |= 0o004;
}
if self.other_write {
mode |= 0o002;
}
if self.other_exec {
mode |= 0o001;
}
mode
}
pub fn to_string_repr(&self) -> String {
format!(
"{}{}{}{}{}{}{}{}{}",
if self.owner_read { 'r' } else { '-' },
if self.owner_write { 'w' } else { '-' },
if self.owner_exec { 'x' } else { '-' },
if self.group_read { 'r' } else { '-' },
if self.group_write { 'w' } else { '-' },
if self.group_exec { 'x' } else { '-' },
if self.other_read { 'r' } else { '-' },
if self.other_write { 'w' } else { '-' },
if self.other_exec { 'x' } else { '-' },
)
}
pub fn file() -> Self {
Self::from_octal(0o644)
}
pub fn executable() -> Self {
Self::from_octal(0o755)
}
pub fn readonly() -> Self {
Self::from_octal(0o444)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileType {
Regular,
Executable,
Symlink(String),
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub path: String,
pub hash: String,
pub file_type: FileType,
pub permissions: Option<Permissions>,
pub size: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct FindResult {
pub files: Vec<FileEntry>,
pub count: usize,
pub dirs_traversed: usize,
}
pub struct Find<'a> {
repo: &'a Repository,
base_commit: Option<String>,
patterns: Vec<String>,
ignore_patterns: Vec<String>,
ignore_hidden: bool,
ignore_case: bool,
max_depth: Option<usize>,
file_type_filter: Option<FileType>,
min_size: Option<usize>,
max_size: Option<usize>,
base_dir: Option<String>,
}
impl<'a> Find<'a> {
pub fn new(repo: &'a Repository) -> Self {
Self {
repo,
base_commit: None,
patterns: Vec::new(),
ignore_patterns: Vec::new(),
ignore_hidden: false,
ignore_case: false,
max_depth: None,
file_type_filter: None,
min_size: None,
max_size: None,
base_dir: None,
}
}
pub fn at_commit(mut self, hash: &str) -> Self {
self.base_commit = Some(hash.to_string());
self
}
pub fn on_trunk(mut self) -> Self {
self.base_commit = None;
self
}
pub fn on_branch(mut self, branch: &str) -> Result<Self> {
let tip = self.repo.branch_tip_internal(branch)?;
self.base_commit = Some(tip.hash);
Ok(self)
}
pub fn pattern(mut self, pattern: &str) -> Self {
self.patterns.push(pattern.to_string());
self
}
pub fn patterns(mut self, patterns: &[&str]) -> Self {
for p in patterns {
self.patterns.push(p.to_string());
}
self
}
pub fn ignore(mut self, pattern: &str) -> Self {
self.ignore_patterns.push(pattern.to_string());
self
}
pub fn ignore_patterns(mut self, patterns: &[&str]) -> Self {
for p in patterns {
self.ignore_patterns.push(p.to_string());
}
self
}
pub fn ignore_hidden(mut self) -> Self {
self.ignore_hidden = true;
self
}
pub fn use_gitignore(mut self) -> Self {
self.ignore_patterns.extend(vec![
".git/**".to_string(),
".gitignore".to_string(),
"node_modules/**".to_string(),
"target/**".to_string(),
"*.pyc".to_string(),
"__pycache__/**".to_string(),
".DS_Store".to_string(),
"*.swp".to_string(),
"*.swo".to_string(),
"*~".to_string(),
]);
self
}
pub fn ignore_case(mut self) -> Self {
self.ignore_case = true;
self
}
pub fn max_depth(mut self, depth: usize) -> Self {
self.max_depth = Some(depth);
self
}
pub fn files_only(mut self) -> Self {
self.file_type_filter = Some(FileType::Regular);
self
}
pub fn executables_only(mut self) -> Self {
self.file_type_filter = Some(FileType::Executable);
self
}
pub fn symlinks_only(mut self) -> Self {
self.file_type_filter = Some(FileType::Symlink(String::new()));
self
}
pub fn min_size(mut self, bytes: usize) -> Self {
self.min_size = Some(bytes);
self
}
pub fn max_size(mut self, bytes: usize) -> Self {
self.max_size = Some(bytes);
self
}
pub fn in_dir(mut self, dir: &str) -> Self {
self.base_dir = Some(dir.trim_end_matches('/').to_string());
self
}
pub fn execute(&self) -> Result<FindResult> {
let commit_hash = if let Some(ref hash) = self.base_commit {
hash.clone()
} else {
let tip = self.repo.branch_tip_internal("trunk")?;
tip.hash
};
let all_files = self.repo.list_files_internal(&commit_hash)?;
let mut matched_files = Vec::new();
let mut dirs_seen = HashSet::new();
let match_patterns: Vec<glob::Pattern> = self
.patterns
.iter()
.filter_map(|p| {
let p = if self.ignore_case {
p.to_lowercase()
} else {
p.clone()
};
glob::Pattern::new(&p).ok()
})
.collect();
let ignore_patterns: Vec<glob::Pattern> = self
.ignore_patterns
.iter()
.filter_map(|p| {
let p = if self.ignore_case {
p.to_lowercase()
} else {
p.clone()
};
glob::Pattern::new(&p).ok()
})
.collect();
for file in all_files {
let file_path = if self.ignore_case {
file.name.to_lowercase()
} else {
file.name.clone()
};
if let Some(idx) = file.name.rfind('/') {
dirs_seen.insert(file.name[..idx].to_string());
}
if let Some(ref base) = self.base_dir {
if !file.name.starts_with(base) && !file.name.starts_with(&format!("{}/", base)) {
continue;
}
}
if let Some(max_depth) = self.max_depth {
let depth = file.name.matches('/').count();
let base_depth = self
.base_dir
.as_ref()
.map(|b| b.matches('/').count())
.unwrap_or(0);
if depth - base_depth > max_depth {
continue;
}
}
if self.ignore_hidden {
let file_name = file.name.rsplit('/').next().unwrap_or(&file.name);
if file_name.starts_with('.') {
continue;
}
}
let ignored = ignore_patterns.iter().any(|p| p.matches(&file_path));
if ignored {
continue;
}
let matches = if match_patterns.is_empty() {
true
} else {
match_patterns.iter().any(|p| p.matches(&file_path))
};
if matches {
matched_files.push(FileEntry {
path: file.name.clone(),
hash: file.hash.clone(),
file_type: FileType::Regular,
permissions: file.permissions.as_ref().map(|p| {
Permissions::from_octal(u32::from_str_radix(p, 8).unwrap_or(0o644))
}),
size: file.size,
});
}
}
Ok(FindResult {
count: matched_files.len(),
files: matched_files,
dirs_traversed: dirs_seen.len(),
})
}
pub fn paths(&self) -> Result<Vec<String>> {
Ok(self.execute()?.files.into_iter().map(|f| f.path).collect())
}
pub fn count(&self) -> Result<usize> {
Ok(self.execute()?.count)
}
}
pub fn find(repo: &Repository, pattern: &str) -> Result<Vec<String>> {
Find::new(repo).pattern(pattern).paths()
}
pub fn count(repo: &Repository, pattern: &str) -> Result<usize> {
Find::new(repo).pattern(pattern).count()
}
pub fn exists(repo: &Repository, path: &str) -> Result<bool> {
let tip = repo.branch_tip_internal("trunk")?;
let files = repo.list_files_internal(&tip.hash)?;
Ok(files.iter().any(|f| f.name == path))
}
pub fn is_dir(repo: &Repository, path: &str) -> Result<bool> {
let tip = repo.branch_tip_internal("trunk")?;
let files = repo.list_files_internal(&tip.hash)?;
let prefix = format!("{}/", path.trim_end_matches('/'));
Ok(files.iter().any(|f| f.name.starts_with(&prefix)))
}
pub fn stat(repo: &Repository, path: &str) -> Result<Option<FileEntry>> {
let tip = repo.branch_tip_internal("trunk")?;
let files = repo.list_files_internal(&tip.hash)?;
for file in files {
if file.name == path {
let content = repo.read_file_internal(&tip.hash, path)?;
let file_type = if let Ok(text) = String::from_utf8(content.clone()) {
if text.starts_with("link ") {
FileType::Symlink(text[5..].trim().to_string())
} else {
FileType::Regular
}
} else {
FileType::Regular
};
return Ok(Some(FileEntry {
path: file.name,
hash: file.hash,
file_type,
permissions: file
.permissions
.as_ref()
.map(|p| Permissions::from_octal(u32::from_str_radix(p, 8).unwrap_or(0o644))),
size: Some(content.len()),
}));
}
}
Ok(None)
}
pub fn du(repo: &Repository, pattern: &str) -> Result<usize> {
let tip = repo.branch_tip_internal("trunk")?;
let files = repo.find_files_internal(&tip.hash, pattern)?;
let mut total = 0;
for file in files {
if let Ok(content) = repo.read_file_internal(&tip.hash, &file.name) {
total += content.len();
}
}
Ok(total)
}
pub fn list_symlinks(repo: &Repository) -> Result<Vec<(String, String)>> {
let tip = repo.branch_tip_internal("trunk")?;
let files = repo.list_files_internal(&tip.hash)?;
let mut symlinks = Vec::new();
for file in files {
if let Ok(content) = repo.read_file_internal(&tip.hash, &file.name) {
if let Ok(text) = String::from_utf8(content) {
if text.starts_with("link ") {
let target = text[5..].trim().to_string();
symlinks.push((file.name, target));
}
}
}
}
Ok(symlinks)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_permissions_from_octal() {
let perms = Permissions::from_octal(0o755);
assert!(perms.owner_read);
assert!(perms.owner_write);
assert!(perms.owner_exec);
assert!(perms.group_read);
assert!(!perms.group_write);
assert!(perms.group_exec);
assert!(perms.other_read);
assert!(!perms.other_write);
assert!(perms.other_exec);
}
#[test]
fn test_permissions_to_octal() {
let perms = Permissions::from_octal(0o644);
assert_eq!(perms.to_octal(), 0o644);
}
#[test]
fn test_permissions_string_repr() {
assert_eq!(Permissions::from_octal(0o755).to_string_repr(), "rwxr-xr-x");
assert_eq!(Permissions::from_octal(0o644).to_string_repr(), "rw-r--r--");
assert_eq!(Permissions::from_octal(0o600).to_string_repr(), "rw-------");
}
#[test]
fn test_permissions_presets() {
assert_eq!(Permissions::file().to_octal(), 0o644);
assert_eq!(Permissions::executable().to_octal(), 0o755);
assert_eq!(Permissions::readonly().to_octal(), 0o444);
}
#[test]
fn test_permissions_roundtrip() {
for mode in [0o000, 0o111, 0o222, 0o333, 0o444, 0o555, 0o666, 0o777] {
let perms = Permissions::from_octal(mode);
assert_eq!(perms.to_octal(), mode);
}
}
#[test]
fn test_permissions_all_bits() {
let perms = Permissions::from_octal(0o777);
assert!(perms.owner_read);
assert!(perms.owner_write);
assert!(perms.owner_exec);
assert!(perms.group_read);
assert!(perms.group_write);
assert!(perms.group_exec);
assert!(perms.other_read);
assert!(perms.other_write);
assert!(perms.other_exec);
assert_eq!(perms.to_string_repr(), "rwxrwxrwx");
}
#[test]
fn test_permissions_no_bits() {
let perms = Permissions::from_octal(0o000);
assert!(!perms.owner_read);
assert!(!perms.owner_write);
assert!(!perms.owner_exec);
assert!(!perms.group_read);
assert!(!perms.group_write);
assert!(!perms.group_exec);
assert!(!perms.other_read);
assert!(!perms.other_write);
assert!(!perms.other_exec);
assert_eq!(perms.to_string_repr(), "---------");
}
#[test]
fn test_file_type_equality() {
assert_eq!(FileType::Regular, FileType::Regular);
assert_eq!(FileType::Executable, FileType::Executable);
assert_eq!(
FileType::Symlink("target".to_string()),
FileType::Symlink("target".to_string())
);
assert_ne!(FileType::Regular, FileType::Executable);
assert_ne!(
FileType::Symlink("a".to_string()),
FileType::Symlink("b".to_string())
);
}
#[test]
fn test_find_result_empty() {
let result = FindResult {
files: vec![],
count: 0,
dirs_traversed: 0,
};
assert_eq!(result.count, 0);
assert!(result.files.is_empty());
}
#[test]
fn test_find_result_with_files() {
let result = FindResult {
files: vec![
FileEntry {
path: "src/main.rs".to_string(),
hash: "abc123".to_string(),
file_type: FileType::Regular,
permissions: Some(Permissions::file()),
size: Some(100),
},
FileEntry {
path: "src/lib.rs".to_string(),
hash: "def456".to_string(),
file_type: FileType::Regular,
permissions: Some(Permissions::file()),
size: Some(200),
},
],
count: 2,
dirs_traversed: 1,
};
assert_eq!(result.count, 2);
assert_eq!(result.files.len(), 2);
assert_eq!(result.dirs_traversed, 1);
}
#[test]
fn test_file_entry_with_symlink() {
let entry = FileEntry {
path: "link".to_string(),
hash: "hash".to_string(),
file_type: FileType::Symlink("target/path".to_string()),
permissions: None,
size: None,
};
assert_eq!(entry.path, "link");
if let FileType::Symlink(target) = entry.file_type {
assert_eq!(target, "target/path");
} else {
panic!("Expected symlink");
}
}
}