use crate::ports::outbound::{LockfileParseResult, LockfileReader, ProjectConfigReader};
use crate::sbom_generation::domain::Package;
use crate::shared::error::SbomError;
use crate::shared::security::{read_file_with_security, MAX_FILE_SIZE};
use crate::shared::Result;
use std::collections::HashMap;
use std::path::Path;
pub struct FileSystemReader;
impl FileSystemReader {
pub fn new() -> Self {
Self
}
}
impl Default for FileSystemReader {
fn default() -> Self {
Self::new()
}
}
impl FileSystemReader {
fn safe_read_file(&self, path: &Path, file_type: &str) -> Result<String> {
read_file_with_security(path, file_type, MAX_FILE_SIZE)
}
}
impl LockfileReader for FileSystemReader {
fn read_lockfile(&self, project_path: &Path) -> Result<String> {
let lockfile_path = project_path.join("uv.lock");
if !lockfile_path.exists() {
return Err(SbomError::LockfileNotFound {
path: lockfile_path.clone(),
suggestion: format!(
"uv.lock file does not exist in project directory \"{}\".\n \
Please run in the root directory of a uv project, or specify the correct path with the --path option.",
project_path.display()
),
}
.into());
}
self.safe_read_file(&lockfile_path, "uv.lock").map_err(|e| {
SbomError::LockfileParseError {
path: lockfile_path,
details: e.to_string(),
}
.into()
})
}
fn read_and_parse_lockfile(&self, project_path: &Path) -> Result<LockfileParseResult> {
let lockfile_content = self.read_lockfile(project_path)?;
self.parse_lockfile_content(&lockfile_content, project_path)
}
}
impl FileSystemReader {
fn parse_lockfile_content(
&self,
content: &str,
project_path: &Path,
) -> Result<LockfileParseResult> {
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct UvLock {
package: Vec<UvPackage>,
}
#[derive(Debug, Deserialize)]
struct UvPackage {
name: String,
version: String,
#[serde(default)]
dependencies: Vec<UvDependency>,
#[serde(default, rename = "dev-dependencies")]
dev_dependencies: Option<DevDependencies>,
}
#[derive(Debug, Deserialize)]
struct UvDependency {
name: String,
}
#[derive(Debug, Deserialize)]
struct DevDependencies {
#[serde(default)]
dev: Vec<UvDependency>,
}
let lockfile: UvLock =
toml::from_str(content).map_err(|e| SbomError::LockfileParseError {
path: project_path.join("uv.lock"),
details: e.to_string(),
})?;
let mut packages = Vec::new();
let mut dependency_map = HashMap::new();
for pkg in lockfile.package {
packages.push(Package::new(pkg.name.clone(), pkg.version.clone())?);
let mut deps = Vec::new();
for dep in &pkg.dependencies {
deps.push(dep.name.clone());
}
if let Some(dev_deps) = &pkg.dev_dependencies {
for dep in &dev_deps.dev {
deps.push(dep.name.clone());
}
}
dependency_map.insert(pkg.name, deps);
}
Ok((packages, dependency_map))
}
}
impl ProjectConfigReader for FileSystemReader {
fn read_project_name(&self, project_path: &Path) -> Result<String> {
let pyproject_path = project_path.join("pyproject.toml");
if !pyproject_path.exists() {
anyhow::bail!("pyproject.toml not found in project directory");
}
let pyproject_content = self.safe_read_file(&pyproject_path, "pyproject.toml")?;
let pyproject: toml::Value = toml::from_str(&pyproject_content)
.map_err(|e| anyhow::anyhow!("Failed to parse pyproject.toml: {}", e))?;
let project_name = pyproject
.get("project")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.ok_or_else(|| anyhow::anyhow!("Project name not found in pyproject.toml"))?;
Ok(project_name.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_read_lockfile_success() {
let temp_dir = TempDir::new().unwrap();
let lockfile_path = temp_dir.path().join("uv.lock");
fs::write(&lockfile_path, "test content").unwrap();
let reader = FileSystemReader::new();
let content = reader.read_lockfile(temp_dir.path()).unwrap();
assert_eq!(content, "test content");
}
#[test]
fn test_read_lockfile_not_found() {
let temp_dir = TempDir::new().unwrap();
let reader = FileSystemReader::new();
let result = reader.read_lockfile(temp_dir.path());
assert!(result.is_err());
let err_string = format!("{}", result.unwrap_err());
assert!(err_string.contains("uv.lock file does not exist"));
}
#[test]
fn test_read_project_name_success() {
let temp_dir = TempDir::new().unwrap();
let pyproject_path = temp_dir.path().join("pyproject.toml");
fs::write(
&pyproject_path,
r#"
[project]
name = "test-project"
version = "1.0.0"
"#,
)
.unwrap();
let reader = FileSystemReader::new();
let project_name = reader.read_project_name(temp_dir.path()).unwrap();
assert_eq!(project_name, "test-project");
}
#[test]
fn test_read_project_name_file_not_found() {
let temp_dir = TempDir::new().unwrap();
let reader = FileSystemReader::new();
let result = reader.read_project_name(temp_dir.path());
assert!(result.is_err());
let err_string = format!("{}", result.unwrap_err());
assert!(err_string.contains("pyproject.toml not found"));
}
#[test]
fn test_read_project_name_invalid_toml() {
let temp_dir = TempDir::new().unwrap();
let pyproject_path = temp_dir.path().join("pyproject.toml");
fs::write(&pyproject_path, "invalid toml [[[").unwrap();
let reader = FileSystemReader::new();
let result = reader.read_project_name(temp_dir.path());
assert!(result.is_err());
let err_string = format!("{}", result.unwrap_err());
assert!(err_string.contains("Failed to parse pyproject.toml"));
}
#[test]
fn test_read_project_name_missing_name_field() {
let temp_dir = TempDir::new().unwrap();
let pyproject_path = temp_dir.path().join("pyproject.toml");
fs::write(
&pyproject_path,
r#"
[project]
version = "1.0.0"
"#,
)
.unwrap();
let reader = FileSystemReader::new();
let result = reader.read_project_name(temp_dir.path());
assert!(result.is_err());
let err_string = format!("{}", result.unwrap_err());
assert!(err_string.contains("Project name not found"));
}
}