use colored::*;
use git2::{Repository, StatusOptions};
use regex::Regex;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::ignores::{should_ignore_dir, should_ignore_file, matches_custom_pattern};
use crate::utils::{format_size, get_dir_size, is_executable};
#[derive(Debug, Clone)]
pub enum GitMode {
Tracked, Untracked, Staged, Changed, History, }
pub struct StructConfig {
pub depth: usize,
pub custom_ignores: Vec<Regex>,
pub max_size_bytes: Option<u64>,
pub git_files: Option<HashSet<PathBuf>>,
pub git_mode: Option<GitMode>,
pub show_size: bool,
pub skip_defaults: bool,
pub skip_specific: Option<String>,
}
pub fn get_git_tracked_files(path: &Path) -> Option<HashSet<PathBuf>> {
if let Ok(repo) = Repository::discover(path) {
let mut tracked = HashSet::new();
if let Ok(workdir) = repo.workdir().ok_or("No workdir") {
if let Ok(index) = repo.index() {
for entry in index.iter() {
if let Some(path_str) = std::str::from_utf8(&entry.path).ok() {
let full_path = workdir.join(path_str);
tracked.insert(full_path);
}
}
}
}
Some(tracked)
} else {
None
}
}
pub fn get_git_untracked_files(path: &Path) -> Option<HashSet<PathBuf>> {
if let Ok(repo) = Repository::discover(path) {
let mut untracked = HashSet::new();
if let Ok(workdir) = repo.workdir().ok_or("No workdir") {
let mut opts = StatusOptions::new();
opts.include_untracked(true);
opts.recurse_untracked_dirs(true);
if let Ok(statuses) = repo.statuses(Some(&mut opts)) {
for entry in statuses.iter() {
let status = entry.status();
if status.is_wt_new() && !status.is_ignored() {
if let Some(path_str) = entry.path() {
let full_path = workdir.join(path_str);
untracked.insert(full_path);
}
}
}
}
}
Some(untracked)
} else {
None
}
}
pub fn get_git_staged_files(path: &Path) -> Option<HashSet<PathBuf>> {
if let Ok(repo) = Repository::discover(path) {
let mut staged = HashSet::new();
if let Ok(workdir) = repo.workdir().ok_or("No workdir") {
let mut opts = StatusOptions::new();
opts.include_untracked(true);
if let Ok(statuses) = repo.statuses(Some(&mut opts)) {
for entry in statuses.iter() {
let status = entry.status();
if status.is_index_new() || status.is_index_modified() || status.is_index_deleted() {
if let Some(path_str) = entry.path() {
let full_path = workdir.join(path_str);
staged.insert(full_path);
}
}
}
}
}
Some(staged)
} else {
None
}
}
pub fn get_git_changed_files(path: &Path) -> Option<HashSet<PathBuf>> {
if let Ok(repo) = Repository::discover(path) {
let mut changed = HashSet::new();
if let Ok(workdir) = repo.workdir().ok_or("No workdir") {
let mut opts = StatusOptions::new();
opts.include_untracked(false);
if let Ok(statuses) = repo.statuses(Some(&mut opts)) {
for entry in statuses.iter() {
let status = entry.status();
if status.is_wt_modified() || status.is_wt_deleted() {
if let Some(path_str) = entry.path() {
let full_path = workdir.join(path_str);
changed.insert(full_path);
}
}
}
}
}
Some(changed)
} else {
None
}
}
pub fn display_tree(
path: &Path,
config: &StructConfig,
current_depth: usize,
prefix: &str,
_is_last: bool,
) {
if current_depth >= config.depth {
return;
}
if current_depth == 0 {
if let Ok(repo) = Repository::discover(path) {
if let Ok(head) = repo.head() {
if let Some(branch) = head.shorthand() {
print!("{}", format!("(git:{}) ", branch).bright_black());
}
}
}
println!("");
}
let mut entries: Vec<_> = match fs::read_dir(path) {
Ok(entries) => entries.filter_map(|e| e.ok()).collect(),
Err(_) => return,
};
entries.sort_by_key(|e| {
let path = e.path();
let is_dir = if path.is_symlink() {
false
} else {
path.is_dir()
};
let name = e.file_name().to_string_lossy().to_lowercase();
(!is_dir, name)
});
let total = entries.len();
for (idx, entry) in entries.iter().enumerate() {
let is_last_entry = idx == total - 1;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let is_symlink = path.is_symlink();
let is_dir = if is_symlink {
false } else {
path.is_dir()
};
if let Some(ref git_files) = config.git_files {
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if is_dir {
let has_tracked_files = git_files.iter().any(|f| f.starts_with(&canonical_path));
if !has_tracked_files {
continue; }
} else {
if !git_files.contains(&canonical_path) {
continue; }
}
} else {
if is_dir {
let should_skip = if config.skip_defaults {
false
} else if let Some(ref specific) = config.skip_specific {
&name != specific && should_ignore_dir(&name)
} else {
should_ignore_dir(&name)
};
if should_skip {
let ignored_count = WalkDir::new(&path)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.count();
let connector = if is_last_entry { "└── " } else { "├── " };
let dir_name = format!("{}/", name).blue().bold();
if config.show_size {
let size = get_dir_size(&path);
let size_str = format_size(size);
let count_msg = format!(" ({}, {} files ignored)", size_str, ignored_count).bright_black();
println!("{}{}{}{}", prefix, connector, dir_name, count_msg);
} else {
let count_msg = format!(" ({} files ignored)", ignored_count).bright_black();
println!("{}{}{}{}", prefix, connector, dir_name, count_msg);
}
continue;
}
}
if config.skip_specific.is_none() && matches_custom_pattern(&name, &config.custom_ignores) {
continue;
}
if !is_dir && should_ignore_file(&name) {
continue;
}
}
if is_dir {
if let Some(max_size) = config.max_size_bytes {
let size = get_dir_size(&path);
if size > max_size {
let connector = if is_last_entry { "└── " } else { "├── " };
let dir_name = format!("{}/", name).blue().bold();
let size_mb = size / (1024 * 1024);
let size_msg = format!(" ({}MB, skipped)", size_mb).bright_black();
println!("{}{}{}{}", prefix, connector, dir_name, size_msg);
continue;
}
}
}
let connector = if is_last_entry { "└── " } else { "├── " };
let display_name = if is_symlink {
if let Ok(target) = fs::read_link(&path) {
format!("{} -> {}", name, target.display()).cyan()
} else {
name.cyan()
}
} else if is_dir {
format!("{}/", name).blue().bold()
} else {
if let Some(ref mode) = config.git_mode {
match mode {
GitMode::Staged => name.green().bold(),
GitMode::Changed => name.yellow().bold(),
GitMode::Untracked => name.red(),
_ => {
if is_executable(&path) {
name.green().bold()
} else {
name.normal()
}
}
}
} else if is_executable(&path) {
name.green().bold()
} else {
name.normal()
}
};
if config.show_size {
if is_dir {
println!("{}{}{}", prefix, connector, display_name);
} else {
if let Ok(metadata) = fs::metadata(&path) {
let size_str = format!(" ({})", format_size(metadata.len())).bright_black();
println!("{}{}{}{}", prefix, connector, display_name, size_str);
} else {
println!("{}{}{}", prefix, connector, display_name);
}
}
} else {
println!("{}{}{}", prefix, connector, display_name);
}
if is_dir {
let new_prefix = if is_last_entry {
format!("{} ", prefix)
} else {
format!("{}│ ", prefix)
};
display_tree(&path, config, current_depth + 1, &new_prefix, is_last_entry);
}
}
}