use crate::ports::outbound::workspace_reader::{WorkspaceMember, WorkspaceReader};
use crate::shared::security::{read_file_with_security, MAX_FILE_SIZE};
use crate::shared::Result;
use anyhow::Context;
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub struct UvWorkspaceReader;
impl UvWorkspaceReader {
pub fn new() -> Self {
Self
}
fn parse_members(content: &str, workspace_root: &Path) -> Result<Vec<WorkspaceMember>> {
#[derive(Deserialize, Default)]
struct Manifest {
members: Option<Vec<String>>,
}
#[derive(Deserialize)]
struct PackageSource {
editable: Option<String>,
#[serde(rename = "virtual")]
virtual_path: Option<String>,
}
impl PackageSource {
fn local_path(&self) -> Option<&str> {
self.editable.as_deref().or(self.virtual_path.as_deref())
}
}
#[derive(Deserialize)]
struct UvPackage {
name: String,
source: Option<PackageSource>,
}
#[derive(Deserialize)]
struct UvLock {
manifest: Option<Manifest>,
#[serde(default)]
package: Vec<UvPackage>,
}
let lock: UvLock = toml::from_str(content).context("Failed to parse uv.lock")?;
let member_ids = match lock.manifest.and_then(|m| m.members) {
Some(ids) if !ids.is_empty() => ids,
_ => return Ok(vec![]),
};
let members = member_ids
.into_iter()
.map(|member_id| {
if let Some(pkg) = lock.package.iter().find(|p| {
p.source
.as_ref()
.and_then(|s| s.local_path())
.map(|path| path == member_id)
.unwrap_or(false)
}) {
let absolute_path = workspace_root.join(&member_id);
return WorkspaceMember {
name: pkg.name.clone(),
absolute_path,
};
}
if let Some(pkg) = lock.package.iter().find(|p| p.name == member_id) {
let rel = pkg
.source
.as_ref()
.and_then(|s| s.local_path())
.map(|p| p.to_owned())
.unwrap_or_else(|| member_id.clone());
let absolute_path = workspace_root.join(&rel);
return WorkspaceMember {
name: pkg.name.clone(),
absolute_path,
};
}
let name = PathBuf::from(&member_id)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| member_id.clone());
let absolute_path = workspace_root.join(&member_id);
WorkspaceMember {
name,
absolute_path,
}
})
.collect();
Ok(members)
}
}
impl Default for UvWorkspaceReader {
fn default() -> Self {
Self::new()
}
}
impl WorkspaceReader for UvWorkspaceReader {
fn read_workspace_members(&self, workspace_root: &Path) -> Result<Vec<WorkspaceMember>> {
let lock_path = workspace_root.join("uv.lock");
let content = read_file_with_security(&lock_path, "uv.lock", MAX_FILE_SIZE)
.with_context(|| format!("Failed to read uv.lock at: {}", lock_path.display()))?;
Self::parse_members(&content, workspace_root)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::TempDir;
const WORKSPACE_LOCK: &str = r#"
version = 1
requires-python = ">=3.11"
[manifest]
members = [
"packages/alpha",
"packages/beta",
]
[[package]]
name = "alpha"
version = "0.1.0"
source = { editable = "packages/alpha" }
[[package]]
name = "beta"
version = "0.2.0"
source = { editable = "packages/beta" }
[[package]]
name = "requests"
version = "2.31.0"
source = { registry = "https://pypi.org/simple" }
"#;
const NON_WORKSPACE_LOCK: &str = r#"
version = 1
requires-python = ">=3.8"
[[package]]
name = "sample-project"
version = "1.0.0"
source = { virtual = "." }
[[package]]
name = "requests"
version = "2.31.0"
source = { registry = "https://pypi.org/simple" }
"#;
const EMPTY_MEMBERS_LOCK: &str = r#"
version = 1
requires-python = ">=3.11"
[manifest]
members = []
[[package]]
name = "requests"
version = "2.31.0"
source = { registry = "https://pypi.org/simple" }
"#;
#[test]
fn test_parse_members_returns_correct_members_for_workspace_lock() {
let root = Path::new("/workspace");
let members = UvWorkspaceReader::parse_members(WORKSPACE_LOCK, root).unwrap();
assert_eq!(members.len(), 2);
let alpha = members
.iter()
.find(|m| m.absolute_path == root.join("packages/alpha"))
.unwrap();
assert_eq!(alpha.name, "alpha");
let beta = members
.iter()
.find(|m| m.absolute_path == root.join("packages/beta"))
.unwrap();
assert_eq!(beta.name, "beta");
}
#[test]
fn test_parse_members_returns_empty_for_non_workspace_lock() {
let root = Path::new("/project");
let members = UvWorkspaceReader::parse_members(NON_WORKSPACE_LOCK, root).unwrap();
assert!(members.is_empty());
}
#[test]
fn test_parse_members_returns_empty_for_empty_members_array() {
let root = Path::new("/project");
let members = UvWorkspaceReader::parse_members(EMPTY_MEMBERS_LOCK, root).unwrap();
assert!(members.is_empty());
}
#[test]
fn test_parse_members_falls_back_to_path_component_when_no_matching_package() {
let content = r#"
version = 1
[manifest]
members = [
"packages/gamma",
]
[[package]]
name = "requests"
version = "2.31.0"
source = { registry = "https://pypi.org/simple" }
"#;
let root = Path::new("/workspace");
let members = UvWorkspaceReader::parse_members(content, root).unwrap();
assert_eq!(members.len(), 1);
assert_eq!(members[0].name, "gamma");
assert_eq!(members[0].absolute_path, root.join("packages/gamma"));
}
#[test]
fn test_parse_members_invalid_toml_returns_error() {
let root = Path::new("/workspace");
let result = UvWorkspaceReader::parse_members("invalid [[[ toml", root);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Failed to parse uv.lock"));
}
#[test]
fn test_read_workspace_members_returns_members_from_real_file() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("uv.lock"), WORKSPACE_LOCK).unwrap();
let reader = UvWorkspaceReader::new();
let members = reader.read_workspace_members(temp_dir.path()).unwrap();
assert_eq!(members.len(), 2);
assert!(members.iter().any(|m| m.name == "alpha"));
assert!(members.iter().any(|m| m.name == "beta"));
}
#[test]
fn test_parse_members_handles_new_uv_format_with_name_ids_and_virtual_source() {
let content = r#"
version = 1
revision = 3
requires-python = ">=3.11"
[manifest]
members = [
"api",
"worker",
]
[[package]]
name = "api"
version = "0.1.0"
source = { virtual = "packages/api" }
[[package]]
name = "worker"
version = "0.1.0"
source = { virtual = "packages/worker" }
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
"#;
let root = Path::new("/workspace");
let members = UvWorkspaceReader::parse_members(content, root).unwrap();
assert_eq!(members.len(), 2);
let api = members.iter().find(|m| m.name == "api").unwrap();
assert_eq!(api.absolute_path, root.join("packages/api"));
let worker = members.iter().find(|m| m.name == "worker").unwrap();
assert_eq!(worker.absolute_path, root.join("packages/worker"));
}
#[test]
fn test_parse_members_handles_old_uv_format_with_path_ids_and_editable_source() {
let root = Path::new("/workspace");
let members = UvWorkspaceReader::parse_members(WORKSPACE_LOCK, root).unwrap();
assert_eq!(members.len(), 2);
let alpha = members
.iter()
.find(|m| m.absolute_path == root.join("packages/alpha"))
.unwrap();
assert_eq!(alpha.name, "alpha");
}
}