use crate::error::{FossilError, Result};
use crate::repo::Repository;
use std::collections::HashMap;
#[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 enum FsOperation {
Copy {
src: String,
dst: String,
},
Move {
src: String,
dst: String,
},
Delete {
path: String,
recursive: bool,
},
Chmod {
path: String,
permissions: Permissions,
recursive: bool,
},
Symlink {
link_path: String,
target: String,
},
Write {
path: String,
content: Vec<u8>,
},
MakeExecutable {
path: String,
},
}
#[derive(Debug, Clone)]
pub struct FindResult {
pub files: Vec<FileEntry>,
pub count: usize,
pub dirs_traversed: usize,
}
pub struct FindBuilder<'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> FindBuilder<'a> {
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 = std::collections::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 struct FsBuilder<'a> {
repo: &'a Repository,
base_commit: Option<String>,
operations: Vec<FsOperation>,
commit_message: Option<String>,
author: Option<String>,
branch: Option<String>,
}
impl<'a> FsBuilder<'a> {
pub(crate) fn new(repo: &'a Repository) -> Self {
Self {
repo,
base_commit: None,
operations: Vec::new(),
commit_message: None,
author: None,
branch: None,
}
}
pub fn at_commit(mut self, hash: &str) -> Self {
self.base_commit = Some(hash.to_string());
self
}
pub fn on_trunk(self) -> Self {
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);
self.branch = Some(branch.to_string());
Ok(self)
}
pub fn message(mut self, msg: &str) -> Self {
self.commit_message = Some(msg.to_string());
self
}
pub fn author(mut self, author: &str) -> Self {
self.author = Some(author.to_string());
self
}
pub fn copy_file(mut self, src: &str, dst: &str) -> Self {
self.operations.push(FsOperation::Copy {
src: src.to_string(),
dst: dst.to_string(),
});
self
}
pub fn copy_dir(mut self, src: &str, dst: &str) -> Self {
self.operations.push(FsOperation::Copy {
src: format!("{}/", src.trim_end_matches('/')),
dst: format!("{}/", dst.trim_end_matches('/')),
});
self
}
pub fn move_file(mut self, src: &str, dst: &str) -> Self {
self.operations.push(FsOperation::Move {
src: src.to_string(),
dst: dst.to_string(),
});
self
}
pub fn move_dir(mut self, src: &str, dst: &str) -> Self {
self.operations.push(FsOperation::Move {
src: format!("{}/", src.trim_end_matches('/')),
dst: format!("{}/", dst.trim_end_matches('/')),
});
self
}
pub fn rename(self, old_path: &str, new_path: &str) -> Self {
self.move_file(old_path, new_path)
}
pub fn delete_file(mut self, path: &str) -> Self {
self.operations.push(FsOperation::Delete {
path: path.to_string(),
recursive: false,
});
self
}
pub fn delete_dir(mut self, path: &str) -> Self {
self.operations.push(FsOperation::Delete {
path: format!("{}/", path.trim_end_matches('/')),
recursive: true,
});
self
}
pub fn delete_matching(mut self, pattern: &str) -> Self {
self.operations.push(FsOperation::Delete {
path: format!("glob:{}", pattern),
recursive: false,
});
self
}
pub fn chmod(mut self, path: &str, mode: u32) -> Self {
self.operations.push(FsOperation::Chmod {
path: path.to_string(),
permissions: Permissions::from_octal(mode),
recursive: false,
});
self
}
pub fn chmod_permissions(mut self, path: &str, perms: Permissions) -> Self {
self.operations.push(FsOperation::Chmod {
path: path.to_string(),
permissions: perms,
recursive: false,
});
self
}
pub fn chmod_dir(mut self, path: &str, mode: u32) -> Self {
self.operations.push(FsOperation::Chmod {
path: format!("{}/", path.trim_end_matches('/')),
permissions: Permissions::from_octal(mode),
recursive: true,
});
self
}
pub fn make_executable(mut self, path: &str) -> Self {
self.operations.push(FsOperation::MakeExecutable {
path: path.to_string(),
});
self
}
pub fn symlink(mut self, link_path: &str, target: &str) -> Self {
self.operations.push(FsOperation::Symlink {
link_path: link_path.to_string(),
target: target.to_string(),
});
self
}
pub fn symlink_file(self, link_path: &str, target_file: &str) -> Self {
self.symlink(link_path, target_file)
}
pub fn symlink_dir(self, link_path: &str, target_dir: &str) -> Self {
self.symlink(link_path, target_dir)
}
pub fn write(mut self, path: &str, content: &[u8]) -> Self {
self.operations.push(FsOperation::Write {
path: path.to_string(),
content: content.to_vec(),
});
self
}
pub fn write_str(self, path: &str, content: &str) -> Self {
self.write(path, content.as_bytes())
}
pub fn touch(self, path: &str) -> Self {
self.write(path, &[])
}
pub fn execute(self) -> Result<String> {
let message = self.commit_message.ok_or_else(|| {
FossilError::InvalidArtifact("commit message required for fs operations".to_string())
})?;
let author = self.author.ok_or_else(|| {
FossilError::InvalidArtifact("author required for fs operations".to_string())
})?;
let base_hash = if let Some(hash) = self.base_commit {
hash
} else {
let tip = self.repo.branch_tip_internal("trunk")?;
tip.hash
};
let base_files = self.repo.list_files_internal(&base_hash)?;
let mut file_contents: HashMap<String, Vec<u8>> = HashMap::new();
let mut file_permissions: HashMap<String, String> = HashMap::new();
let mut symlinks: HashMap<String, String> = HashMap::new();
for file in &base_files {
let content = self.repo.read_file_internal(&base_hash, &file.name)?;
file_contents.insert(file.name.clone(), content);
if let Some(ref perms) = file.permissions {
file_permissions.insert(file.name.clone(), perms.clone());
}
}
for op in &self.operations {
match op {
FsOperation::Copy { src, dst } => {
if src.ends_with('/') {
let src_prefix = src.trim_end_matches('/');
let dst_prefix = dst.trim_end_matches('/');
let to_copy: Vec<_> = file_contents
.keys()
.filter(|k| k.starts_with(src_prefix))
.cloned()
.collect();
for path in to_copy {
let new_path = path.replacen(src_prefix, dst_prefix, 1);
if let Some(content) = file_contents.get(&path).cloned() {
file_contents.insert(new_path.clone(), content);
}
if let Some(perms) = file_permissions.get(&path).cloned() {
file_permissions.insert(new_path, perms);
}
}
} else {
if let Some(content) = file_contents.get(src).cloned() {
file_contents.insert(dst.clone(), content);
}
if let Some(perms) = file_permissions.get(src).cloned() {
file_permissions.insert(dst.clone(), perms);
}
}
}
FsOperation::Move { src, dst } => {
if src.ends_with('/') {
let src_prefix = src.trim_end_matches('/');
let dst_prefix = dst.trim_end_matches('/');
let to_move: Vec<_> = file_contents
.keys()
.filter(|k| k.starts_with(src_prefix))
.cloned()
.collect();
for path in to_move {
let new_path = path.replacen(src_prefix, dst_prefix, 1);
if let Some(content) = file_contents.remove(&path) {
file_contents.insert(new_path.clone(), content);
}
if let Some(perms) = file_permissions.remove(&path) {
file_permissions.insert(new_path, perms);
}
}
} else {
if let Some(content) = file_contents.remove(src) {
file_contents.insert(dst.clone(), content);
}
if let Some(perms) = file_permissions.remove(src) {
file_permissions.insert(dst.clone(), perms);
}
}
}
FsOperation::Delete { path, recursive } => {
if path.starts_with("glob:") {
let pattern = &path[5..];
if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
let to_delete: Vec<_> = file_contents
.keys()
.filter(|k| glob_pattern.matches(k))
.cloned()
.collect();
for p in to_delete {
file_contents.remove(&p);
file_permissions.remove(&p);
}
}
} else if *recursive || path.ends_with('/') {
let prefix = path.trim_end_matches('/');
let to_delete: Vec<_> = file_contents
.keys()
.filter(|k| k.starts_with(prefix) || *k == prefix)
.cloned()
.collect();
for p in to_delete {
file_contents.remove(&p);
file_permissions.remove(&p);
}
} else {
file_contents.remove(path);
file_permissions.remove(path);
}
}
FsOperation::Chmod {
path,
permissions,
recursive,
} => {
let perm_str = format!("{:o}", permissions.to_octal());
if *recursive || path.ends_with('/') {
let prefix = path.trim_end_matches('/');
let to_chmod: Vec<_> = file_contents
.keys()
.filter(|k| k.starts_with(prefix))
.cloned()
.collect();
for p in to_chmod {
file_permissions.insert(p, perm_str.clone());
}
} else if file_contents.contains_key(path) {
file_permissions.insert(path.clone(), perm_str);
}
}
FsOperation::MakeExecutable { path } => {
if file_contents.contains_key(path) {
file_permissions.insert(path.clone(), "755".to_string());
}
}
FsOperation::Symlink { link_path, target } => {
let symlink_content = format!("link {}", target);
file_contents.insert(link_path.clone(), symlink_content.into_bytes());
symlinks.insert(link_path.clone(), target.clone());
}
FsOperation::Write { path, content } => {
file_contents.insert(path.clone(), content.clone());
}
}
}
let files: Vec<(&str, &[u8])> = file_contents
.iter()
.map(|(k, v)| (k.as_str(), v.as_slice()))
.collect();
self.repo.commit_internal(
&files,
&message,
&author,
Some(&base_hash),
self.branch.as_deref(),
)
}
pub fn preview(&self) -> Result<FsPreview> {
let base_hash = if let Some(ref hash) = self.base_commit {
hash.clone()
} else {
let tip = self.repo.branch_tip_internal("trunk")?;
tip.hash
};
let base_files = self.repo.list_files_internal(&base_hash)?;
Ok(FsPreview {
base_commit: base_hash,
base_file_count: base_files.len(),
operations: self.operations.clone(),
})
}
}
#[derive(Debug)]
pub struct FsPreview {
pub base_commit: String,
pub base_file_count: usize,
pub operations: Vec<FsOperation>,
}
impl FsPreview {
pub fn describe(&self) -> Vec<String> {
self.operations
.iter()
.map(|op| match op {
FsOperation::Copy { src, dst } => format!("COPY {} -> {}", src, dst),
FsOperation::Move { src, dst } => format!("MOVE {} -> {}", src, dst),
FsOperation::Delete { path, recursive } => {
if *recursive {
format!("DELETE {} (recursive)", path)
} else if path.starts_with("glob:") {
format!("DELETE matching {}", &path[5..])
} else {
format!("DELETE {}", path)
}
}
FsOperation::Chmod {
path,
permissions,
recursive,
} => {
if *recursive {
format!("CHMOD {} {:o} (recursive)", path, permissions.to_octal())
} else {
format!("CHMOD {} {:o}", path, permissions.to_octal())
}
}
FsOperation::Symlink { link_path, target } => {
format!("SYMLINK {} -> {}", link_path, target)
}
FsOperation::Write { path, content } => {
format!("WRITE {} ({} bytes)", path, content.len())
}
FsOperation::MakeExecutable { path } => format!("MAKE_EXECUTABLE {}", path),
})
.collect()
}
}
pub struct FsOpsBuilder<'a> {
repo: &'a Repository,
}
impl<'a> FsOpsBuilder<'a> {
pub(crate) fn new(repo: &'a Repository) -> Self {
Self { repo }
}
pub fn modify(self) -> FsBuilder<'a> {
FsBuilder::new(self.repo)
}
pub fn find(self) -> FindBuilder<'a> {
FindBuilder::new(self.repo)
}
pub fn find_pattern(self, pattern: &str) -> FindBuilder<'a> {
FindBuilder::new(self.repo).pattern(pattern)
}
pub fn list_symlinks(&self) -> Result<Vec<(String, String)>> {
let tip = self.repo.branch_tip_internal("trunk")?;
let files = self.repo.list_files_internal(&tip.hash)?;
let mut symlinks = Vec::new();
for file in files {
if let Ok(content) = self.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)
}
pub fn exists(&self, path: &str) -> Result<bool> {
let tip = self.repo.branch_tip_internal("trunk")?;
let files = self.repo.list_files_internal(&tip.hash)?;
Ok(files.iter().any(|f| f.name == path))
}
pub fn is_dir(&self, path: &str) -> Result<bool> {
let tip = self.repo.branch_tip_internal("trunk")?;
let files = self.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(&self, path: &str) -> Result<Option<FileEntry>> {
let tip = self.repo.branch_tip_internal("trunk")?;
let files = self.repo.list_files_internal(&tip.hash)?;
for file in files {
if file.name == path {
let content = self.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(&self, pattern: &str) -> Result<usize> {
let tip = self.repo.branch_tip_internal("trunk")?;
let files = self.repo.find_files_internal(&tip.hash, pattern)?;
let mut total = 0;
for file in files {
if let Ok(content) = self.repo.read_file_internal(&tip.hash, &file.name) {
total += content.len();
}
}
Ok(total)
}
pub fn count(&self, pattern: &str) -> Result<usize> {
let tip = self.repo.branch_tip_internal("trunk")?;
let files = self.repo.find_files_internal(&tip.hash, pattern)?;
Ok(files.len())
}
}