Skip to main content

logoxide_import/
update.rs

1/*!
2Updating an import: the same import made again from the publisher's newer download, compared
3with the one a project has ([`compare_import`]), replaced ([`apply_import`]), and skipped when
4the publisher's own version says it's current ([`import_is_current`])
5*/
6
7use std::sync::Arc;
8
9use logoxide::{
10    LogoxideError, Result,
11    catalog::CatalogEntry,
12    manifest::{MANIFEST, Manifest, Package as Info},
13    package::{Features, Package},
14    reader::TextReader,
15    storage::{MemoryStorage, Storage},
16    summary::Diff,
17};
18
19/// What updating an imported package changes ([`compare_import`])
20#[derive(Clone, Debug)]
21pub struct ImportUpdate {
22    pub from: semver::Version,
23    pub to: semver::Version,
24    /// Its verses' changes (reworded, added, removed, renumbered), and its titles'
25    pub diff: Diff,
26    /// The publisher's versions before and after (`[package.upstream] version`), as given
27    pub upstream_from: Option<String>,
28    pub upstream_to: Option<String>,
29}
30
31/**
32How a package made again from its publisher's download (`fresh`, as [`crate::build`] writes it)
33differs from the one a project has (`current`): none when their declared files are the same. The
34fresh package's version is the current one raised by what changed
35([`logoxide::summary::next_version`]: rewording is a minor change, removing a verse a major one, a
36new copyright notice or publisher's version alone a patch), written in its `logoxide.toml`, so one
37version never names two texts (links record the version they were made in) and a version says
38what an update can break, whatever the publisher numbers it. Both texts are read by `reader`
39*/
40pub fn compare_import(
41    current: Arc<dyn Storage>,
42    fresh: &Arc<MemoryStorage>,
43    reader: &dyn TextReader,
44) -> Result<Option<ImportUpdate>> {
45    use logoxide::summary::{diff_summaries, next_version, summarize};
46    let fresh_storage: Arc<dyn Storage> = fresh.clone();
47    let info_of = |storage: &dyn Storage| -> Result<Info> {
48        let manifest = Manifest::parse(&storage.read_string(MANIFEST)?)?;
49        (manifest.package).ok_or_else(|| LogoxideError::Import(String::from("not a package")))
50    };
51    let had = info_of(current.as_ref())?;
52    let made = info_of(fresh.as_ref())?;
53    // Compared at the current version, so the same download is the same package
54    set_version(fresh.as_ref(), &had.version)?;
55    if logoxide::package::checksum(current.as_ref())?
56        == logoxide::package::checksum(fresh.as_ref())?
57    {
58        return Ok(None);
59    }
60    let old = Package::open(current, Features::default())?;
61    let new = Package::open(fresh_storage, Features::default())?;
62    let (old_summary, old_verses) = summarize(&old, reader)?;
63    let (new_summary, new_verses) = summarize(&new, reader)?;
64    let diff = diff_summaries((&old_summary, &old_verses), (&new_summary, &new_verses));
65    let to = next_version(&had.version, diff.change());
66    set_version(fresh.as_ref(), &to)?;
67    Ok(Some(ImportUpdate {
68        from: had.version,
69        to,
70        diff,
71        upstream_from: had.upstream.and_then(|u| u.version),
72        upstream_to: made.upstream.and_then(|u| u.version),
73    }))
74}
75
76/// Sets a made package's version in its `logoxide.toml`
77pub fn set_version(storage: &dyn Storage, version: &semver::Version) -> Result<()> {
78    let text = storage.read_string(MANIFEST)?;
79    let mut doc: toml_edit::DocumentMut =
80        (text.parse()).map_err(|e: toml_edit::TomlError| LogoxideError::Import(e.to_string()))?;
81    doc["package"]["version"] = toml_edit::value(version.to_string());
82    storage.write(MANIFEST, doc.to_string().as_bytes())
83}
84
85/**
86Replaces an imported package's files (`current`) with a fresh import's: its declared files the
87fresh one doesn't have are removed, the fresh one's written. Files beside them that aren't the
88package's (a reader's annotations) stay. On disk, give it an
89[`logoxide::FsStorage::no_symlinks`] folder, so a link in the project can't have a file written
90or removed somewhere else
91*/
92pub fn apply_import(current: &dyn Storage, fresh: &dyn Storage) -> Result<()> {
93    for path in import_removals(current, fresh)? {
94        current.remove(&path)?;
95    }
96    for path in fresh.walk("")? {
97        current.write(&path, &fresh.read(&path)?)?;
98    }
99    Ok(())
100}
101
102/// The imported package's files a fresh import replaces by nothing: its declared files the fresh
103/// one lacks (what [`apply_import`] removes, for hosts that remove files themselves)
104pub fn import_removals(current: &dyn Storage, fresh: &dyn Storage) -> Result<Vec<String>> {
105    let new = fresh.walk("")?;
106    Ok((logoxide::package::declared_files(current)?.into_iter())
107        .filter(|path| !new.contains(path))
108        .collect())
109}
110
111/**
112Whether an import made from the publisher's version `upstream` (its `[package.upstream]
113version`) is as new as `entry` lists, so it needn't be downloaded again to know (only where the
114publisher's versions follow its updates:
115[`logoxide::catalog::CatalogSource::versions_track_updates`]). The package's own version is
116never compared: it's logoxide's, raised by updates
117*/
118pub fn import_is_current(entry: &CatalogEntry, upstream: Option<&str>) -> bool {
119    let (Some(listed), Some(installed)) = (entry.version.as_deref(), upstream) else {
120        return false;
121    };
122    entry.source.versions_track_updates()
123        && compare_publisher_versions(listed, installed).is_some_and(|o| o.is_le())
124}
125
126/**
127Two of a publisher's versions in order, when both are numbers between dots, dashes, or
128underscores (`2026-10-08` before `2026-11-01`, `3.1` before `3.10`; missing parts are 0); others
129only when they're the same
130*/
131pub fn compare_publisher_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
132    let parts = |v: &str| -> Option<Vec<u64>> {
133        (v.trim().split(['.', '-', '_']))
134            .map(|p| p.parse().ok())
135            .collect()
136    };
137    match (parts(a), parts(b)) {
138        (Some(mut a), Some(mut b)) => {
139            let len = a.len().max(b.len());
140            a.resize(len, 0);
141            b.resize(len, 0);
142            Some(a.cmp(&b))
143        }
144        _ => (a.trim() == b.trim()).then_some(std::cmp::Ordering::Equal),
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::tests::zip;
152    use logoxide::reader::DefaultReader;
153
154    #[test]
155    fn updates_an_import() {
156        let csv = "\"languageCode\",\"translationId\",\"languageNameInEnglish\",\"title\",\"description\",\"Redistributable\",\"Copyright\",\"UpdateDate\",\"textDirection\",\"downloadable\",\"shortTitle\"\n\"eng\",\"eng-asv\",\"English\",\"American Standard Version (1901)\",\"\",\"True\",\"public domain\",\"2026-10-08\",\"ltr\",\"True\",\"ASV\"\n";
157        let catalog = crate::ebible::catalog(csv).unwrap();
158        let entry = &catalog.entries[0];
159        let book = |text: &str| format!("\\id JHN\n\\c 3\n\\p\n\\v 16 {text}\n");
160        let made = |files: &[(&str, &str)]| {
161            let storage = Arc::new(MemoryStorage::new());
162            crate::ebible::import(entry, &zip(files), storage.as_ref()).unwrap();
163            storage
164        };
165        let current = made(&[
166            ("73-JHNeng-asv.usfm", &book("For God so loved the world.")),
167            (
168                "74-ACTeng-asv.usfm",
169                "\\id ACT\n\\c 1\n\\p\n\\v 1 The former treatise.\n",
170            ),
171        ]);
172        // An imported package names its catalog entry by its upstream
173        let package = Package::open(current.clone(), Features::default()).unwrap();
174        let upstream = package.info().upstream.clone().unwrap();
175        assert_eq!(catalog.find_upstream(&upstream), Some(entry));
176        assert_eq!(crate::upstream(entry), Some(upstream));
177        assert!(entry.source.versions_track_updates());
178        // By its URL alone too (a package made by hand from the download)
179        let by_url = logoxide::manifest::Upstream {
180            import: None,
181            ..package.info().upstream.clone().unwrap()
182        };
183        assert_eq!(catalog.find_upstream(&by_url), Some(entry));
184
185        // The same download: nothing to update
186        let same = made(&[
187            ("73-JHNeng-asv.usfm", &book("For God so loved the world.")),
188            (
189                "74-ACTeng-asv.usfm",
190                "\\id ACT\n\\c 1\n\\p\n\\v 1 The former treatise.\n",
191            ),
192        ]);
193        assert!(
194            compare_import(current.clone(), &same, &DefaultReader)
195                .unwrap()
196                .is_none()
197        );
198
199        // Reworded at the same publisher's version (as fetch.bible's change): the version is
200        // raised by the change, a book removed being a major one
201        let fresh = made(&[(
202            "73-JHNeng-asv.usfm",
203            &book("For God so much loved the world."),
204        )]);
205        let update = compare_import(current.clone(), &fresh, &DefaultReader)
206            .unwrap()
207            .unwrap();
208        assert_eq!(
209            (update.from.to_string(), update.to.to_string()),
210            ("1.0.0".into(), "2.0.0".into())
211        );
212        assert_eq!(
213            (
214                update.upstream_from.as_deref(),
215                update.upstream_to.as_deref()
216            ),
217            (Some("2026-10-08"), Some("2026-10-08"))
218        );
219        assert_eq!(
220            (update.diff.changed.len(), update.diff.removed.len()),
221            (1, 1)
222        );
223        // A reader's note beside the package stays; a book the publisher dropped goes
224        current.write("JHN notes.md", b"mine").unwrap();
225        apply_import(current.as_ref(), fresh.as_ref()).unwrap();
226        assert_eq!(
227            current.walk("").unwrap(),
228            ["JHN notes.md", "logoxide.toml", "usfm/73-JHNeng-asv.usfm"]
229        );
230        let package = Package::open(current.clone(), Features::default()).unwrap();
231        assert_eq!(package.info().version.to_string(), "2.0.0");
232        // Made again from the same download: current at its raised version, not raised again
233        let again = made(&[(
234            "73-JHNeng-asv.usfm",
235            &book("For God so much loved the world."),
236        )]);
237        assert!(
238            compare_import(current.clone(), &again, &DefaultReader)
239                .unwrap()
240                .is_none()
241        );
242        // The raised version doesn't hide the publisher's next release: the publisher's
243        // versions are compared, never the package's
244        let upstream = package.info().upstream.clone().unwrap();
245        assert!(import_is_current(entry, upstream.version.as_deref()));
246        let next = crate::ebible::catalog(&csv.replace("2026-10-08", "2026-11-01")).unwrap();
247        let next = &next.entries[0];
248        assert_eq!(next.version.as_deref(), Some("2026-11-01"));
249        assert!(!import_is_current(next, upstream.version.as_deref()));
250        assert!(!import_is_current(next, None));
251        // Its download, with the same text: a patch, which records the publisher's version
252        let storage = Arc::new(MemoryStorage::new());
253        let same_text = zip(&[(
254            "73-JHNeng-asv.usfm",
255            &book("For God so much loved the world."),
256        )]);
257        crate::ebible::import(next, &same_text, storage.as_ref()).unwrap();
258        let update = compare_import(current.clone(), &storage, &DefaultReader)
259            .unwrap()
260            .unwrap();
261        assert_eq!(
262            (
263                update.to.to_string(),
264                update.upstream_from.as_deref(),
265                update.upstream_to.as_deref()
266            ),
267            ("2.0.1".into(), Some("2026-10-08"), Some("2026-11-01"))
268        );
269        apply_import(current.as_ref(), storage.as_ref()).unwrap();
270        let package = Package::open(current.clone(), Features::default()).unwrap();
271        let upstream = package.info().upstream.clone().unwrap();
272        assert!(import_is_current(next, upstream.version.as_deref()));
273    }
274
275    #[test]
276    fn publishers_versions_are_compared_as_numbers() {
277        use std::cmp::Ordering::*;
278        assert_eq!(
279            compare_publisher_versions("2026-10-08", "2026-11-01"),
280            Some(Less)
281        );
282        assert_eq!(compare_publisher_versions("3.10", "3.9"), Some(Greater));
283        assert_eq!(compare_publisher_versions("3.1", "3.1.0"), Some(Equal));
284        assert_eq!(compare_publisher_versions("1.0b", "1.0b"), Some(Equal));
285        assert_eq!(compare_publisher_versions("1.0b", "1.0c"), None);
286    }
287}