1use 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#[derive(Clone, Debug)]
21pub struct ImportUpdate {
22 pub from: semver::Version,
23 pub to: semver::Version,
24 pub diff: Diff,
26 pub upstream_from: Option<String>,
28 pub upstream_to: Option<String>,
29}
30
31pub 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 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
76pub 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
85pub 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
102pub 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
111pub 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
126pub 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 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 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 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 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 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 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 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 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}