Skip to main content

sluice/domain/
record.rs

1use std::fmt;
2
3use super::document::Document;
4use super::uinfo::{parse_info_extension, parse_uinfo, Uinfo};
5use crate::error::ParseError;
6
7/// A classified Maven index document: either a structural record
8/// (descriptor, group lists) or an artifact add/remove with parsed coordinates.
9#[derive(Debug, Clone, PartialEq, Eq)]
10#[cfg_attr(feature = "serde", derive(serde::Serialize))]
11#[non_exhaustive]
12pub enum Record {
13    /// Index descriptor document (header metadata).
14    Descriptor,
15    /// `allGroups` index — the full list of group IDs.
16    AllGroups,
17    /// `rootGroups` index — top-level group name prefixes.
18    RootGroups,
19    /// Artifact add record with parsed coordinates.
20    ArtifactAdd(Uinfo),
21    /// Artifact removal record with parsed coordinates.
22    ArtifactRemove(Uinfo),
23    /// Document that did not match any known record shape.
24    Unknown,
25}
26
27impl fmt::Display for Record {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Record::Descriptor => f.write_str("descriptor"),
31            Record::AllGroups => f.write_str("allGroups"),
32            Record::RootGroups => f.write_str("rootGroups"),
33            Record::ArtifactAdd(u) => write!(f, "add {u}"),
34            Record::ArtifactRemove(u) => write!(f, "remove {u}"),
35            Record::Unknown => f.write_str("unknown"),
36        }
37    }
38}
39
40impl TryFrom<&Document> for Record {
41    type Error = ParseError;
42
43    /// Classify a document into a [`Record`].
44    ///
45    /// Rules, checked in priority order:
46    /// 1. `DESCRIPTOR` field → `Descriptor`
47    /// 2. `allGroups` field → `AllGroups`
48    /// 3. `rootGroups` field → `RootGroups`
49    /// 4. `u` field → `ArtifactAdd(parse_uinfo(u))`
50    /// 5. `del` field → `ArtifactRemove(parse_uinfo(del))`
51    /// 6. otherwise → `Unknown`
52    ///
53    /// # Errors
54    ///
55    /// Returns [`ParseError::MalformedUinfo`] when an add or remove record
56    /// contains a UINFO string that cannot be parsed. Structural documents
57    /// (descriptor, group lists) never fail.
58    fn try_from(doc: &Document) -> Result<Self, Self::Error> {
59        if doc.has("DESCRIPTOR") {
60            return Ok(Record::Descriptor);
61        }
62        if doc.has("allGroups") {
63            return Ok(Record::AllGroups);
64        }
65        if doc.has("rootGroups") {
66            return Ok(Record::RootGroups);
67        }
68        if let Some(raw) = doc.find("u") {
69            let mut uinfo = parse_uinfo(raw)?;
70            // MINDEXER-41: backfill extension from INFO field when UINFO has
71            // only 4 segments (pre-5.x indexes omit the extension segment).
72            if uinfo.extension.is_none() {
73                if let Some(info_raw) = doc.find("i") {
74                    uinfo.extension = parse_info_extension(info_raw);
75                }
76            }
77            return Ok(Record::ArtifactAdd(uinfo));
78        }
79        if let Some(raw) = doc.find("del") {
80            return Ok(Record::ArtifactRemove(parse_uinfo(raw)?));
81        }
82        Ok(Record::Unknown)
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::domain::field::Field;
90    use crate::domain::flags::FieldFlags;
91
92    fn field(name: &str, value: &str) -> Field {
93        Field {
94            flags: FieldFlags::new(0x07),
95            name: name.to_owned(),
96            value: value.to_owned(),
97        }
98    }
99
100    fn doc(fields: Vec<Field>) -> Document {
101        Document::new(fields)
102    }
103
104    #[test]
105    fn classifies_descriptor() {
106        let d = doc(vec![
107            field("DESCRIPTOR", "NexusIndex"),
108            field("IDXINFO", "..."),
109        ]);
110        assert_eq!(Record::try_from(&d).unwrap(), Record::Descriptor);
111    }
112
113    #[test]
114    fn classifies_all_groups() {
115        let d = doc(vec![
116            field("allGroups", "ignored"),
117            field("allGroupsList", "a|b|c"),
118        ]);
119        assert_eq!(Record::try_from(&d).unwrap(), Record::AllGroups);
120    }
121
122    #[test]
123    fn classifies_root_groups() {
124        let d = doc(vec![
125            field("rootGroups", "x"),
126            field("rootGroupsList", "a|b"),
127        ]);
128        assert_eq!(Record::try_from(&d).unwrap(), Record::RootGroups);
129    }
130
131    #[test]
132    fn classifies_add() {
133        let d = doc(vec![field("u", "org.example|lib|1.0|NA|jar")]);
134        let Record::ArtifactAdd(u) = Record::try_from(&d).unwrap() else {
135            panic!("expected Add");
136        };
137        assert_eq!(u.group_id, "org.example");
138    }
139
140    #[test]
141    fn classifies_remove() {
142        let d = doc(vec![field("del", "org.example|lib|1.0|NA|jar")]);
143        let Record::ArtifactRemove(u) = Record::try_from(&d).unwrap() else {
144            panic!("expected Remove");
145        };
146        assert_eq!(u.artifact_id, "lib");
147    }
148
149    #[test]
150    fn unknown_when_no_recognisable_field() {
151        let d = doc(vec![field("foo", "bar")]);
152        assert_eq!(Record::try_from(&d).unwrap(), Record::Unknown);
153    }
154
155    #[test]
156    fn descriptor_beats_u_field_priority() {
157        let d = doc(vec![field("DESCRIPTOR", "x"), field("u", "a|b|c|NA|jar")]);
158        assert_eq!(Record::try_from(&d).unwrap(), Record::Descriptor);
159    }
160
161    #[test]
162    fn all_groups_beats_u_field_priority() {
163        let d = doc(vec![field("allGroups", "x"), field("u", "a|b|c|NA|jar")]);
164        assert_eq!(Record::try_from(&d).unwrap(), Record::AllGroups);
165    }
166
167    #[test]
168    fn malformed_uinfo_on_add_bubbles_up() {
169        let d = doc(vec![field("u", "not-enough-pipes")]);
170        assert!(matches!(
171            Record::try_from(&d),
172            Err(ParseError::MalformedUinfo(_))
173        ));
174    }
175
176    #[test]
177    fn four_segment_uinfo_backfills_extension_from_info() {
178        let d = doc(vec![
179            field("u", "org.example|lib|1.0|NA"),
180            field("i", "jar|1700000000000|123|0|0|0|jar"),
181        ]);
182        let Record::ArtifactAdd(u) = Record::try_from(&d).unwrap() else {
183            panic!("expected ArtifactAdd");
184        };
185        assert_eq!(u.extension.as_deref(), Some("jar"));
186    }
187
188    #[test]
189    fn five_segment_uinfo_ignores_info_extension() {
190        let d = doc(vec![
191            field("u", "org.example|lib|1.0|NA|war"),
192            field("i", "jar|1700000000000|123|0|0|0|jar"),
193        ]);
194        let Record::ArtifactAdd(u) = Record::try_from(&d).unwrap() else {
195            panic!("expected ArtifactAdd");
196        };
197        assert_eq!(u.extension.as_deref(), Some("war"));
198    }
199
200    #[test]
201    fn display_descriptor() {
202        assert_eq!(Record::Descriptor.to_string(), "descriptor");
203    }
204
205    #[test]
206    fn display_unknown() {
207        assert_eq!(Record::Unknown.to_string(), "unknown");
208    }
209
210    #[test]
211    fn display_artifact_add_uses_uinfo_display() {
212        let d = doc(vec![field("u", "org.example|lib|1.0|NA|jar")]);
213        let r = Record::try_from(&d).unwrap();
214        assert_eq!(r.to_string(), "add org.example:lib:1.0:jar");
215    }
216
217    #[test]
218    fn display_artifact_remove_uses_uinfo_display() {
219        let d = doc(vec![field("del", "org.example|lib|1.0|sources|jar")]);
220        let r = Record::try_from(&d).unwrap();
221        assert_eq!(r.to_string(), "remove org.example:lib:1.0:sources:jar");
222    }
223
224    #[test]
225    fn four_segment_uinfo_without_info_stays_none() {
226        let d = doc(vec![field("u", "org.example|lib|1.0|NA")]);
227        let Record::ArtifactAdd(u) = Record::try_from(&d).unwrap() else {
228            panic!("expected ArtifactAdd");
229        };
230        assert_eq!(u.extension, None);
231    }
232}