use anyhow::{Result, bail};
use std::fmt;
use std::str::FromStr;
use crate::core::ResourceType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockfileDependencyRef {
pub source: Option<String>,
pub resource_type: ResourceType,
pub path: String,
pub version: Option<String>,
}
impl LockfileDependencyRef {
pub fn new(
source: Option<String>,
resource_type: ResourceType,
path: String,
version: Option<String>,
) -> Self {
Self {
source,
resource_type,
path,
version,
}
}
pub fn local(resource_type: ResourceType, path: String, version: Option<String>) -> Self {
Self {
source: None,
resource_type,
path,
version,
}
}
pub fn git(
source: String,
resource_type: ResourceType,
path: String,
version: Option<String>,
) -> Self {
Self {
source: Some(source),
resource_type,
path,
version,
}
}
}
impl FromStr for LockfileDependencyRef {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
let (base_part, version) = if let Some(at_pos) = s.rfind('@') {
let base = &s[..at_pos];
let version = Some(s[at_pos + 1..].to_string());
(base, version)
} else {
(s, None)
};
let first_colon_pos = base_part.find(':').unwrap_or(0);
let has_slash_before_colon = if let Some(slash_pos) = base_part.find('/') {
slash_pos < first_colon_pos
} else {
false
};
let is_git = has_slash_before_colon;
let (source, type_path_part) = if is_git {
if let Some(slash_pos) = base_part.find('/') {
let source_part = &base_part[..slash_pos];
let rest = &base_part[slash_pos + 1..];
(Some(source_part.to_string()), rest)
} else {
bail!("Git dependency format requires / separator: {}", s);
}
} else {
(None, base_part)
};
if let Some(colon_pos) = type_path_part.find(':') {
let type_part = &type_path_part[..colon_pos];
let path_part = &type_path_part[colon_pos + 1..];
let resource_type = type_part.parse::<ResourceType>()?;
if path_part.is_empty() {
bail!("Dependency path cannot be empty in: {}", s);
}
Ok(Self {
source,
resource_type,
path: path_part.to_string(),
version,
})
} else {
bail!(
"Invalid dependency reference format: {}. Expected format: <type>:<path> or <source>/<type>:<path>",
s
);
}
}
}
impl fmt::Display for LockfileDependencyRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let normalized_path = crate::utils::normalize_path_for_storage(&self.path);
match &self.source {
Some(source) => {
write!(f, "{}/{}:{}", source, self.resource_type, normalized_path)?;
if let Some(version) = &self.version {
write!(f, "@{}", version)?;
}
Ok(())
}
None => {
write!(f, "{}:{}", self.resource_type, normalized_path)?;
if let Some(version) = &self.version {
write!(f, "@{}", version)?;
}
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_local_dependency_no_version() {
let dep =
LockfileDependencyRef::from_str("snippet:snippets/commands/update-docstrings").unwrap();
assert_eq!(dep.source, None);
assert_eq!(dep.resource_type, ResourceType::Snippet);
assert_eq!(dep.path, "snippets/commands/update-docstrings");
assert_eq!(dep.version, None);
}
#[test]
fn test_parse_local_dependency_with_version() {
let dep =
LockfileDependencyRef::from_str("snippet:snippets/commands/update-docstrings@v0.0.1")
.unwrap();
assert_eq!(dep.source, None);
assert_eq!(dep.resource_type, ResourceType::Snippet);
assert_eq!(dep.path, "snippets/commands/update-docstrings");
assert_eq!(dep.version, Some("v0.0.1".to_string()));
}
#[test]
fn test_parse_git_dependency_no_version() {
let dep = LockfileDependencyRef::from_str(
"agpm-resources/snippet:snippets/commands/update-docstrings",
)
.unwrap();
assert_eq!(dep.source, Some("agpm-resources".to_string()));
assert_eq!(dep.resource_type, ResourceType::Snippet);
assert_eq!(dep.path, "snippets/commands/update-docstrings");
assert_eq!(dep.version, None);
}
#[test]
fn test_parse_git_dependency_with_version() {
let dep = LockfileDependencyRef::from_str(
"agpm-resources/snippet:snippets/commands/update-docstrings@v0.0.1",
)
.unwrap();
assert_eq!(dep.source, Some("agpm-resources".to_string()));
assert_eq!(dep.resource_type, ResourceType::Snippet);
assert_eq!(dep.path, "snippets/commands/update-docstrings");
assert_eq!(dep.version, Some("v0.0.1".to_string()));
}
#[test]
fn test_parse_invalid_format() {
let result = LockfileDependencyRef::from_str("invalid-format");
assert!(result.is_err());
}
#[test]
fn test_parse_empty_path() {
let result = LockfileDependencyRef::from_str("snippet:");
assert!(result.is_err());
}
#[test]
fn test_display_local_dependency() {
let dep = LockfileDependencyRef::local(
ResourceType::Snippet,
"snippets/commands/update-docstrings".to_string(),
Some("v0.0.1".to_string()),
);
assert_eq!(dep.to_string(), "snippet:snippets/commands/update-docstrings@v0.0.1");
}
#[test]
fn test_display_local_dependency_no_version() {
let dep = LockfileDependencyRef::local(
ResourceType::Snippet,
"snippets/commands/update-docstrings".to_string(),
None,
);
assert_eq!(dep.to_string(), "snippet:snippets/commands/update-docstrings");
}
#[test]
fn test_display_git_dependency() {
let dep = LockfileDependencyRef::git(
"agpm-resources".to_string(),
ResourceType::Snippet,
"snippets/commands/update-docstrings".to_string(),
Some("v0.0.1".to_string()),
);
assert_eq!(
dep.to_string(),
"agpm-resources/snippet:snippets/commands/update-docstrings@v0.0.1"
);
}
#[test]
fn test_display_git_dependency_no_version() {
let dep = LockfileDependencyRef::git(
"agpm-resources".to_string(),
ResourceType::Snippet,
"snippets/commands/update-docstrings".to_string(),
None,
);
assert_eq!(dep.to_string(), "agpm-resources/snippet:snippets/commands/update-docstrings");
}
#[test]
fn test_roundtrip_conversion() {
let original = "agpm-resources/snippet:snippets/commands/update-docstrings@v0.0.1";
let parsed = LockfileDependencyRef::from_str(original).unwrap();
assert_eq!(parsed.to_string(), original);
}
}