use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::config::Settings;
use crate::project_resolver::{
ResolutionResult, Sha256Hash,
helpers::{compute_config_shas, extract_language_config_paths, is_language_enabled},
memo::ResolutionMemo,
persist::{ResolutionIndex, ResolutionPersistence, ResolutionRules},
provider::ProjectResolutionProvider,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CsprojPath(PathBuf);
impl CsprojPath {
pub fn new(path: PathBuf) -> Self {
Self(path)
}
pub fn as_path(&self) -> &PathBuf {
&self.0
}
}
#[derive(Debug, Clone, Default)]
pub struct CsprojInfo {
pub root_namespace: Option<String>,
pub assembly_name: Option<String>,
pub is_sdk_style: bool,
}
pub struct CSharpProvider {
#[allow(dead_code)]
memo: ResolutionMemo<HashMap<CsprojPath, Sha256Hash>>,
}
impl Default for CSharpProvider {
fn default() -> Self {
Self::new()
}
}
impl CSharpProvider {
pub fn new() -> Self {
Self {
memo: ResolutionMemo::new(),
}
}
pub fn namespace_for_file(&self, file_path: &Path) -> Option<String> {
let codanna_dir = Path::new(crate::init::local_dir_name());
let persistence = ResolutionPersistence::new(codanna_dir);
let index = persistence.load("csharp").ok()?;
let canon_file = file_path.canonicalize().ok()?;
let config_path = index.get_config_for_file(&canon_file)?;
let rules = index.rules.get(config_path)?;
let root_namespace = rules.base_url.as_ref()?;
for root_path in rules.paths.keys() {
let root = Path::new(root_path);
let canon_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
if let Ok(relative) = canon_file.strip_prefix(&canon_root) {
let dir_path = relative.parent()?.to_string_lossy();
if dir_path.is_empty() {
return Some(root_namespace.clone());
} else {
let namespace_suffix = dir_path.replace(['/', '\\'], ".");
return Some(format!("{root_namespace}.{namespace_suffix}"));
}
}
}
None
}
fn parse_csproj(&self, csproj_path: &Path) -> ResolutionResult<CsprojInfo> {
use std::fs;
let content = fs::read_to_string(csproj_path).map_err(|e| {
crate::project_resolver::ResolutionError::IoError {
path: csproj_path.to_path_buf(),
cause: e.to_string(),
}
})?;
let is_sdk_style = content.contains("Sdk=\"Microsoft.NET.Sdk")
|| content.contains("Sdk=\"Microsoft.NET.Sdk.Web")
|| content.contains("Sdk=\"Microsoft.NET.Sdk.Razor")
|| content.contains("Sdk=\"Microsoft.NET.Sdk.Worker")
|| content.contains("<Sdk Name=\"Microsoft.NET.Sdk");
let root_namespace = Self::extract_xml_element(&content, "RootNamespace");
let assembly_name = Self::extract_xml_element(&content, "AssemblyName");
Ok(CsprojInfo {
root_namespace,
assembly_name,
is_sdk_style,
})
}
fn extract_xml_element(content: &str, element_name: &str) -> Option<String> {
let open_tag = format!("<{element_name}>");
let close_tag = format!("</{element_name}>");
let start = content.find(&open_tag)?;
let start = start + open_tag.len();
let end = content[start..].find(&close_tag)?;
let value = content[start..start + end].trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn derive_root_namespace(&self, csproj_path: &Path, info: &CsprojInfo) -> String {
if let Some(ref ns) = info.root_namespace {
return ns.clone();
}
if let Some(ref asm) = info.assembly_name {
return asm.clone();
}
csproj_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string()
}
fn build_rules_for_config(&self, config_path: &Path) -> ResolutionResult<ResolutionRules> {
let csproj_info = self.parse_csproj(config_path)?;
let project_root = config_path.parent().unwrap_or(Path::new(".")).to_path_buf();
let root_namespace = self.derive_root_namespace(config_path, &csproj_info);
let mut paths: HashMap<String, Vec<String>> = HashMap::new();
paths.insert(project_root.to_string_lossy().to_string(), vec![]);
Ok(ResolutionRules {
base_url: Some(root_namespace),
paths,
})
}
}
impl ProjectResolutionProvider for CSharpProvider {
fn language_id(&self) -> &'static str {
"csharp"
}
fn is_enabled(&self, settings: &Settings) -> bool {
is_language_enabled(settings, "csharp")
}
fn config_paths(&self, settings: &Settings) -> Vec<PathBuf> {
extract_language_config_paths(settings, "csharp")
}
fn compute_shas(&self, configs: &[PathBuf]) -> ResolutionResult<HashMap<PathBuf, Sha256Hash>> {
compute_config_shas(configs)
}
fn rebuild_cache(&self, settings: &Settings) -> ResolutionResult<()> {
let config_paths = self.config_paths(settings);
if config_paths.is_empty() {
return Ok(());
}
let persistence = ResolutionPersistence::new(Path::new(crate::init::local_dir_name()));
let mut index = ResolutionIndex::new();
for config_path in &config_paths {
if !config_path.exists() {
continue;
}
let rules = self.build_rules_for_config(config_path)?;
for source_dir in rules.paths.keys() {
let pattern = format!("{source_dir}/**/*.cs");
index.mappings.insert(pattern, config_path.clone());
}
index.rules.insert(config_path.clone(), rules);
}
let shas = self.compute_shas(&config_paths)?;
for (path, sha) in shas {
index.hashes.insert(path, sha.0);
}
persistence.save("csharp", &index)?;
Ok(())
}
fn select_affected_files(&self, _settings: &Settings) -> Vec<PathBuf> {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_parse_csproj_sdk_style_with_root_namespace() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("MyProject.csproj");
let csproj_content = r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>MyCompany.MyProject</RootNamespace>
</PropertyGroup>
</Project>"#;
fs::write(&csproj_path, csproj_content).unwrap();
let provider = CSharpProvider::new();
let info = provider.parse_csproj(&csproj_path).unwrap();
assert!(info.is_sdk_style);
assert_eq!(info.root_namespace, Some("MyCompany.MyProject".to_string()));
assert!(info.assembly_name.is_none());
}
#[test]
fn test_parse_csproj_sdk_style_implicit_namespace() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("MyCompany.MyApp.csproj");
let csproj_content = r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>"#;
fs::write(&csproj_path, csproj_content).unwrap();
let provider = CSharpProvider::new();
let info = provider.parse_csproj(&csproj_path).unwrap();
assert!(info.is_sdk_style);
assert!(info.root_namespace.is_none()); assert!(info.assembly_name.is_none());
let namespace = provider.derive_root_namespace(&csproj_path, &info);
assert_eq!(namespace, "MyCompany.MyApp");
}
#[test]
fn test_parse_csproj_with_assembly_name() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("EFCore.Abstractions.csproj");
let csproj_content = r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>Microsoft.EntityFrameworkCore.Abstractions</AssemblyName>
<RootNamespace>Microsoft.EntityFrameworkCore</RootNamespace>
</PropertyGroup>
</Project>"#;
fs::write(&csproj_path, csproj_content).unwrap();
let provider = CSharpProvider::new();
let info = provider.parse_csproj(&csproj_path).unwrap();
assert!(info.is_sdk_style);
assert_eq!(
info.root_namespace,
Some("Microsoft.EntityFrameworkCore".to_string())
);
assert_eq!(
info.assembly_name,
Some("Microsoft.EntityFrameworkCore.Abstractions".to_string())
);
let namespace = provider.derive_root_namespace(&csproj_path, &info);
assert_eq!(namespace, "Microsoft.EntityFrameworkCore");
}
#[test]
fn test_parse_csproj_web_sdk() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("MyWebApp.csproj");
let csproj_content = r#"<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>"#;
fs::write(&csproj_path, csproj_content).unwrap();
let provider = CSharpProvider::new();
let info = provider.parse_csproj(&csproj_path).unwrap();
assert!(info.is_sdk_style);
}
#[test]
fn test_derive_namespace_priority() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("ProjectName.csproj");
let provider = CSharpProvider::new();
let info1 = CsprojInfo {
root_namespace: Some("Custom.Namespace".to_string()),
assembly_name: Some("Custom.Assembly".to_string()),
is_sdk_style: true,
};
assert_eq!(
provider.derive_root_namespace(&csproj_path, &info1),
"Custom.Namespace"
);
let info2 = CsprojInfo {
root_namespace: None,
assembly_name: Some("Custom.Assembly".to_string()),
is_sdk_style: true,
};
assert_eq!(
provider.derive_root_namespace(&csproj_path, &info2),
"Custom.Assembly"
);
let info3 = CsprojInfo {
root_namespace: None,
assembly_name: None,
is_sdk_style: true,
};
assert_eq!(
provider.derive_root_namespace(&csproj_path, &info3),
"ProjectName"
);
}
#[test]
fn test_build_rules_creates_correct_structure() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("MyApp.csproj");
let csproj_content = r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>MyCompany.MyApp</RootNamespace>
</PropertyGroup>
</Project>"#;
fs::write(&csproj_path, csproj_content).unwrap();
let provider = CSharpProvider::new();
let rules = provider.build_rules_for_config(&csproj_path).unwrap();
assert_eq!(rules.base_url, Some("MyCompany.MyApp".to_string()));
assert_eq!(rules.paths.len(), 1);
}
#[test]
fn test_provider_language_id() {
let provider = CSharpProvider::new();
assert_eq!(provider.language_id(), "csharp");
}
#[test]
fn test_provider_uses_helpers_for_settings() {
let provider = CSharpProvider::new();
let settings = Settings::default();
assert!(provider.is_enabled(&settings)); assert!(provider.config_paths(&settings).is_empty()); }
#[test]
#[ignore = "Requires filesystem isolation (changes cwd, conflicts with parallel tests)"]
fn test_rebuild_cache_creates_resolution_json() {
let temp_dir = TempDir::new().unwrap();
let csproj_path = temp_dir.path().join("MyApp.csproj");
let codanna_dir = temp_dir.path().join(crate::init::local_dir_name());
let csproj_content = r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>MyCompany.MyApp</RootNamespace>
</PropertyGroup>
</Project>"#;
fs::write(&csproj_path, csproj_content).unwrap();
let settings_content = format!(
r#"
[languages.csharp]
enabled = true
config_files = ["{}"]
"#,
csproj_path.display()
);
let settings: Settings = toml::from_str(&settings_content).unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&temp_dir).unwrap();
fs::create_dir_all(&codanna_dir).unwrap();
let provider = CSharpProvider::new();
provider.rebuild_cache(&settings).unwrap();
std::env::set_current_dir(&original_dir).unwrap();
let cache_path = codanna_dir.join("index/resolvers/csharp_resolution.json");
assert!(
cache_path.exists(),
"Cache file should exist at {}",
cache_path.display()
);
let cache_content = fs::read_to_string(&cache_path).unwrap();
assert!(
cache_content.contains("MyCompany.MyApp"),
"Cache should contain namespace"
);
}
}