Skip to main content

quilt_rs/lineage/
package.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use multihash::Multihash;
5use serde::Deserialize;
6use serde::Serialize;
7use serde::de;
8use serde::ser;
9
10use crate::Error;
11use crate::Res;
12use crate::error::LineageError;
13use crate::lineage::status::UpstreamState;
14use quilt_uri::ManifestUri;
15
16fn multihash_to_str<S: ser::Serializer>(
17    hash: &Multihash<256>,
18    serializer: S,
19) -> Result<S::Ok, S::Error> {
20    let s = hex::encode(hash.to_bytes());
21    serializer.serialize_str(&s)
22}
23
24fn str_to_multihash<'de, D: de::Deserializer<'de>>(
25    deserializer: D,
26) -> Result<Multihash<256>, D::Error> {
27    let s = String::deserialize(deserializer)?;
28    let bytes = hex::decode(s).map_err(de::Error::custom)?;
29    Multihash::from_bytes(&bytes).map_err(de::Error::custom)
30}
31
32/// State of the file tracked in lineage
33#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34pub struct PathState {
35    /// Last "modified" date.
36    /// Last time it was installed or commited.
37    pub timestamp: chrono::DateTime<chrono::Utc>,
38    /// Last tracked hash.
39    /// We don't track files modifications in real time.
40    /// We calculate hash when we commit or install file.
41    #[serde(
42        serialize_with = "multihash_to_str",
43        deserialize_with = "str_to_multihash"
44    )]
45    pub hash: Multihash<256>,
46}
47
48/// A map of paths to their state
49///
50/// The key is the name of the path, and the value is the state of the path
51pub type LineagePaths = BTreeMap<PathBuf, PathState>;
52
53/// What is the latest commit and what are previous commits if present
54///
55/// TODO: migrate `hash` and `prev_hashes` to a `TopHash` newtype around
56/// `Multihash<256>` that validates the SHA-256 multicodec on
57/// construction (pairing with the existing `TopHasher` builder in
58/// `manifest::top_hasher`). A bare `Multihash<256>` is not enough:
59///
60/// - The on-disk format in `data.json` is hex-of-digest only (no
61///   multicodec prefix), so the multicodec must be re-attached on
62///   deserialization.
63/// - The codebase also uses CRC64 and SHA-256-chunked multihashes
64///   elsewhere, so any helper that strips the multicodec for
65///   serialization must guarantee SHA-256 — otherwise a wrong-codec
66///   multihash passes through silently and writes a corrupt hash.
67///
68/// Best done together with the adjacent `String` hashes
69/// (`PackageLineage::base_hash`, `latest_hash`, `ManifestUri::hash`);
70/// migrating only `CommitState` turns every comparison with those
71/// fields into a conversion boundary and leaves the type-safety win
72/// partial.
73#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
74pub struct CommitState {
75    /// When the last commit was done
76    pub timestamp: chrono::DateTime<chrono::Utc>,
77    /// What is the hash of the latest commit
78    pub hash: String,
79    /// What are the previous commit hashes
80    #[serde(default = "Vec::new")]
81    pub prev_hashes: Vec<String>,
82}
83
84/// Stores lineage (installation/modification history) of the package read from `data.json` file
85#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
86pub struct PackageLineage {
87    /// Local commits
88    pub commit: Option<CommitState>,
89    /// Where we installed this package from. `None` for local-only packages.
90    #[serde(default, rename = "remote", skip_serializing_if = "Option::is_none")]
91    pub remote_uri: Option<ManifestUri>,
92    // TODO: I don't understand yet how and why we use it
93    pub base_hash: String,
94    /// Latest tracked hash. In other words, what was the remote hash when we last checked.
95    /// It can be different from the `remote.hash`, because we can install not the latest package.
96    ///
97    /// TODO: pair with `checked_at: DateTime<Utc>`. The classifier treats
98    /// this as authoritative for `Behind`/`Diverged` with no notion of
99    /// staleness.
100    pub latest_hash: String,
101    /// Installed paths (or files in other words)
102    #[serde(default = "BTreeMap::new")]
103    pub paths: LineagePaths,
104}
105
106impl From<PackageLineage> for UpstreamState {
107    fn from(lineage: PackageLineage) -> Self {
108        let Some(remote) = lineage.remote_uri.as_ref() else {
109            return Self::Local;
110        };
111        // `set_remote` rejects empty buckets, so on a production-written
112        // `data.json` this branch is unreachable. It exists to keep
113        // hand-edited or test-fixture lineages from falling through to
114        // the `hash.is_empty()` arm below, which would mis-classify a
115        // bucket-less remote with a non-empty `latest_hash` as
116        // `Diverged`. Covered by
117        // `test_empty_bucket_is_local_even_with_latest_hash`.
118        if remote.bucket.is_empty() {
119            return Self::Local;
120        }
121        if remote.hash.is_empty() {
122            // `remote.hash` empty + `latest_hash` empty: truly first push,
123            // the remote bucket has no revision for this namespace yet.
124            // `remote.hash` empty + `latest_hash` non-empty: a teammate has
125            // already published under this namespace, so we are not really
126            // local — autosync must refuse to push and surface the conflict.
127            //
128            // Note: this classification deliberately ignores `origin`. A
129            // bucket-only (no-catalog) CLI push leaves `origin = None` but
130            // is otherwise a normal remote-tracking package. The autosync
131            // watcher applies its own `origin.is_none()` skip rule when
132            // deciding whether to act on a package; the state classifier
133            // here just reports what is.
134            return if lineage.latest_hash.is_empty() {
135                Self::Local
136            } else {
137                Self::Diverged
138            };
139        }
140        let behind = lineage.base_hash != lineage.latest_hash;
141        let ahead = lineage.base_hash != lineage.current_hash().unwrap_or_default();
142        match (ahead, behind) {
143            (false, false) => Self::UpToDate,
144            (false, true) => Self::Behind,
145            (true, false) => Self::Ahead,
146            (true, true) => Self::Diverged,
147        }
148    }
149}
150
151impl PackageLineage {
152    /// Returns the remote ManifestUri, or an error if this is a local-only package.
153    pub fn remote(&self) -> Res<&ManifestUri> {
154        self.remote_uri
155            .as_ref()
156            .ok_or(Error::Lineage(LineageError::NoRemote))
157    }
158
159    /// Returns a mutable reference to the remote ManifestUri,
160    /// or an error if this is a local-only package.
161    pub fn remote_mut(&mut self) -> Res<&mut ManifestUri> {
162        self.remote_uri
163            .as_mut()
164            .ok_or(Error::Lineage(LineageError::NoRemote))
165    }
166
167    pub fn from_remote(remote: ManifestUri, latest_hash: String) -> Self {
168        Self {
169            base_hash: remote.hash.clone(),
170            remote_uri: Some(remote),
171            latest_hash,
172            commit: None,
173            paths: BTreeMap::new(),
174        }
175    }
176
177    pub fn current_hash(&self) -> Option<&str> {
178        self.commit
179            .as_ref()
180            .map(|c| c.hash.as_str())
181            .or(self.remote_uri.as_ref().map(|r| r.hash.as_str()))
182            .or(if self.base_hash.is_empty() {
183                None
184            } else {
185                Some(self.base_hash.as_str())
186            })
187    }
188
189    pub fn update_latest(&mut self, manifest_uri: ManifestUri) {
190        let new_latest_hash = manifest_uri.hash;
191        self.latest_hash.clone_from(&new_latest_hash);
192        self.base_hash.clone_from(&new_latest_hash);
193    }
194}
195
196impl From<ManifestUri> for PackageLineage {
197    fn from(uri: ManifestUri) -> Self {
198        Self {
199            base_hash: uri.hash.clone(),
200            remote_uri: Some(uri.clone()),
201            latest_hash: uri.hash.clone(),
202            commit: None,
203            paths: BTreeMap::new(),
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_default_is_local() {
214        assert_eq!(
215            UpstreamState::from(PackageLineage::default()),
216            UpstreamState::Local
217        );
218    }
219
220    #[test]
221    fn test_remote_configured_but_never_pushed_is_local() {
222        let lineage = PackageLineage {
223            remote_uri: Some(ManifestUri {
224                hash: String::new(),
225                bucket: "test-bucket".to_string(),
226                namespace: ("foo", "bar").into(),
227                ..ManifestUri::default()
228            }),
229            ..PackageLineage::default()
230        };
231        assert_eq!(UpstreamState::from(lineage), UpstreamState::Local);
232    }
233
234    #[test]
235    fn test_empty_bucket_is_local_even_with_latest_hash() {
236        // `set_remote` rejects empty buckets at the only production
237        // write site, so this state should not occur from normal use.
238        // The guard exists for hand-edited `data.json` files: without
239        // it, the next arm would classify a bucket-less remote with a
240        // non-empty `latest_hash` as `Diverged`, which is nonsense (no
241        // real remote to be diverged from).
242        let lineage = PackageLineage {
243            remote_uri: Some(ManifestUri {
244                hash: String::new(),
245                bucket: String::new(),
246                namespace: ("foo", "bar").into(),
247                origin: Some("catalog.dev".parse().unwrap()),
248            }),
249            latest_hash: "abc".to_string(),
250            ..PackageLineage::default()
251        };
252        assert_eq!(UpstreamState::from(lineage), UpstreamState::Local);
253    }
254
255    #[test]
256    fn test_foreign_remote_with_latest_is_diverged() {
257        let lineage = PackageLineage {
258            remote_uri: Some(ManifestUri {
259                hash: String::new(),
260                bucket: "test-bucket".to_string(),
261                namespace: ("foo", "bar").into(),
262                origin: Some("catalog.dev".parse().unwrap()),
263            }),
264            latest_hash: "abc".to_string(),
265            ..PackageLineage::default()
266        };
267        assert_eq!(UpstreamState::from(lineage), UpstreamState::Diverged);
268    }
269
270    #[test]
271    fn test_remote_returns_no_remote_error() {
272        let lineage = PackageLineage::default();
273        assert!(matches!(
274            lineage.remote(),
275            Err(Error::Lineage(LineageError::NoRemote))
276        ));
277    }
278
279    #[test]
280    fn test_current_hash_without_remote() {
281        let lineage = PackageLineage::default();
282        assert_eq!(lineage.current_hash(), None);
283    }
284}