Skip to main content

facto_core/lockfiles/
models.rs

1//! Data models for the lockfile parsing module.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7/// Identifier for a recognised lockfile format.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum LockfileFormat {
12    UvLock,
13    PoetryLock,
14    PdmLock,
15    PipfileLock,
16    NpmLock,
17    PnpmLock,
18    CargoLock,
19}
20
21impl LockfileFormat {
22    /// Return the registry identifier this lockfile format's deps belong to.
23    ///
24    /// Matches the registry IDs used by `RegistryManager::get_registry`.
25    pub fn registry(&self) -> &'static str {
26        match self {
27            LockfileFormat::UvLock
28            | LockfileFormat::PoetryLock
29            | LockfileFormat::PdmLock
30            | LockfileFormat::PipfileLock => "pypi",
31            LockfileFormat::NpmLock | LockfileFormat::PnpmLock => "npm",
32            LockfileFormat::CargoLock => "crates",
33        }
34    }
35}
36
37/// Dependency group as extracted from a lockfile.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39#[serde(tag = "kind", rename_all = "snake_case")]
40#[non_exhaustive]
41pub enum DepGroup {
42    Main,
43    Dev,
44    Build,
45    Optional { name: String },
46    Unknown,
47}
48
49/// A resolved dependency extracted from a lockfile.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct ResolvedDep {
52    pub name: String,
53    pub version: String,
54    pub registry: String,
55    pub group: DepGroup,
56}
57
58/// A lockfile dependency checked against its registry for the latest version.
59#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
60pub struct CheckedDep {
61    pub name: String,
62    pub current: String,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub latest: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub outdated: Option<bool>,
67    pub registry: String,
68    pub group: DepGroup,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub error: Option<String>,
71}
72
73/// A non-fatal warning emitted while parsing a lockfile.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75pub struct ParseWarning {
76    pub line: Option<usize>,
77    pub message: String,
78}
79
80/// Outcome of a successful parse. May still contain warnings.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
82pub struct ParseResult {
83    pub format: LockfileFormat,
84    pub deps: Vec<ResolvedDep>,
85    pub warnings: Vec<ParseWarning>,
86}
87
88/// Fatal error when a lockfile cannot be parsed at all.
89#[derive(Debug, Error, Clone, PartialEq, Eq)]
90pub enum ParseError {
91    #[error("unknown lockfile format: {0}")]
92    UnknownFormat(String),
93    #[error("invalid lockfile content: {0}")]
94    InvalidContent(String),
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_resolved_dep_roundtrip_json() {
103        let dep = ResolvedDep {
104            name: "fastapi".to_string(),
105            version: "0.115.8".to_string(),
106            registry: "pypi".to_string(),
107            group: DepGroup::Main,
108        };
109        let json = serde_json::to_string(&dep).unwrap();
110        assert!(json.contains("\"group\":{\"kind\":\"main\"}"));
111        let back: ResolvedDep = serde_json::from_str(&json).unwrap();
112        assert_eq!(dep, back);
113    }
114
115    #[test]
116    fn test_dep_group_optional_serializes_with_name() {
117        let group = DepGroup::Optional {
118            name: "test".to_string(),
119        };
120        let json = serde_json::to_string(&group).unwrap();
121        assert!(json.contains("\"name\":\"test\""));
122        assert!(json.contains("\"kind\":\"optional\""));
123        let back: DepGroup = serde_json::from_str(&json).unwrap();
124        assert_eq!(group, back);
125    }
126
127    #[test]
128    fn test_parse_result_empty_is_valid() {
129        let result = ParseResult {
130            format: LockfileFormat::UvLock,
131            deps: vec![],
132            warnings: vec![],
133        };
134        let json = serde_json::to_string(&result).unwrap();
135        assert!(json.contains("\"format\":\"uv_lock\""));
136    }
137
138    #[test]
139    fn test_registry_for_python_formats() {
140        assert_eq!(LockfileFormat::UvLock.registry(), "pypi");
141        assert_eq!(LockfileFormat::PoetryLock.registry(), "pypi");
142        assert_eq!(LockfileFormat::PdmLock.registry(), "pypi");
143        assert_eq!(LockfileFormat::PipfileLock.registry(), "pypi");
144    }
145
146    #[test]
147    fn test_registry_for_node_formats() {
148        assert_eq!(LockfileFormat::NpmLock.registry(), "npm");
149        assert_eq!(LockfileFormat::PnpmLock.registry(), "npm");
150    }
151
152    #[test]
153    fn test_registry_for_rust_format() {
154        assert_eq!(LockfileFormat::CargoLock.registry(), "crates");
155    }
156}