use log::{debug, info, warn};
use crate::{
file::File,
metadata::{
cilassemblyview::CilAssemblyView, cilobject::CilObject, identity::AssemblyIdentity,
validation::ValidationConfig,
},
project::{context::ProjectContext, ProjectResult},
Error, Result,
};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
pub struct ProjectLoader {
primary_file: Option<PathBuf>,
dependency_files: Vec<PathBuf>,
search_paths: Vec<PathBuf>,
auto_discover: bool,
strict_mode: bool,
validation_config: Option<ValidationConfig>,
}
impl ProjectLoader {
#[must_use]
pub fn new() -> Self {
Self {
primary_file: None,
dependency_files: Vec::new(),
search_paths: Vec::new(),
auto_discover: false,
strict_mode: false,
validation_config: None,
}
}
pub fn primary_file<P: AsRef<Path>>(mut self, path: P) -> Result<Self> {
let path = path.as_ref();
if !path.exists() {
return Err(Error::Configuration(format!(
"Primary file does not exist: {}",
path.display()
)));
}
self.primary_file = Some(path.to_path_buf());
Ok(self)
}
pub fn with_dependency<P: AsRef<Path>>(mut self, path: P) -> Result<Self> {
let path = path.as_ref();
if !path.exists() {
return Err(Error::Configuration(format!(
"Dependency file does not exist: {}",
path.display()
)));
}
self.dependency_files.push(path.to_path_buf());
Ok(self)
}
pub fn with_search_path<P: AsRef<Path>>(mut self, path: P) -> Result<Self> {
let path = path.as_ref();
if !path.exists() || !path.is_dir() {
return Err(Error::Configuration(format!(
"Search path does not exist or is not a directory: {}",
path.display()
)));
}
self.search_paths.push(path.to_path_buf());
Ok(self)
}
#[must_use]
pub fn auto_discover(mut self, enabled: bool) -> Self {
self.auto_discover = enabled;
self
}
#[must_use]
pub fn strict_mode(mut self, strict: bool) -> Self {
self.strict_mode = strict;
self
}
#[must_use]
pub fn with_validation(mut self, config: ValidationConfig) -> Self {
self.validation_config = Some(config);
self
}
pub fn build(self) -> Result<ProjectResult> {
let primary_path = self.primary_file.clone().ok_or_else(|| {
Error::Configuration(
"No primary file specified. Use primary_file() to set the main assembly."
.to_string(),
)
})?;
info!("Loading project: primary={}", primary_path.display());
let primary_search_dir = primary_path
.parent()
.ok_or_else(|| {
Error::Configuration("Cannot determine parent directory of root file".to_string())
})?
.to_path_buf();
let mut result = ProjectResult::new();
self.discover_assemblies(&primary_path, &primary_search_dir, &mut result)?;
debug!(
"Resolving {} assembly references",
result.pending_views.len()
);
self.load_assemblies_parallel(&mut result)?;
info!(
"Project loaded: {}/{} assemblies",
result.success_count(),
result.success_count() + result.failure_count()
);
Ok(result)
}
fn discover_assemblies(
&self,
primary_path: &Path,
search_dir: &Path,
result: &mut ProjectResult,
) -> Result<()> {
let validation_config = self
.validation_config
.unwrap_or_else(ValidationConfig::production);
result.enqueue(primary_path.to_path_buf());
for dep_path in &self.dependency_files {
result.enqueue(dep_path.clone());
}
while let Some(current_path) = result.next_path() {
let Some((view, identity)) = Self::load_assembly_view(¤t_path, validation_config)
else {
if let Some(name) = current_path.file_stem() {
result.record_failure(
name.to_string_lossy().to_string(),
"Failed to load assembly".to_string(),
);
}
continue;
};
if current_path == primary_path {
result.primary_identity = Some(identity.clone());
}
if result.pending_views.contains_key(&identity) {
continue;
}
let dependencies = view.dependencies();
result.pending_views.insert(identity, view);
if self.auto_discover {
self.resolve_dependencies(dependencies, search_dir, result);
}
}
if result.pending_views.is_empty() {
return Err(Error::Configuration(format!(
"Failed to discover any assemblies, including the primary file: {}. \
This may indicate the file is corrupted or not a valid .NET assembly.",
primary_path.display()
)));
}
Ok(())
}
fn load_assembly_view(
path: &Path,
validation_config: ValidationConfig,
) -> Option<(CilAssemblyView, AssemblyIdentity)> {
let file = File::from_path(path).ok()?;
if !file.is_clr() {
return None;
}
let view =
CilAssemblyView::from_dotscope_file_with_validation(file, validation_config).ok()?;
let identity = view.identity().ok().flatten()?;
Some((view, identity))
}
fn resolve_dependencies(
&self,
dependencies: Vec<AssemblyIdentity>,
search_dir: &Path,
result: &mut ProjectResult,
) {
for required in dependencies {
if result.has_compatible_version(&required) {
continue;
}
match self.resolve_dependency(&required, search_dir) {
Some((path, actual)) => {
info!(
"Resolved dependency: {} -> {}",
required.name,
path.display()
);
if !actual.satisfies(&required) {
result.record_version_mismatch(required, actual);
}
result.enqueue(path);
}
None => {
warn!(
"Dependency not found: {} v{}",
required.name, required.version
);
result
.record_failure(required.name.clone(), "Dependency not found".to_string());
}
}
}
}
fn load_assemblies_parallel(&self, result: &mut ProjectResult) -> Result<()> {
let views = result.take_pending_views();
let primary_identity = result.primary_identity.clone();
let project_context = Arc::new(ProjectContext::new(views.len())?);
let mut sorted_views: Vec<_> = views.into_iter().collect();
sorted_views.sort_by(|(a, _), (b, _)| a.name.cmp(&b.name));
let handles: Vec<_> = sorted_views
.into_iter()
.map(|(identity, view)| {
let context = project_context.clone();
let validation_config = self.validation_config.unwrap_or_default();
std::thread::spawn(move || {
let load_result =
CilObject::from_project(view, context.as_ref(), validation_config);
if let Err(ref e) = load_result {
context.break_all_barriers(&format!(
"Assembly {} failed to load: {}",
identity.name, e
));
}
(identity, load_result)
})
})
.collect();
for handle in handles {
let Ok((identity, load_result)) = handle.join() else {
result.record_failure(
"unknown".to_string(),
"assembly loading thread panicked".to_string(),
);
continue;
};
match load_result {
Ok(cil_object) => {
let is_primary = primary_identity
.as_ref()
.is_some_and(|primary_id| identity == *primary_id);
if let Err(e) = result.project.add_assembly(cil_object, is_primary) {
if self.strict_mode {
return Err(Error::Configuration(format!(
"Failed to add {} to project: {}",
identity.name, e
)));
}
result.record_failure(identity.name.clone(), e.to_string());
} else {
result.record_success(Some(identity));
}
}
Err(e) => {
if self.strict_mode {
return Err(Error::Configuration(format!(
"Failed to load {} in strict mode: {}",
identity.name, e
)));
}
result.record_failure(identity.name, e.to_string());
}
}
}
Ok(())
}
fn resolve_dependency(
&self,
required: &AssemblyIdentity,
search_dir: &Path,
) -> Option<(PathBuf, AssemblyIdentity)> {
let candidate_paths = self.find_candidate_files(&required.name, search_dir);
let mut best_match: Option<(PathBuf, AssemblyIdentity)> = None;
for path in candidate_paths {
let file = match File::from_path(&path) {
Ok(f) if f.is_clr() => f,
_ => continue,
};
let Ok(view) = CilAssemblyView::from_dotscope_file(file) else {
continue;
};
let Ok(Some(identity)) = view.identity() else {
continue;
};
if identity.satisfies(required) {
return Some((path, identity));
}
let dominated = best_match.as_ref().is_some_and(|(_, best)| {
best.version
.is_closer_to(&identity.version, &required.version)
});
if !dominated {
best_match = Some((path, identity));
}
}
best_match
}
fn find_candidate_files(&self, name: &str, search_dir: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
for search_path in &self.search_paths {
paths.push(search_path.join(format!("{name}.dll")));
paths.push(search_path.join(format!("{name}.exe")));
}
paths.push(search_dir.join(format!("{name}.dll")));
paths.push(search_dir.join(format!("{name}.exe")));
paths.into_iter().filter(|p| p.exists()).collect()
}
}
impl Default for ProjectLoader {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_project_loader_basic_api() {
let _loader = ProjectLoader::new().auto_discover(true).strict_mode(false);
let _default_loader = ProjectLoader::default();
}
#[test]
fn test_project_loader_validation_errors() {
let result = ProjectLoader::new().primary_file("/nonexistent/file.exe");
assert!(result.is_err(), "Should fail for non-existent primary file");
let result = ProjectLoader::new().with_dependency("/nonexistent/dep.dll");
assert!(
result.is_err(),
"Should fail for non-existent dependency file"
);
let result = ProjectLoader::new().with_search_path("/nonexistent/directory");
assert!(result.is_err(), "Should fail for non-existent search path");
}
#[test]
fn test_project_loader_build_fails_without_primary() {
let result = ProjectLoader::new().build();
assert!(
result.is_err(),
"Should fail when no primary file specified"
);
if let Err(e) = result {
assert!(
e.to_string().contains("No primary file specified"),
"Error should mention missing primary file"
);
}
}
}