Files
galaxy/crates/repo_metadata/src/local_model_test.rs
T

1260 lines
54 KiB
Rust

//! Tests for the LocalRepoMetadataModel.
#[cfg(test)]
#[allow(clippy::module_inception)]
mod tests {
use crate::entry::{DirectoryEntry, Entry, FileMetadata};
use crate::file_tree_store::{FileTreeEntry, FileTreeEntryState, FileTreeState};
use crate::local_model::{
GetContentsArgs, IndexedRepoState, LocalRepoMetadataModel, RepoUpdate,
RepositoryMetadataEvent,
};
use crate::repositories::DetectedRepositories;
use crate::watcher::DirectoryWatcher;
use futures::channel::oneshot;
use futures::executor::block_on;
use galaxy_util::standardized_path::StandardizedPath;
use galaxyui::r#async::FutureExt as _;
use galaxyui::App;
use ignore::gitignore::Gitignore;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::Duration;
use virtual_fs::{Stub, VirtualFS};
impl LocalRepoMetadataModel {
fn new_for_test() -> Self {
Self {
repositories: HashMap::new(),
lazy_loaded_paths: Default::default(),
#[cfg(feature = "local_fs")]
watcher: Default::default(),
emit_incremental_updates: false,
}
}
}
#[test]
fn test_get_repo_contents() {
VirtualFS::test("repo_contents_test", |dirs, mut vfs| {
let test_repo = dirs.tests().join("test_repo");
// Create a test repository structure using VirtualFS with .git directory
vfs.mkdir("test_repo/.git/objects")
.mkdir("test_repo/subdir")
.with_files(vec![
Stub::FileWithContent("test_repo/.git/HEAD", "ref: refs/heads/main"),
Stub::FileWithContent(
"test_repo/.git/config",
"[core]\n\trepositoryformatversion = 0",
),
Stub::FileWithContent("test_repo/file1.txt", "content1"),
Stub::FileWithContent("test_repo/subdir/file2.rs", "content2"),
Stub::FileWithContent("test_repo/subdir/file3.py", "content3"),
Stub::FileWithContent("test_repo/file4.md", "content4"),
Stub::FileWithContent("test_repo/.gitignore", ""),
]);
// Create a mock file tree structure
let file1 = Entry::File(FileMetadata::new(test_repo.join("file1.txt"), false));
let file2 = Entry::File(FileMetadata::new(test_repo.join("subdir/file2.rs"), false));
let file3 = Entry::File(FileMetadata::new(test_repo.join("subdir/file3.py"), false));
let file4 = Entry::File(FileMetadata::new(test_repo.join("file4.md"), false));
let subdir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&test_repo.join("subdir")).unwrap(),
children: vec![file2, file3],
ignored: false,
loaded: true,
});
let root = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&test_repo).unwrap(),
children: vec![file1, subdir, file4],
ignored: false,
loaded: true,
});
let (gitignore, _) = Gitignore::new(test_repo.join(".gitignore"));
App::test((), |mut app| async move {
// Create RepoWatcher and get Repository handle through it
let repo_watcher = app.add_singleton_model(DirectoryWatcher::new);
let repo_handle = repo_watcher.update(&mut app, |repo_watcher, ctx| {
repo_watcher
.add_directory(
StandardizedPath::from_local_canonicalized(&test_repo).unwrap(),
ctx,
)
.unwrap()
});
let state = FileTreeState::new(root, vec![gitignore], Some(repo_handle));
let model_handle = app.add_model(|_| LocalRepoMetadataModel::new_for_test());
model_handle.update(&mut app, |model, _ctx| {
// Use the CanonicalizedPath as the key
let canonical_key =
StandardizedPath::from_local_canonicalized(&test_repo).unwrap();
model
.repositories
.insert(canonical_key, IndexedRepoState::Indexed(state));
});
// Test getting all files
model_handle.read(&app, |model, _ctx| {
let args = GetContentsArgs {
include_folders: false,
include_ignored: false,
filter: None,
};
let files = model
.get_repo_contents(
&StandardizedPath::from_local_canonicalized(&test_repo).unwrap(),
args,
)
.unwrap();
// Should have 4 files total (file1.txt, file2.rs, file3.py, file4.md)
assert_eq!(files.len(), 4);
// Test with non-existent repository
let non_existent = StandardizedPath::try_new("/non_existent_repo").unwrap();
let args = GetContentsArgs {
include_folders: false,
include_ignored: false,
filter: None,
};
let non_existent_result = model.get_repo_contents(&non_existent, args);
assert!(non_existent_result.is_none());
});
});
});
}
#[cfg(feature = "local_fs")]
#[test]
fn test_lazy_loaded_path_registrations_are_refcounted() {
VirtualFS::test("lazy_loaded_path_refcount", |dirs, mut vfs| {
vfs.mkdir("shared_dir")
.with_files(vec![Stub::FileWithContent(
"shared_dir/file.txt",
"content",
)]);
let shared_dir = dirs.tests().join("shared_dir");
App::test((), |mut app| async move {
let model_handle = app.add_model(|_| LocalRepoMetadataModel::new_for_test());
let shared_dir_for_index =
StandardizedPath::from_local_canonicalized(&shared_dir).unwrap();
model_handle.update(&mut app, |model, ctx| {
model
.index_lazy_loaded_path(&shared_dir_for_index, ctx)
.unwrap();
model
.index_lazy_loaded_path(&shared_dir_for_index, ctx)
.unwrap();
});
model_handle.read(&app, |model, _ctx| {
assert!(model.is_lazy_loaded_path(
&StandardizedPath::from_local_canonicalized(&shared_dir).unwrap()
));
assert!(model.has_repository(
&StandardizedPath::from_local_canonicalized(&shared_dir).unwrap()
));
});
let shared_dir_std =
StandardizedPath::from_local_canonicalized(&shared_dir).unwrap();
model_handle.update(&mut app, |model, ctx| {
model.remove_lazy_loaded_path(&shared_dir_std, ctx);
});
model_handle.read(&app, |model, _ctx| {
assert!(model.is_lazy_loaded_path(&shared_dir_std));
assert!(model.has_repository(&shared_dir_std));
});
model_handle.update(&mut app, |model, ctx| {
model.remove_lazy_loaded_path(&shared_dir_std, ctx);
});
model_handle.read(&app, |model, _ctx| {
assert!(!model.is_lazy_loaded_path(
&StandardizedPath::from_local_canonicalized(&shared_dir).unwrap()
));
assert!(!model.has_repository(
&StandardizedPath::from_local_canonicalized(&shared_dir).unwrap()
));
});
});
});
}
#[cfg(feature = "local_fs")]
#[test]
fn test_index_directory_upgrades_lazy_loaded_path_to_repo() {
VirtualFS::test("lazy_loaded_path_upgrade", |dirs, mut vfs| {
vfs.mkdir("repo/.git/objects")
.mkdir("repo/src/nested")
.with_files(vec![
Stub::FileWithContent("repo/.git/HEAD", "ref: refs/heads/main"),
Stub::FileWithContent(
"repo/.git/config",
"[core]\n\trepositoryformatversion = 0",
),
Stub::FileWithContent("repo/src/nested/main.rs", "fn main() {}\n"),
]);
let repo_root = dirs.tests().join("repo");
let src_dir = repo_root.join("src");
let source_file = repo_root.join("src/nested/main.rs");
App::test((), |mut app| async move {
let directory_watcher = app.add_singleton_model(DirectoryWatcher::new);
let repository_handle = directory_watcher.update(&mut app, |watcher, ctx| {
watcher
.add_directory(
StandardizedPath::from_local_canonicalized(&repo_root).unwrap(),
ctx,
)
.unwrap()
});
let model_handle = app.add_model(|_| LocalRepoMetadataModel::new_for_test());
let repo_root_for_index =
StandardizedPath::from_local_canonicalized(&repo_root).unwrap();
model_handle.update(&mut app, |model, ctx| {
model
.index_lazy_loaded_path(&repo_root_for_index, ctx)
.unwrap();
});
model_handle.read(&app, |model, _ctx| {
assert!(model.is_lazy_loaded_path(
&StandardizedPath::from_local_canonicalized(&repo_root).unwrap()
));
let Some(IndexedRepoState::Indexed(state)) = model.repository_state(
&StandardizedPath::from_local_canonicalized(&repo_root).unwrap(),
) else {
panic!("expected indexed lazy-loaded path");
};
assert!(state
.entry
.contains(&StandardizedPath::try_from_local(&src_dir).unwrap()));
assert!(!state
.entry
.contains(&StandardizedPath::try_from_local(&source_file).unwrap()));
});
let (tx, rx) = oneshot::channel();
let repo_root_for_event = repo_root.clone();
let upgrade_completed = Rc::new(RefCell::new(Some(tx)));
let upgrade_completed_for_event = upgrade_completed.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&model_handle, move |_, event, _ctx| {
if matches!(
event,
RepositoryMetadataEvent::RepositoryUpdated { path }
if path.to_local_path().as_ref() == Some(&repo_root_for_event)
) {
if let Some(tx) = upgrade_completed_for_event.borrow_mut().take() {
let _ = tx.send(());
}
}
});
});
model_handle.update(&mut app, |model, ctx| {
model.index_directory(repository_handle, ctx).unwrap();
});
rx.with_timeout(Duration::from_secs(5))
.await
.expect("timed out waiting for repo upgrade")
.expect("repo upgrade completion sender dropped");
model_handle.read(&app, |model, _ctx| {
assert!(!model.is_lazy_loaded_path(
&StandardizedPath::from_local_canonicalized(&repo_root).unwrap()
));
let Some(IndexedRepoState::Indexed(state)) = model.repository_state(
&StandardizedPath::from_local_canonicalized(&repo_root).unwrap(),
) else {
panic!("expected indexed repo after upgrade");
};
assert!(state
.entry
.contains(&StandardizedPath::try_from_local(&source_file).unwrap()));
});
});
});
}
#[test]
fn test_get_repo_contents_include_ignored() {
VirtualFS::test("repo_contents_include_ignored_test", |dirs, mut vfs| {
let test_repo = dirs.tests().join("test_repo");
// Create a test repository structure with both ignored and non-ignored files
vfs.mkdir("test_repo/.git/objects")
.mkdir("test_repo/src")
.mkdir("test_repo/target/debug")
.mkdir("test_repo/node_modules")
.with_files(vec![
Stub::FileWithContent("test_repo/.git/HEAD", "ref: refs/heads/main"),
Stub::FileWithContent(
"test_repo/.git/config",
"[core]\n\trepositoryformatversion = 0",
),
Stub::FileWithContent("test_repo/src/main.rs", "fn main() {}"),
Stub::FileWithContent("test_repo/README.md", "# Project"),
Stub::FileWithContent("test_repo/target/debug/binary", "binary"),
Stub::FileWithContent("test_repo/node_modules/package.json", "{}"),
Stub::FileWithContent("test_repo/debug.log", "log"),
Stub::FileWithContent("test_repo/.gitignore", "*.log\n/target/\nnode_modules/"),
]);
// Create mock file tree with ignored and non-ignored entries
let main_rs = Entry::File(FileMetadata::new(test_repo.join("src/main.rs"), false));
let readme = Entry::File(FileMetadata::new(test_repo.join("README.md"), false));
let debug_log = Entry::File(FileMetadata::new(test_repo.join("debug.log"), true));
let binary = Entry::File(FileMetadata::new(
test_repo.join("target/debug/binary"),
true,
));
let package_json = Entry::File(FileMetadata::new(
test_repo.join("node_modules/package.json"),
true,
));
let src_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&test_repo.join("src")).unwrap(),
children: vec![main_rs],
ignored: false,
loaded: true,
});
let debug_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&test_repo.join("target/debug")).unwrap(),
children: vec![binary],
ignored: true,
loaded: true,
});
let target_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&test_repo.join("target")).unwrap(),
children: vec![debug_dir],
ignored: true,
loaded: true,
});
let node_modules_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&test_repo.join("node_modules")).unwrap(),
children: vec![package_json],
ignored: true,
loaded: true,
});
let root = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&test_repo).unwrap(),
children: vec![src_dir, readme, debug_log, target_dir, node_modules_dir],
ignored: false,
loaded: true,
});
let (gitignore, _) = Gitignore::new(test_repo.join(".gitignore"));
App::test((), |mut app| async move {
let repo_watcher = app.add_singleton_model(DirectoryWatcher::new);
let repo_handle = repo_watcher.update(&mut app, |repo_watcher, ctx| {
repo_watcher
.add_directory(
StandardizedPath::from_local_canonicalized(&test_repo).unwrap(),
ctx,
)
.unwrap()
});
let state = FileTreeState::new(root, vec![gitignore], Some(repo_handle));
let model_handle = app.add_model(|_| LocalRepoMetadataModel::new_for_test());
model_handle.update(&mut app, |model, _ctx| {
let canonical_key =
StandardizedPath::from_local_canonicalized(&test_repo).unwrap();
model
.repositories
.insert(canonical_key, IndexedRepoState::Indexed(state));
});
// Test with include_ignored = false (should exclude ignored files and directories)
model_handle.read(&app, |model, _ctx| {
let args = GetContentsArgs {
include_folders: true,
include_ignored: false,
filter: None,
};
let contents = model
.get_repo_contents(
&StandardizedPath::from_local_canonicalized(&test_repo).unwrap(),
args,
)
.unwrap();
let paths: Vec<PathBuf> = contents
.iter()
.map(|c| match c {
crate::RepoContent::File(f) => f.path.to_local_path_lossy(),
crate::RepoContent::Directory(d) => d.path.to_local_path_lossy(),
})
.collect();
// Should include non-ignored files and directories
assert!(paths.contains(&test_repo.join("src")));
assert!(paths.contains(&test_repo.join("src/main.rs")));
assert!(paths.contains(&test_repo.join("README.md")));
// Should NOT include ignored directories or files
assert!(!paths.contains(&test_repo.join("target")));
assert!(!paths.contains(&test_repo.join("node_modules")));
assert!(!paths.contains(&test_repo.join("debug.log")));
});
// Test with include_ignored = true (should include everything)
model_handle.read(&app, |model, _ctx| {
let args = GetContentsArgs {
include_folders: true,
include_ignored: true,
filter: None,
};
let contents = model
.get_repo_contents(
&StandardizedPath::from_local_canonicalized(&test_repo).unwrap(),
args,
)
.unwrap();
let paths: Vec<PathBuf> = contents
.iter()
.map(|c| match c {
crate::RepoContent::File(f) => f.path.to_local_path_lossy(),
crate::RepoContent::Directory(d) => d.path.to_local_path_lossy(),
})
.collect();
// Should include everything
assert!(paths.contains(&test_repo.join("src")));
assert!(paths.contains(&test_repo.join("target")));
assert!(paths.contains(&test_repo.join("target/debug")));
assert!(paths.contains(&test_repo.join("node_modules")));
assert!(paths.contains(&test_repo.join("src/main.rs")));
assert!(paths.contains(&test_repo.join("README.md")));
assert!(paths.contains(&test_repo.join("debug.log")));
assert!(paths.contains(&test_repo.join("target/debug/binary")));
assert!(paths.contains(&test_repo.join("node_modules/package.json")));
});
});
});
}
#[test]
fn test_should_include_path_respects_gitignore() {
VirtualFS::test("gitignore_test", |dirs, mut fs| {
let repo_path = dirs.tests();
// Create directory structure and files using VirtualFS
fs.mkdir("src")
.mkdir("target/debug")
.mkdir("node_modules/package")
.mkdir("docs")
.with_files(vec![
Stub::FileWithContent("debug.log", "log"),
Stub::FileWithContent("target/debug/main", "binary"),
Stub::FileWithContent("node_modules/package/index.js", "js"),
Stub::FileWithContent(".env", "env"),
Stub::FileWithContent("src/main.rs", "rust"),
Stub::FileWithContent("README.md", "readme"),
Stub::FileWithContent("package.json", "json"),
Stub::FileWithContent("docs/guide.md", "guide"),
Stub::FileWithContent(".gitignore", "*.log\n/target/\nnode_modules/\n.env"),
]);
let gitignore_path = repo_path.join(".gitignore");
// Create the gitignore object
let (gitignore, _) = Gitignore::new(&gitignore_path);
let gitignores = vec![gitignore];
// Test files that should be excluded
let excluded_paths = vec![
repo_path.join("debug.log"),
repo_path.join("target").join("debug").join("main"),
repo_path
.join("node_modules")
.join("package")
.join("index.js"),
repo_path.join(".env"),
];
for path in excluded_paths {
assert!(
LocalRepoMetadataModel::path_is_ignored(&path, &gitignores),
"Path should be excluded by gitignore: {path:?}"
);
}
// Test files that should be included
let included_paths = vec![
repo_path.join("src").join("main.rs"),
repo_path.join("README.md"),
repo_path.join("package.json"),
repo_path.join("docs").join("guide.md"),
];
for path in included_paths {
assert!(
!LocalRepoMetadataModel::path_is_ignored(&path, &gitignores),
"Path should be included: {path:?}"
);
}
});
}
#[test]
fn test_update_file_tree_entry_respects_gitignore() {
VirtualFS::test("tree_update_test", |dirs, mut fs| {
let repo_path = dirs.tests();
// Create initial directory structure and files
fs.mkdir("src")
.with_files(vec![
Stub::FileWithContent("src/main.rs", "fn main() {}"),
Stub::FileWithContent(".gitignore", "*.log\n/target/"),
Stub::FileWithContent("debug.log", "log content"),
Stub::FileWithContent("README.md", "# Project"),
])
.mkdir("target");
let gitignore_path = repo_path.join(".gitignore");
let (gitignore, _) = Gitignore::new(&gitignore_path);
let gitignores = vec![gitignore];
// Create an initial file tree
let root_entry = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(repo_path).unwrap(),
children: vec![Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&repo_path.join("src")).unwrap(),
children: vec![Entry::File(FileMetadata::new(
repo_path.join("src").join("main.rs"),
false,
))],
ignored: false,
loaded: true,
})],
ignored: false,
loaded: true,
});
let mut root = FileTreeEntry::from(root_entry);
// Create files to test adding - some should be ignored
let log_file = repo_path.join("debug.log");
let target_dir = repo_path.join("target");
let readme_file = repo_path.join("README.md");
// Create update with both ignored and allowed files
let update = RepoUpdate {
added: vec![log_file.clone(), readme_file.clone(), target_dir.clone()],
deleted: vec![],
moved: HashMap::new(),
};
// Compute mutations on the "background thread" then apply on the "main thread".
let mutations = block_on(LocalRepoMetadataModel::compute_file_tree_mutations(
&update,
&gitignores,
));
LocalRepoMetadataModel::apply_file_tree_mutations(&mut root, mutations, false, false);
// Verify that only the README.md was added (log file and target dir should be ignored)
let mut all_paths = Vec::new();
collect_all_paths(&root, &mut all_paths);
// Should contain all files
let readme_std = StandardizedPath::try_from_local(&readme_file).unwrap();
let log_std = StandardizedPath::try_from_local(&log_file).unwrap();
let target_std = StandardizedPath::try_from_local(&target_dir).unwrap();
assert!(all_paths.contains(&readme_std));
assert!(all_paths.contains(&log_std));
assert!(all_paths.contains(&target_std));
// Make sure that the ignored files and folders are marked as ignored.
assert!(root
.get(&StandardizedPath::try_from_local(&log_file).unwrap())
.unwrap()
.ignored());
assert!(root
.get(&StandardizedPath::try_from_local(&target_dir).unwrap())
.unwrap()
.ignored());
// Make sure that the ignored folder is not eagerly loaded.
assert!(!root
.get(&StandardizedPath::try_from_local(&target_dir).unwrap())
.unwrap()
.loaded());
});
}
#[test]
fn test_gitignore_patterns_comprehensive() {
VirtualFS::test("comprehensive_test", |dirs, mut fs| {
let repo_path = dirs.tests();
// Create directory structure and files using VirtualFS
fs.mkdir("target/debug")
.mkdir("dist")
.mkdir("build")
.mkdir("logs")
.mkdir("node_modules/react")
.mkdir("vendor")
.mkdir(".vscode")
.mkdir(".idea")
.mkdir("src")
.mkdir("docs")
.mkdir("tests")
.mkdir(".github/workflows");
// Create a comprehensive .gitignore
let gitignore_content = r#"
# Build outputs
/target/
/dist/
build/
# Logs
*.log
logs/
# Dependencies
node_modules/
/vendor/
# IDE files
.vscode/
.idea/
*.swp
# Environment
.env
.env.local
# OS files
.DS_Store
Thumbs.db
"#;
// Create all files
fs.with_files(vec![
Stub::FileWithContent("target/debug/main", "binary"),
Stub::FileWithContent("dist/bundle.js", "js"),
Stub::FileWithContent("logs/app.log", "log"),
Stub::FileWithContent("debug.log", "log"),
Stub::FileWithContent("node_modules/react/index.js", "js"),
Stub::FileWithContent(".vscode/settings.json", "json"),
Stub::FileWithContent(".env", "env"),
Stub::FileWithContent(".DS_Store", "store"),
Stub::FileWithContent("temp.swp", "swap"),
Stub::FileWithContent("src/main.rs", "rust"),
Stub::FileWithContent("README.md", "readme"),
Stub::FileWithContent("package.json", "json"),
Stub::FileWithContent("docs/guide.md", "guide"),
Stub::FileWithContent("tests/integration.rs", "test"),
Stub::FileWithContent(".github/workflows/ci.yml", "yml"),
Stub::FileWithContent(".gitignore", gitignore_content),
]);
let gitignore_path = repo_path.join(".gitignore");
let (gitignore, _) = Gitignore::new(&gitignore_path);
let gitignores = vec![gitignore];
// Test various patterns
let test_cases = vec![
// Should be ignored
(repo_path.join("target").join("debug").join("main"), false),
(repo_path.join("dist").join("bundle.js"), false),
(repo_path.join("logs").join("app.log"), false),
(repo_path.join("debug.log"), false),
(
repo_path
.join("node_modules")
.join("react")
.join("index.js"),
false,
),
(repo_path.join(".vscode").join("settings.json"), false),
(repo_path.join(".env"), false),
(repo_path.join(".DS_Store"), false),
(repo_path.join("temp.swp"), false),
// Should be included
(repo_path.join("src").join("main.rs"), true),
(repo_path.join("README.md"), true),
(repo_path.join("package.json"), true),
(repo_path.join("docs").join("guide.md"), true),
(repo_path.join("tests").join("integration.rs"), true),
(
repo_path.join(".github").join("workflows").join("ci.yml"),
true,
),
];
for (path, should_include) in test_cases {
let actual = !LocalRepoMetadataModel::path_is_ignored(&path, &gitignores);
assert_eq!(
actual, should_include,
"Path {path:?} - expected: {should_include}, actual: {actual}"
);
}
});
}
#[test]
fn test_git_directory_exclusion() {
VirtualFS::test("git_exclusion_test", |dirs, mut fs| {
let repo_path = dirs.tests();
// Create .git directory and files using VirtualFS
fs.mkdir(".git/objects").mkdir("src").with_files(vec![
Stub::FileWithContent(".git/config", "config"),
Stub::FileWithContent(".git/objects/abc123", "object"),
Stub::FileWithContent("src/main.rs", "rust"),
]);
let gitignores = vec![]; // Empty gitignore rules
// .git directory and its contents should be excluded
assert!(LocalRepoMetadataModel::path_is_ignored(
&repo_path.join(".git"),
&gitignores
));
assert!(LocalRepoMetadataModel::path_is_ignored(
&repo_path.join(".git").join("config"),
&gitignores
));
assert!(LocalRepoMetadataModel::path_is_ignored(
&repo_path.join(".git").join("objects").join("abc123"),
&gitignores
));
// Regular files should be included
assert!(!LocalRepoMetadataModel::path_is_ignored(
&repo_path.join("src").join("main.rs"),
&gitignores
));
});
}
#[test]
fn test_nested_gitignore_rules() {
VirtualFS::test("nested_gitignore_test", |dirs, mut fs| {
let repo_path = dirs.tests();
// Create nested directory structure and files using VirtualFS
fs.mkdir("frontend/dist")
.mkdir("backend/target")
.mkdir("frontend/src")
.with_files(vec![
Stub::FileWithContent("frontend/dist/bundle.js", "js"),
Stub::FileWithContent("backend/target/binary", "bin"),
Stub::FileWithContent("frontend/src/main.ts", "ts"),
Stub::FileWithContent(".gitignore", "*/dist/\n*/target/"),
Stub::FileWithContent("frontend/.gitignore", "!dist/important.js"),
]);
// Create gitignore objects
let root_gitignore_path = repo_path.join(".gitignore");
let frontend_gitignore_path = repo_path.join("frontend").join(".gitignore");
let (root_gitignore, _) = Gitignore::new(&root_gitignore_path);
let (frontend_gitignore, _) = Gitignore::new(&frontend_gitignore_path);
let gitignores = vec![root_gitignore, frontend_gitignore];
// Test that nested gitignore rules are respected
assert!(LocalRepoMetadataModel::path_is_ignored(
&repo_path.join("frontend").join("dist").join("bundle.js"),
&gitignores
));
assert!(LocalRepoMetadataModel::path_is_ignored(
&repo_path.join("backend").join("target").join("binary"),
&gitignores
));
assert!(!LocalRepoMetadataModel::path_is_ignored(
&repo_path.join("frontend").join("src").join("main.ts"),
&gitignores
));
});
}
#[test]
fn test_ensure_parent_directories_exist() {
use crate::local_model::LocalRepoMetadataModel;
// Test case 1: Normal operation - creating nested parent directories
let root_entry = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_new("/test_repo").unwrap(),
children: vec![Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_new("/test_repo/src").unwrap(),
children: vec![],
ignored: false,
loaded: true,
})],
ignored: false,
loaded: true,
});
let mut root = FileTreeEntry::from(root_entry);
// Try to ensure parent directories exist for a deeply nested path
LocalRepoMetadataModel::ensure_parent_directories_exist(
&mut root,
&StandardizedPath::try_new("/test_repo/src/components/ui/forms").unwrap(),
);
// Verify that all intermediate directories were created
let mut all_paths = Vec::new();
collect_all_paths(&root, &mut all_paths);
assert!(all_paths.contains(&StandardizedPath::try_new("/test_repo").unwrap()));
assert!(all_paths.contains(&StandardizedPath::try_new("/test_repo/src").unwrap()));
assert!(
all_paths.contains(&StandardizedPath::try_new("/test_repo/src/components").unwrap())
);
assert!(
all_paths.contains(&StandardizedPath::try_new("/test_repo/src/components/ui").unwrap())
);
assert!(all_paths
.contains(&StandardizedPath::try_new("/test_repo/src/components/ui/forms").unwrap()));
// Test case 2: Existing directories should not be recreated
let initial_count = all_paths.len();
LocalRepoMetadataModel::ensure_parent_directories_exist(
&mut root,
&StandardizedPath::try_new("/test_repo/src/components/ui/forms").unwrap(),
);
let mut updated_paths = Vec::new();
collect_all_paths(&root, &mut updated_paths);
assert_eq!(
initial_count,
updated_paths.len(),
"No new directories should be created when they already exist"
);
// Test case 3: Edge case - file exists where directory is expected
// This tests the edge case documented in the function's comment
let root_with_file_conflict_entry = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_new("/test_repo").unwrap(),
children: vec![
// Create a file at the path where we'll try to create a directory
Entry::File(FileMetadata::from_standardized(
StandardizedPath::try_new("/test_repo/conflicting_path").unwrap(),
false,
)),
],
ignored: false,
loaded: true,
});
let mut root_with_file_conflict = FileTreeEntry::from(root_with_file_conflict_entry);
// Try to create parent directories where a file already exists
LocalRepoMetadataModel::ensure_parent_directories_exist(
&mut root_with_file_conflict,
&StandardizedPath::try_new("/test_repo/conflicting_path/nested/deep").unwrap(),
);
// Verify that the function returned early and didn't corrupt the tree
let mut conflict_paths = Vec::new();
collect_all_paths(&root_with_file_conflict, &mut conflict_paths);
// The function should detect the file conflict and return early without creating
// any nested directories beyond the conflicting file.
// Should still have the original file
assert!(conflict_paths
.contains(&StandardizedPath::try_new("/test_repo/conflicting_path").unwrap()));
// Should NOT have created nested directories beyond the conflict
assert!(!conflict_paths
.contains(&StandardizedPath::try_new("/test_repo/conflicting_path/nested").unwrap()));
assert!(!conflict_paths.contains(
&StandardizedPath::try_new("/test_repo/conflicting_path/nested/deep").unwrap()
));
// Verify the conflicting entry is still a file, not a directory
let conflicting_entry = root_with_file_conflict
.get(&StandardizedPath::try_new("/test_repo/conflicting_path").unwrap())
.expect("Conflicting entry should exist");
assert!(
matches!(conflicting_entry, FileTreeEntryState::File(_)),
"Conflicting entry should remain a file"
);
{
// Test case 3b: File conflict at intermediate level
let root_with_intermediate_conflict_entry = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_new("/test_repo").unwrap(),
children: vec![Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_new("/test_repo/src").unwrap(),
children: vec![
// Create a file where we expect a directory
Entry::File(FileMetadata::from_standardized(
StandardizedPath::try_new("/test_repo/src/components").unwrap(),
false,
)),
],
ignored: false,
loaded: true,
})],
ignored: false,
loaded: true,
});
let mut root_with_intermediate_conflict =
FileTreeEntry::from(root_with_intermediate_conflict_entry);
// Try to create nested directories where an intermediate path has a file conflict
LocalRepoMetadataModel::ensure_parent_directories_exist(
&mut root_with_intermediate_conflict,
&StandardizedPath::try_new("/test_repo/src/components/ui/forms").unwrap(),
);
// Verify that the function handled the conflict properly
let mut intermediate_conflict_paths = Vec::new();
collect_all_paths(
&root_with_intermediate_conflict,
&mut intermediate_conflict_paths,
);
// Should still have the original file at components level
assert!(intermediate_conflict_paths
.contains(&StandardizedPath::try_new("/test_repo/src/components").unwrap()));
// Should NOT have created deeper nested directories beyond the conflict
assert!(!intermediate_conflict_paths
.contains(&StandardizedPath::try_new("/test_repo/src/components/ui").unwrap()));
assert!(!intermediate_conflict_paths.contains(
&StandardizedPath::try_new("/test_repo/src/components/ui/forms").unwrap()
));
// Verify the conflicting entry is still a file, not a directory
let conflicting_entry = root_with_intermediate_conflict
.get(&StandardizedPath::try_new("/test_repo/src/components").unwrap())
.expect("Conflicting entry should exist");
assert!(
matches!(conflicting_entry, FileTreeEntryState::File(_)),
"Conflicting entry should remain a file"
);
// Test case 4: Single level directory creation
let simple_root_entry = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_new("/simple").unwrap(),
children: vec![],
ignored: false,
loaded: true,
});
let mut simple_root = FileTreeEntry::from(simple_root_entry);
let simple_target = StandardizedPath::try_new("/simple/new_dir").unwrap();
LocalRepoMetadataModel::ensure_parent_directories_exist(
&mut simple_root,
&simple_target,
);
let mut simple_paths = Vec::new();
collect_all_paths(&simple_root, &mut simple_paths);
assert!(simple_paths.contains(&StandardizedPath::try_new("/simple/new_dir").unwrap()));
// Test case 5: Target parent is the root itself (edge case)
let root_target_entry = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_new("/root").unwrap(),
children: vec![],
ignored: false,
loaded: true,
});
let mut root_target = FileTreeEntry::from(root_target_entry);
// This should not crash or create any new directories
LocalRepoMetadataModel::ensure_parent_directories_exist(
&mut root_target,
&StandardizedPath::try_new("/root").unwrap(),
);
let mut root_paths = Vec::new();
collect_all_paths(&root_target, &mut root_paths);
assert_eq!(root_paths.len(), 1); // Should only contain the root itself
// Test case 6: Empty path handling
let empty_root_entry = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_new("/empty").unwrap(),
children: vec![],
ignored: false,
loaded: true,
});
let mut empty_root = FileTreeEntry::from(empty_root_entry);
// Test with a path that has no additional parents to create
let same_level_target = StandardizedPath::try_new("/empty").unwrap();
LocalRepoMetadataModel::ensure_parent_directories_exist(
&mut empty_root,
&same_level_target,
);
let mut empty_paths = Vec::new();
collect_all_paths(&empty_root, &mut empty_paths);
assert_eq!(empty_paths.len(), 1); // Should still only contain the root
}
}
/// Helper function to collect all paths in a file tree
fn collect_all_paths(entry: &FileTreeEntry, paths: &mut Vec<StandardizedPath>) {
let root_path = entry.root_directory().clone();
collect_paths_recursive(entry, &root_path, paths);
}
fn collect_paths_recursive(
entry: &FileTreeEntry,
current_path: &StandardizedPath,
paths: &mut Vec<StandardizedPath>,
) {
paths.push(current_path.clone());
if let Some(FileTreeEntryState::Directory(_)) = entry.get(current_path) {
for child in entry.child_paths(current_path) {
collect_paths_recursive(entry, child, paths);
}
}
}
#[test]
fn test_canonicalized_path_functionality() {
use galaxy_util::standardized_path::StandardizedPath;
VirtualFS::test("canonicalized_path_test", |dirs, mut vfs| {
let repo_path = dirs.tests();
// Create a directory structure with symlinks
vfs.mkdir("real_dir/subdir")
.mkdir("other_dir")
.with_files(vec![
Stub::FileWithContent("real_dir/file.txt", "content"),
Stub::FileWithContent("real_dir/subdir/nested.rs", "rust code"),
]);
let real_dir = repo_path.join("real_dir");
let symlink_dir = repo_path.join("symlinked_dir");
let relative_path = repo_path.join("./real_dir");
// Create a symlink to real_dir
#[cfg(unix)]
let symlink_created = std::os::unix::fs::symlink(&real_dir, &symlink_dir).is_ok();
#[cfg(windows)]
let symlink_created =
std::os::windows::fs::symlink_dir(&real_dir, &symlink_dir).is_ok();
if symlink_created {
// Test that different path representations canonicalize to the same path
let canonical_real = StandardizedPath::from_local_canonicalized(&real_dir).unwrap();
let canonical_symlink =
StandardizedPath::from_local_canonicalized(&symlink_dir).unwrap();
let canonical_relative =
StandardizedPath::from_local_canonicalized(&relative_path).unwrap();
// All should point to the same canonical path
assert_eq!(canonical_real, canonical_symlink);
assert_eq!(canonical_real, canonical_relative);
// Test that the canonical path is absolute and resolved
let local = canonical_real.to_local_path().unwrap();
assert!(local.is_absolute());
assert!(!local.to_string_lossy().contains("./"));
}
// Test with various input types
let path_buf = real_dir.clone();
let path_ref = real_dir.as_path();
let canonical_from_pathbuf =
StandardizedPath::from_local_canonicalized(&path_buf).unwrap();
let canonical_from_path = StandardizedPath::from_local_canonicalized(path_ref).unwrap();
// All should be equal
assert_eq!(canonical_from_pathbuf, canonical_from_path);
// Test conversion to local path
let canonical = StandardizedPath::from_local_canonicalized(&real_dir).unwrap();
let local_path = canonical.to_local_path().unwrap();
// Test internal consistency - compare with dunce-canonicalized version
let expected_canonical = dunce::canonicalize(&real_dir).unwrap();
assert_eq!(local_path, expected_canonical);
// Test error handling for non-existent paths
let nonexistent = repo_path.join("nonexistent");
let result = StandardizedPath::from_local_canonicalized(&nonexistent);
assert!(result.is_err());
});
}
#[test]
fn test_repository_operations_with_standardized_paths() {
use galaxy_util::standardized_path::StandardizedPath;
VirtualFS::test("repo_canonicalized_test", |dirs, mut vfs| {
let test_root = dirs.tests();
// Create a real repository directory
vfs.mkdir("real_repo/src")
.mkdir("other_location")
.with_files(vec![
Stub::FileWithContent("real_repo/src/main.rs", "fn main() {}"),
Stub::FileWithContent("real_repo/.gitignore", "*.log\n/target/"),
Stub::FileWithContent("real_repo/README.md", "# Project"),
]);
let real_repo = test_root.join("real_repo");
let symlink_repo = test_root.join("symlinked_repo");
let relative_repo = test_root.join("./real_repo");
// Create symlink to the repo
#[cfg(unix)]
let symlink_created = std::os::unix::fs::symlink(&real_repo, &symlink_repo).is_ok();
#[cfg(windows)]
let symlink_created =
std::os::windows::fs::symlink_dir(&real_repo, &symlink_repo).is_ok();
if symlink_created {
App::test((), |mut app| async move {
let repo_watcher = app.add_singleton_model(DirectoryWatcher::new);
let _detected_repo =
app.add_singleton_model(|_| DetectedRepositories::default());
let model_handle = app.add_model(LocalRepoMetadataModel::new);
// Create file tree state for testing
let src_file =
Entry::File(FileMetadata::new(real_repo.join("src/main.rs"), false));
let readme_file =
Entry::File(FileMetadata::new(real_repo.join("README.md"), false));
let src_dir = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&real_repo.join("src")).unwrap(),
children: vec![src_file],
ignored: false,
loaded: true,
});
let root = Entry::Directory(DirectoryEntry {
path: StandardizedPath::try_from_local(&real_repo).unwrap(),
children: vec![src_dir, readme_file],
ignored: false,
loaded: true,
});
let (gitignore, _) = Gitignore::new(real_repo.join(".gitignore"));
let repo_handle = repo_watcher.update(&mut app, |repo_watcher, ctx| {
repo_watcher
.add_directory(
StandardizedPath::from_local_canonicalized(&real_repo).unwrap(),
ctx,
)
.unwrap()
});
let state = FileTreeState::new(root, vec![gitignore], Some(repo_handle));
// Test adding repository using different path representations
model_handle.update(&mut app, |model, ctx| {
// Add using real path
let result1 = model.add_repository_internal(
StandardizedPath::from_local_canonicalized(&real_repo).unwrap(),
state.clone(),
ctx,
);
assert!(result1.is_ok());
// Try to add using symlink path - this should canonicalize to the same path
let result2 = model.add_repository_internal(
StandardizedPath::from_local_canonicalized(&symlink_repo).unwrap(),
state.clone(),
ctx,
);
assert!(result2.is_ok());
// Try to add using relative path
let result3 = model.add_repository_internal(
StandardizedPath::from_local_canonicalized(&relative_repo).unwrap(),
state.clone(),
ctx,
);
assert!(result3.is_ok());
// Verify that only one repository entry exists (all paths canonicalized to the same)
let canonical_path =
StandardizedPath::from_local_canonicalized(&real_repo).unwrap();
assert!(model.repositories.contains_key(&canonical_path));
});
// Test find_repository_for_path with different path formats
model_handle.read(&app, |model, _ctx| {
let file_in_repo = real_repo.join("src/main.rs");
let symlink_file = symlink_repo.join("src/main.rs");
let found_real = model.find_repository_for_path(&file_in_repo);
let found_symlink = model.find_repository_for_path(&symlink_file);
// Both should find the same repository
assert!(found_real.is_some());
assert!(found_symlink.is_some());
assert_eq!(found_real, found_symlink);
});
});
}
});
}
#[test]
fn test_standardized_path_edge_cases() {
use galaxy_util::standardized_path::StandardizedPath;
VirtualFS::test("canonicalized_edge_cases", |dirs, mut vfs| {
let test_root = dirs.tests();
// Create test files and directories
vfs.mkdir("existing_dir")
.with_files(vec![Stub::FileWithContent("existing_file.txt", "content")]);
let existing_dir = test_root.join("existing_dir");
let existing_file = test_root.join("existing_file.txt");
let nonexistent = test_root.join("nonexistent");
// Test successful canonicalization
assert!(StandardizedPath::from_local_canonicalized(&existing_dir).is_ok());
assert!(StandardizedPath::from_local_canonicalized(&existing_file).is_ok());
// Test failed canonicalization
assert!(StandardizedPath::from_local_canonicalized(&nonexistent).is_err());
// Test equality and hashing
let canonical1 = StandardizedPath::from_local_canonicalized(&existing_dir).unwrap();
let canonical2 = StandardizedPath::from_local_canonicalized(&existing_dir).unwrap();
assert_eq!(canonical1, canonical2);
// Test that they can be used in HashMaps
let mut map = std::collections::HashMap::new();
map.insert(canonical1.clone(), "value1");
assert_eq!(map.get(&canonical2), Some(&"value1"));
// Test Debug trait
let debug_str = format!("{canonical1:?}");
assert!(debug_str.contains("StandardizedPath"));
});
}
}