diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c8b5925..9480005 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,9 @@ jobs: run: cargo check - name: Format - run: cargo fmt --all -- --check + run: | + cargo fmt --all -- --check + cargo clippy - name: Build Debug run: cargo build --verbose diff --git a/Cargo.toml b/Cargo.toml index c81703f..53e585f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,10 +32,13 @@ tokio = { version = "1", features = ["full"] } itertools = "0.10.5" once_cell = "1.16.0" thiserror = "1" +fastrand = "2" +machine-uid = "0.5.1" [dev-dependencies] tempdir = "0.3.7" uuid = { version = "1.1.2", features = ["v4"] } +rstest = '0.18.2' [build-dependencies] flate2 = "1.0.24" @@ -44,3 +47,4 @@ libloading = "0.7.3" tar = "0.4.38" target-lexicon = "0.12.4" ureq = "2.4.0" +ring = "=0.17.5" diff --git a/build.rs b/build.rs index 1b1e10b..7e86917 100644 --- a/build.rs +++ b/build.rs @@ -12,6 +12,7 @@ fn main() { let target = Triple::from_str(t.as_str()).unwrap(); let out_dir = env::var_os("OUT_DIR").unwrap(); + println!("{}", target.operating_system); // Avoid duplicate download if !fs_extra::dir::ls(&out_dir, &HashSet::new()) .unwrap() @@ -41,10 +42,11 @@ fn main() { } OperatingSystem::Ios => name.push("ios"), OperatingSystem::MacOSX { - major: 11, - minor: 0, - patch: 0, + major: _, + minor: _, + patch: _, } => name.push("mac"), + OperatingSystem::Darwin => name.push("mac"), _ => {} } @@ -57,7 +59,7 @@ fn main() { } dbg!(&name); - let filename = name.join("-").to_string(); + let filename = name.join("-"); let url = format!( "https://github.com/bblanchon/pdfium-binaries/releases/download/chromium/{}/{}.tgz", PDFIUM_VERSION, filename @@ -81,10 +83,11 @@ fn main() { .unwrap(), OperatingSystem::Ios | OperatingSystem::MacOSX { - major: 11, - minor: 0, - patch: 0, - } => fs_extra::file::move_file( + major: _, + minor: _, + patch: _, + } + | OperatingSystem::Darwin => fs_extra::file::move_file( PathBuf::from(&out_dir) .join("bin") .join("libpdfium.dylib"), diff --git a/src/atomic/file.rs b/src/atomic/file.rs new file mode 100644 index 0000000..1fc4ad7 --- /dev/null +++ b/src/atomic/file.rs @@ -0,0 +1,370 @@ +use std::fs::{self, File}; +use std::io::{Error, ErrorKind, Read, Result}; +#[cfg(target_os = "unix")] +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +const MAX_VERSION_FILES: usize = 10; + +pub struct TmpFile { + file: File, + path: PathBuf, +} + +impl TmpFile { + pub fn create_in(temp_dir: impl AsRef) -> Result { + let filename: String = std::iter::repeat_with(fastrand::alphanumeric) + .take(10) + .collect(); + let path = temp_dir.as_ref().join(filename); + let file = std::fs::File::create(&path)?; + Ok(Self { file, path }) + } +} + +impl std::io::Read for &TmpFile { + fn read(&mut self, buf: &mut [u8]) -> Result { + (&self.file).read(buf) + } +} + +impl std::io::Write for &TmpFile { + fn write(&mut self, buf: &[u8]) -> Result { + (&self.file).write(buf) + } + + fn flush(&mut self) -> Result<()> { + (&self.file).flush() + } +} + +impl Drop for TmpFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +#[derive(Clone)] +pub struct ReadOnlyFile { + version: usize, + path: PathBuf, +} + +/// This struct is the only way to read the file. Both path and version are private +impl ReadOnlyFile { + /// Open the underlying file, which can be read from but not written to. + /// May return `Ok(None)`, which means that no version + /// of the`AtomicFile` has been created yet. + pub fn open(&self) -> Result> { + if self.version != 0 { + Ok(Some(File::open(&self.path)?)) + } else { + Ok(None) + } + } + + pub fn read_to_string(&self) -> Result { + match self.open() { + Ok(None) => Err(Error::new(ErrorKind::NotFound, "File not found")), + Ok(Some(mut file)) => { + let mut buff = String::new(); + file.read_to_string(&mut buff)?; + Ok(buff) + } + Err(e) => Err(e), + } + } + + pub fn read_content(&self) -> Result> { + match self.open() { + Ok(None) => Err(Error::new(ErrorKind::NotFound, "File not found")), + Err(e) => Err(e), + Ok(Some(mut file)) => { + let mut buf = vec![]; + file.read_to_end(&mut buf)?; + Ok(buf) + } + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct AtomicFile { + directory: PathBuf, + prefix: String, +} + +fn parse_version(filename: Option<&str>) -> Option { + let (_, version) = filename?.rsplit_once('.')?; + version.parse().ok() +} + +impl AtomicFile { + pub fn new(path: impl Into) -> crate::Result { + let directory = path.into(); + // This UID must be treated as confidential information. + // Depending on network transport used to sync the files (if any), + // it can leak to an unauthorized party. + let machine_id = machine_uid::get()?; + std::fs::create_dir_all(&directory)?; + let filename: &str = match directory.file_name() { + Some(name) => name.to_str().unwrap(), + None => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "`path` must specify a directory name", + ))?, + }; + let prefix = format!("{}_{}.", filename, machine_id); + Ok(Self { directory, prefix }) + } + + /// Return the latest version together with vector of the + /// files matching this version. Multiple files for the same version + /// can appear due to usage of file syncronization. Different devices + /// can create same version simultaneously. + fn latest_version(&self) -> Result<(usize, Vec)> { + let files_iterator = fs::read_dir(&self.directory)?.flatten(); + let (files, version) = files_iterator.into_iter().fold( + (vec![], 0), + |(mut files, mut current_max_version), entry| { + let filename = entry.file_name(); + if let Some(version) = parse_version(filename.to_str()) { + // It's possible to have same version for two files coming from different machines + // Add this files to the result + if version >= current_max_version { + let read_only = ReadOnlyFile { + version, + path: entry.path(), + }; + files.push(read_only); + current_max_version = version; + } + } + (files, current_max_version) + }, + ); + let files = files + .into_iter() + .filter_map(|file| { + let file_version = parse_version(file.path.to_str())?; + if file_version == version { + Some(file) + } else { + None + } + }) + .collect(); + Ok((version, files)) + } + + fn path(&self, version: usize) -> PathBuf { + self.directory + .join(format!("{}{version}", self.prefix)) + } + + pub fn load(&self) -> Result { + let (version, mut files) = self.latest_version()?; + let file = match files.len() { + 0 => ReadOnlyFile { + version, + path: self.path(version), + }, + 1 => files.remove(0), + _ => { + log::warn!( + "There is multiple files with the version {version}" + ); + files + .into_iter() + .find(|file| { + if let Some(path) = file.path.to_str() { + path.contains(&self.prefix) + } else { + false + } + }) + .ok_or_else(|| { + Error::new( + ErrorKind::NotFound, + "File not found with correct version", + ) + })? + } + }; + Ok(file) + } + + pub fn make_temp(&self) -> Result { + TmpFile::create_in(&self.directory) + } + + /// Replace the contents of the file with the contents of `new` if the + /// latest version is the same as `current`. + /// + /// # Errors + /// If `io::ErrorKind::AlreadyExists` is returned, it means that the latest + /// version was not the same as `current` and the operation must be retried + /// with a fresher version of the file. Any other I/O error is forwarded as + /// well. + pub fn compare_and_swap( + &self, + current: &ReadOnlyFile, + new: TmpFile, + ) -> Result<()> { + let new_path = self.path(current.version + 1); + (new.file).sync_data()?; + // Just to check if current.version is still the latest_version + let (latest_version, _) = self.latest_version()?; + if latest_version > current.version { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "the `current` file is not the latest version", + )); + } + // May return `EEXIST`. + let res = std::fs::hard_link(&new.path, new_path); + if let Err(err) = res { + #[cfg(target_os = "unix")] + // From open(2) manual page: + // + // "[...] create a unique file on the same filesystem (e.g., + // incorporating hostname and PID), and use link(2) to make a link + // to the lockfile. If link(2) returns 0, the lock is successful. + // Otherwise, use stat(2) on the unique file to check if its link + // count has increased to 2, in which case the lock is also + // succesful." + if new.path.metadata()?.nlink() != 2 { + Err(err)?; + } + #[cfg(not(target_os = "unix"))] + Err(err)?; + } + + let number_of_removed = self.prune_old_versions(latest_version); + log::debug!("pruned {} old files", number_of_removed); + Ok(()) + } + + /// Return the number of files deleted + fn prune_old_versions(&self, version: usize) -> usize { + let mut deleted = 0; + if let Ok(iterator) = fs::read_dir(&self.directory) { + for entry in iterator.flatten() { + if let Some(file_version) = + parse_version(entry.file_name().to_str()) + { + if file_version + MAX_VERSION_FILES - 1 <= version + && fs::remove_file(entry.path()).is_ok() + { + deleted += 1; + } + } + } + } + deleted + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::io::Write; + use tempdir::TempDir; + + #[test] + fn delete_old_files() { + let dir = TempDir::new("max_files").unwrap(); + let root = dir.path(); + let file = AtomicFile::new(root).unwrap(); + let number_of_version = 20; + assert!(number_of_version > MAX_VERSION_FILES); + for i in 0..number_of_version { + let temp = file.make_temp().unwrap(); + let current = file.load().unwrap(); + let content = format!("Version {}", i + 1); + (&temp).write_all(content.as_bytes()).unwrap(); + file.compare_and_swap(¤t, temp).unwrap(); + } + + // Check the number of files + let version_files = fs::read_dir(root).unwrap().count(); + assert_eq!(version_files, MAX_VERSION_FILES); + } + + #[test] + fn multiple_version_files() { + let dir = TempDir::new("multiple_version").unwrap(); + let root = dir.path(); + + let file = AtomicFile::new(root).unwrap(); + let temp = file.make_temp().unwrap(); + let current = file.load().unwrap(); + let content_local = "Locally created content".to_string(); + (&temp) + .write_all(content_local.as_bytes()) + .unwrap(); + file.compare_and_swap(¤t, temp).unwrap(); + + // Other machine file (renamed on purpose to validate test) + let current = file.load().unwrap(); + let content_remote = "Content created on remote machine".to_string(); + let temp = file.make_temp().unwrap(); + (&temp) + .write_all(content_remote.as_bytes()) + .unwrap(); + file.compare_and_swap(¤t, temp).unwrap(); + + let version_2_path = file.path(2); + let rename_path = + root.join(format!("{}_cellphoneId.1", root.display())); + fs::rename(version_2_path, rename_path).unwrap(); + + // We should take content from current machine + let current = file.load().unwrap(); + let content = current.read_to_string().unwrap(); + assert_eq!(content, content_local); + } + + #[rstest] + #[case(3, &[1, 3], "case_1")] + #[case(5, &[2, 4], "case_2")] + #[case(10, &[3, 5, 7, 9, 10], "case_3")] + #[case(15, &[5, 14, 15], "case_4")] + fn latest_version( + #[case] versions: usize, + #[case] cellphone_versions: &[usize], + #[case] temp_name: &str, + ) { + // Create the files without atmic to handles files names + let dir = TempDir::new(temp_name).unwrap(); + let root = dir.path(); + let current_machine = machine_uid::get().unwrap(); + let file = AtomicFile::new(root).unwrap(); + let prefix = &file.prefix; + for version in 0..versions { + let file_path = root.join(format!("{}{}", prefix, version + 1)); + let mut file = fs::File::create(file_path).unwrap(); + let content = + format!("Version {} on {current_machine}", version + 1); + file.write_all(content.as_bytes()).unwrap(); + } + // Write other machine files + let mut path = prefix.split('_'); + let path = path.next().unwrap(); + for cellphone_version in cellphone_versions { + let file_path = + root.join(format!("{path}_cellphone.{cellphone_version}")); + let mut file = fs::File::create(file_path).unwrap(); + let content = format!("Version {cellphone_version} on cellphone"); + file.write_all(content.as_bytes()).unwrap(); + } + assert_eq!(file.latest_version().unwrap().0, versions); + let latest = file.load().unwrap(); + let latest_content = latest.read_to_string().unwrap(); + assert_eq!( + latest_content, + format!("Version {} on {current_machine}", versions) + ); + } +} diff --git a/src/atomic/mod.rs b/src/atomic/mod.rs new file mode 100644 index 0000000..6d1a9ad --- /dev/null +++ b/src/atomic/mod.rs @@ -0,0 +1,130 @@ +mod file; + +use serde::{de::DeserializeOwned, Serialize}; +use std::io::{Read, Result, Write}; + +pub use file::AtomicFile; + +pub fn modify( + atomic_file: &AtomicFile, + mut operator: impl FnMut(&[u8]) -> Vec, +) -> Result<()> { + let mut buf = vec![]; + loop { + let latest = atomic_file.load()?; + buf.clear(); + if let Some(mut file) = latest.open()? { + file.read_to_end(&mut buf)?; + } + let data = operator(&buf); + let tmp = atomic_file.make_temp()?; + (&tmp).write_all(&data)?; + (&tmp).flush()?; + match atomic_file.compare_and_swap(&latest, tmp) { + Ok(()) => return Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + continue + } + Err(err) => return Err(err), + } + } +} + +pub fn modify_json( + atomic_file: &AtomicFile, + mut operator: impl FnMut(&mut Option), +) -> Result<()> { + loop { + let latest = atomic_file.load()?; + let mut val = None; + if let Some(file) = latest.open()? { + val = Some(serde_json::from_reader(std::io::BufReader::new(file))?); + } + operator(&mut val); + let tmp = atomic_file.make_temp()?; + let mut writer = std::io::BufWriter::new(&tmp); + serde_json::to_writer(&mut writer, &val)?; + writer.flush()?; + drop(writer); + match atomic_file.compare_and_swap(&latest, tmp) { + Ok(()) => return Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + continue + } + Err(err) => return Err(err), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempdir::TempDir; + + #[test] + fn failed_to_write_simultaneously() { + let dir = TempDir::new("writing_test").unwrap(); + let root = dir.path(); + let shared_file = std::sync::Arc::new(AtomicFile::new(root).unwrap()); + let mut handles = Vec::with_capacity(5); + for i in 0..5 { + let file = shared_file.clone(); + let handle = std::thread::spawn(move || { + let temp = file.make_temp().unwrap(); + let current = file.load().unwrap(); + let content = format!("Content from thread {i}!"); + (&temp).write_all(content.as_bytes()).unwrap(); + // In case slow computer ensure each thread are running in the same time + std::thread::sleep(std::time::Duration::from_millis(300)); + file.compare_and_swap(¤t, temp) + }); + handles.push(handle); + } + let results = handles + .into_iter() + .map(|h| h.join().unwrap()) + .collect::>(); + // Ensure only one thread has succeed to write + let success = results.iter().fold(0, |mut acc, r| { + if r.is_ok() { + acc += 1; + } + acc + }); + assert_eq!(success, 1); + } + + #[test] + fn multiple_writes_detected() { + let dir = TempDir::new("simultaneous_writes").unwrap(); + let root = dir.path(); + let shared_file = std::sync::Arc::new(AtomicFile::new(root).unwrap()); + let thread_number = 10; + assert!(thread_number > 3); + // Need to have less than 255 thread to store thread number as byte directly + assert!(thread_number < 256); + let mut handles = Vec::with_capacity(thread_number); + for i in 0..thread_number { + let file = shared_file.clone(); + let handle = std::thread::spawn(move || { + modify(&file, |data| { + let mut data = data.to_vec(); + data.push(i.try_into().unwrap()); + data + }) + }); + handles.push(handle); + } + handles.into_iter().for_each(|handle| { + handle.join().unwrap().unwrap(); + }); + // Last content + let last_file = shared_file.load().unwrap(); + let last_content = last_file.read_content().unwrap(); + for i in 0..thread_number { + let as_byte = i.try_into().unwrap(); + assert!(last_content.contains(&as_byte)); + } + } +} diff --git a/src/errors.rs b/src/errors.rs index 539cd77..813f97b 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -31,3 +31,21 @@ impl From for ArklibError { Self::Parse } } + +impl From for ArklibError { + fn from(_: serde_json::Error) -> Self { + Self::Parse + } +} + +impl From for ArklibError { + fn from(_: url::ParseError) -> Self { + Self::Parse + } +} + +impl From> for ArklibError { + fn from(e: Box) -> Self { + Self::Other(anyhow::anyhow!(e.to_string())) + } +} diff --git a/src/id.rs b/src/id.rs index 564e1cd..72ffa80 100644 --- a/src/id.rs +++ b/src/id.rs @@ -3,11 +3,11 @@ use crc32fast::Hasher; use log; use serde::{Deserialize, Serialize}; use std::fmt::{self, Display, Formatter}; +use std::fs; use std::io::Read; use std::io::{BufRead, BufReader}; use std::path::Path; use std::str::FromStr; -use std::{fs, num::TryFromIntError}; use crate::{ArklibError, Result}; @@ -99,7 +99,7 @@ impl ResourceId { })?; } - let crc32: u32 = hasher.finalize().into(); + let crc32: u32 = hasher.finalize(); log::trace!("[compute] {} bytes has been read", bytes_read); log::trace!("[compute] checksum: {:#02x}", crc32); assert_eq!(std::convert::Into::::into(bytes_read), data_size); @@ -120,14 +120,15 @@ mod tests { fn compute_id_test() { let file_path = Path::new("./tests/lena.jpg"); let data_size = fs::metadata(file_path) - .expect(&format!( - "Could not open image test file_path.{}", - file_path.display() - )) + .unwrap_or_else(|_| { + panic!( + "Could not open image test file_path.{}", + file_path.display() + ) + }) .len(); - let id1 = ResourceId::compute(data_size.try_into().unwrap(), file_path) - .unwrap(); + let id1 = ResourceId::compute(data_size, file_path).unwrap(); assert_eq!(id1.crc32, 0x342a3d4a); assert_eq!(id1.data_size, 128760); diff --git a/src/index.rs b/src/index.rs index c10b721..a950acc 100644 --- a/src/index.rs +++ b/src/index.rs @@ -1,4 +1,5 @@ use anyhow::anyhow; +use canonical_path::{CanonicalPath, CanonicalPathBuf}; use itertools::Itertools; use std::collections::{HashMap, HashSet}; use std::fs::{self, File, Metadata}; @@ -7,8 +8,6 @@ use std::ops::Add; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use canonical_path::{CanonicalPath, CanonicalPathBuf}; use walkdir::{DirEntry, WalkDir}; use log; @@ -83,34 +82,32 @@ impl ResourceIndex { }; // We should not return early in case of missing files - for line in BufReader::new(file).lines() { - if let Ok(entry) = line { - let mut parts = entry.split(' '); - - let modified = { - let str = parts.next().ok_or(ArklibError::Parse)?; - UNIX_EPOCH.add(Duration::from_millis( - str.parse().map_err(|_| ArklibError::Parse)?, - )) - }; - - let id = { - let str = parts.next().ok_or(ArklibError::Parse)?; - ResourceId::from_str(str)? - }; - - let path: String = - itertools::Itertools::intersperse(parts, " ").collect(); - let path: PathBuf = root_path.join(Path::new(&path)); - match CanonicalPathBuf::canonicalize(&path) { - Ok(path) => { - log::trace!("[load] {} -> {}", id, path.display()); - index.insert_entry(path, IndexEntry { id, modified }); - } - Err(_) => { - log::warn!("File {} not found", path.display()); - continue; - } + for line in BufReader::new(file).lines().flatten() { + let mut parts = line.split(' '); + + let modified = { + let str = parts.next().ok_or(ArklibError::Parse)?; + UNIX_EPOCH.add(Duration::from_millis( + str.parse().map_err(|_| ArklibError::Parse)?, + )) + }; + + let id = { + let str = parts.next().ok_or(ArklibError::Parse)?; + ResourceId::from_str(str)? + }; + + let path: String = + itertools::Itertools::intersperse(parts, " ").collect(); + let path: PathBuf = root_path.join(Path::new(&path)); + match CanonicalPathBuf::canonicalize(&path) { + Ok(path) => { + log::trace!("[load] {} -> {}", id, path.display()); + index.insert_entry(path, IndexEntry { id, modified }); + } + Err(_) => { + log::warn!("File {} not found", path.display()); + continue; } } } @@ -136,7 +133,7 @@ impl ResourceIndex { let mut path2id: Vec<(&CanonicalPathBuf, &IndexEntry)> = self.path2id.iter().collect(); - path2id.sort_by_key(|(_, entry)| entry.clone()); + path2id.sort_by_key(|(_, entry)| *entry); for (path, entry) in path2id.iter() { log::trace!("[store] {} by path {}", entry.id, path.display()); @@ -155,7 +152,7 @@ impl ResourceIndex { "Couldn't calculate path diff".into(), ))?; - write!(file, "{} {} {}\n", timestamp, entry.id, path.display())?; + writeln!(file, "{} {} {}", timestamp, entry.id, path.display())?; } log::trace!( @@ -364,7 +361,7 @@ impl ResourceIndex { self.path2id[canonical_path] ); - return match fs::metadata(canonical_path) { + match fs::metadata(canonical_path) { Err(_) => { // updating the index after resource removal is a correct // scenario @@ -412,14 +409,13 @@ impl ResourceIndex { } } } - }; + } } pub fn forget_id(&mut self, old_id: ResourceId) -> Result { let old_path = self .path2id .drain() - .into_iter() .filter_map(|(k, v)| { if v.id == old_id { Some(k) @@ -435,24 +431,24 @@ impl ResourceIndex { let mut deleted = HashSet::new(); deleted.insert(old_id); - return Ok(IndexUpdate { + Ok(IndexUpdate { added: HashMap::new(), deleted, - }); + }) } fn insert_entry(&mut self, path: CanonicalPathBuf, entry: IndexEntry) { log::trace!("[add] {} by path {}", entry.id, path.display()); let id = entry.id; - if self.id2path.contains_key(&id) { - if let Some(nonempty) = self.collisions.get_mut(&id) { - *nonempty += 1; - } else { - self.collisions.insert(id, 2); - } + if let std::collections::hash_map::Entry::Vacant(e) = + self.id2path.entry(id) + { + e.insert(path.clone()); + } else if let Some(nonempty) = self.collisions.get_mut(&id) { + *nonempty += 1; } else { - self.id2path.insert(id, path.clone()); + self.collisions.insert(id, 2); } self.path2id.insert(path, entry); @@ -465,7 +461,7 @@ impl ResourceIndex { ) -> Result { self.path2id.remove(path); - if let Some(mut collisions) = self.collisions.get_mut(&old_id) { + if let Some(collisions) = self.collisions.get_mut(&old_id) { debug_assert!( *collisions > 1, "Any collision must involve at least 2 resources" @@ -509,10 +505,10 @@ impl ResourceIndex { let mut deleted = HashSet::new(); deleted.insert(old_id); - return Ok(IndexUpdate { + Ok(IndexUpdate { added: HashMap::new(), deleted, - }); + }) } } @@ -561,7 +557,7 @@ fn scan_entry(path: &CanonicalPath, metadata: Metadata) -> Result { let size = metadata.len(); if size == 0 { - return Err(std::io::Error::new( + Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "Empty resource", ))?; @@ -602,7 +598,7 @@ fn is_hidden(entry: &DirEntry) -> bool { entry .file_name() .to_str() - .map(|s| s.starts_with(".")) + .map(|s| s.starts_with('.')) .unwrap_or(false) } @@ -613,9 +609,12 @@ mod tests { use crate::ArklibError; use crate::ResourceIndex; use canonical_path::CanonicalPathBuf; - use std::fs::{File, Permissions}; - #[cfg(target_os = "unix")] + use std::fs::File; + #[cfg(target_os = "linux")] + use std::fs::Permissions; + #[cfg(target_os = "linux")] use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; use std::str::FromStr; use std::time::SystemTime; @@ -661,8 +660,8 @@ mod tests { } fn run_test_and_clean_up( - test: impl FnOnce(PathBuf) -> () + std::panic::UnwindSafe, - ) -> () { + test: impl FnOnce(PathBuf) + std::panic::UnwindSafe, + ) { let path = get_temp_dir(); let result = std::panic::catch_unwind(|| test(path.clone())); std::fs::remove_dir_all(path.clone()) @@ -777,7 +776,7 @@ mod tests { assert_eq!(update.added.len(), 1); let added_key = - CanonicalPathBuf::canonicalize(&expected_path.clone()) + CanonicalPathBuf::canonicalize(expected_path.clone()) .expect("CanonicalPathBuf should be fine"); assert_eq!( update @@ -844,7 +843,7 @@ mod tests { assert_eq!(actual.collisions.len(), 0); assert_eq!(actual.size(), 2); - #[cfg(target_os = "unix")] + #[cfg(target_os = "linux")] file.set_permissions(Permissions::from_mode(0o222)) .expect("Should be fine"); diff --git a/src/lib.rs b/src/lib.rs index 4cfff49..70d1a56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,22 @@ +#![deny(clippy::all)] #[macro_use] extern crate lazy_static; extern crate canonical_path; + pub mod errors; pub use errors::{ArklibError, Result}; + pub mod id; +pub mod index; + pub mod link; pub mod pdf; -pub mod index; -mod meta; +mod atomic; +mod storage; +mod util; + +pub use atomic::{modify, modify_json, AtomicFile}; use index::ResourceIndex; @@ -18,13 +26,12 @@ use std::sync::{Arc, RwLock}; use canonical_path::CanonicalPathBuf; -use log; - pub const ARK_FOLDER: &str = ".ark"; // must not be lost (user data) pub const STATS_FOLDER: &str = "stats"; pub const FAVORITES_FILE: &str = "favorites"; +pub const DEVICE_ID: &str = "device"; // User-defined data pub const TAG_STORAGE_FILE: &str = "user/tags"; diff --git a/src/link.rs b/src/link.rs index ec146aa..dd29623 100644 --- a/src/link.rs +++ b/src/link.rs @@ -1,33 +1,50 @@ +use crate::id::ResourceId; +use crate::storage::meta::store_metadata; +use crate::storage::prop::store_properties; +use crate::{ + storage::prop::load_raw_properties, AtomicFile, Result, ARK_FOLDER, + PREVIEWS_STORAGE_FOLDER, PROPERTIES_STORAGE_FOLDER, +}; use reqwest::header::HeaderValue; use scraper::{Html, Selector}; use serde::{Deserialize, Serialize}; use std::fmt; use std::path::Path; use std::str::{self, FromStr}; -use std::{fs, fs::File, io::Write, path::PathBuf}; +use std::{io::Write, path::PathBuf}; use url::Url; -use crate::id::ResourceId; -use crate::meta::{load_meta_bytes, store_meta}; -use crate::{ArklibError, Result, ARK_FOLDER, PREVIEWS_STORAGE_FOLDER}; - #[derive(Debug, Deserialize, Serialize)] pub struct Link { pub url: Url, - pub meta: Metadata, + pub prop: Properties, } #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Metadata { +pub struct Properties { pub title: String, pub desc: Option, } +/// Write data to a tempory file and move that written file to destination +/// +/// May failed if writing or moving failed +fn temp_and_move( + data: &[u8], + dest_dir: impl AsRef, + filename: &str, +) -> Result<()> { + let mut path = std::env::temp_dir(); + path.push(filename); + std::fs::write(&path, data)?; + std::fs::rename(path, dest_dir.as_ref().join(filename))?; + Ok(()) +} impl Link { pub fn new(url: Url, title: String, desc: Option) -> Self { Self { url, - meta: Metadata { title, desc }, + prop: Properties { title, desc }, } } @@ -35,93 +52,103 @@ impl Link { ResourceId::compute_bytes(self.url.as_str().as_bytes()) } - /// Load a link with its metadata from file - pub fn load>(root: P, path: P) -> Result { - let p = path.as_ref().to_path_buf(); + fn load_user_data>( + root: P, + id: &ResourceId, + ) -> Result { + let path = root + .as_ref() + .join(ARK_FOLDER) + .join(PROPERTIES_STORAGE_FOLDER) + .join(id.to_string()); + let file = AtomicFile::new(path)?; + let current = file.load()?; + let data = current.read_to_string()?; + let user_meta: Properties = serde_json::from_str(&data)?; + Ok(user_meta) + } + + /// Load a link with its properties from file + pub fn load>(root: P, filename: P) -> Result { + let p = root.as_ref().join(filename); let url = Self::load_url(p)?; let id = ResourceId::compute_bytes(url.as_str().as_bytes())?; + // Load user properties first + let user_prop = Self::load_user_data(&root, &id)?; + let mut description = user_prop.desc; + + // Only load properties if the description is not set + if description.is_none() { + let bytes = load_raw_properties(root.as_ref(), id)?; + let graph_meta: OpenGraph = serde_json::from_slice(&bytes)?; + description = graph_meta.description; + } - let bytes = load_meta_bytes::(root.as_ref().to_owned(), id)?; - let meta: Metadata = - serde_json::from_slice(&bytes).map_err(|_| ArklibError::Parse)?; - - Ok(Self { url, meta }) + Ok(Self { + url, + prop: Properties { + title: user_prop.title, + desc: description, + }, + }) } - /// Write zipped file to path - pub async fn write_to_path>( - &mut self, + pub async fn save>( + &self, root: P, - path: P, - save_preview: bool, + with_preview: bool, ) -> Result<()> { let id = self.id()?; - store_meta::(root.as_ref(), id, &self.meta)?; - - let mut link_file = File::create(path.as_ref().to_owned())?; - let file_data = self.url.as_str().as_bytes(); - link_file.write(file_data)?; - if save_preview { - let preview_data = Link::get_preview(self.url.clone()) - .await - .unwrap_or_default(); - let image_data = preview_data - .fetch_image() - .await - .unwrap_or_default(); - self.save_preview(root.as_ref(), image_data, id) - .await?; + let id_string = id.to_string(); + + // Resources are stored in the folder chosen by user + let bytes = self.url.as_str().as_bytes(); + temp_and_move(bytes, root.as_ref(), &id_string)?; + //User defined properties + store_properties(&root, id, &self.prop)?; + + // Generated data + if let Ok(graph) = self.get_preview().await { + log::debug!("Trying to save: {with_preview} with {graph:?}"); + + store_metadata(&root, id, &graph)?; + if with_preview { + if let Some(preview_data) = graph.fetch_image().await { + self.save_preview(root, preview_data, &id)?; + } + } } - - store_meta::(root, id, &self.meta) - } - - /// Synchronized version of Write zipped file to path - pub fn write_to_path_sync>( - &mut self, - root: P, - path: P, - save_preview: bool, - ) -> Result<()> { - let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(self.write_to_path(root, path, save_preview)) + Ok(()) } - pub async fn save_preview>( - &mut self, + fn save_preview>( + &self, root: P, image_data: Vec, - id: ResourceId, + id: &ResourceId, ) -> Result<()> { let path = root .as_ref() .join(ARK_FOLDER) - .join(PREVIEWS_STORAGE_FOLDER); - fs::create_dir_all(path.to_owned())?; - - let file = path.to_owned().join(id.to_string()); - - let mut file = File::create(file)?; - file.write(image_data.as_slice())?; - + .join(PREVIEWS_STORAGE_FOLDER) + .join(id.to_string()); + let file = AtomicFile::new(path)?; + let tmp = file.make_temp()?; + (&tmp).write_all(&image_data)?; + let current_preview = file.load()?; + file.compare_and_swap(¤t_preview, tmp)?; Ok(()) } - /// Get metadata of the link (synced). - pub fn get_preview_synced(url: S) -> Result - where - S: Into, - { + /// Get OGP metadata of the link (synced). + pub fn get_preview_synced(&self) -> Result { let runtime = tokio::runtime::Runtime::new().expect("Unable to create a runtime"); - return runtime.block_on(Link::get_preview(url)); + return runtime.block_on(self.get_preview()); } - /// Get metadata of the link. - pub async fn get_preview(url: S) -> Result - where - S: Into, - { + /// Get OGP metadata of the link. + pub async fn get_preview(&self) -> Result { let mut header = reqwest::header::HeaderMap::new(); header.insert( "User-Agent", @@ -132,13 +159,9 @@ impl Link { let client = reqwest::Client::builder() .default_headers(header) .build()?; - let scraper = client - .get(url.into()) - .send() - .await? - .text() - .await?; - let html = Html::parse_document(&scraper.as_str()); + let url = self.url.to_string(); + let scraper = client.get(url).send().await?.text().await?; + let html = Html::parse_document(scraper.as_str()); let title = select_og(&html, OpenGraphTag::Title).or(select_title(&html)); Ok(OpenGraph { @@ -153,9 +176,8 @@ impl Link { } fn load_url(path: PathBuf) -> Result { - let url_raw = std::fs::read(path)?; - let url_str = str::from_utf8(url_raw.as_slice())?; - Url::from_str(url_str).map_err(|_| ArklibError::Parse) + let content = std::fs::read_to_string(path)?; + Ok(Url::from_str(&content)?) } } @@ -191,7 +213,7 @@ fn select_title(html: &Html) -> Option { None } -#[derive(Debug, Serialize, Deserialize, Default)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] pub struct OpenGraph { /// Represents the "og:title" OpenGraph meta tag. /// @@ -278,42 +300,44 @@ impl OpenGraphTag { } } -#[test] -fn test_create_link_file() { +#[tokio::test] +async fn test_create_link_file() { use tempdir::TempDir; + let dir = TempDir::new("arklib_test").unwrap(); let root = dir.path(); println!("temporary root: {}", root.display()); - let url = Url::parse("https://example.com/").unwrap(); - let mut link = - Link::new(url, String::from("title"), Some(String::from("desc"))); + let url = Url::parse("https://kaydee.net/blog/open-graph-image/").unwrap(); + let link = Link::new( + url, + String::from("test_title"), + Some(String::from("test_desc")), + ); - let path = root.join("test.link"); + // Resources are stored in the folder chosen by user + let path = root.join(link.id().unwrap().to_string()); for save_preview in [false, true] { - link.write_to_path_sync(root, path.as_path(), save_preview) - .unwrap(); - let link_file_bytes = std::fs::read(path.to_owned()).unwrap(); + link.save(&root, save_preview).await.unwrap(); + let current_bytes = std::fs::read_to_string(&path).unwrap(); let url: Url = - Url::from_str(str::from_utf8(&link_file_bytes).unwrap()).unwrap(); - assert_eq!(url.as_str(), "https://example.com/"); - let link = Link::load(root.clone(), path.as_path()).unwrap(); + Url::from_str(str::from_utf8(current_bytes.as_bytes()).unwrap()) + .unwrap(); + assert_eq!(url.as_str(), "https://kaydee.net/blog/open-graph-image/"); + let link = Link::load(root, &path).unwrap(); assert_eq!(link.url.as_str(), url.as_str()); - assert_eq!(link.meta.desc.unwrap(), "desc"); - assert_eq!(link.meta.title, "title"); - - let id = ResourceId::compute_bytes(link_file_bytes.as_slice()).unwrap(); - println!("resource: {}, {}", id.crc32, id.data_size); + assert_eq!(link.prop.desc.unwrap(), "test_desc"); + assert_eq!(link.prop.title, "test_title"); - if Path::new(root) + let id = ResourceId::compute_bytes(current_bytes.as_bytes()).unwrap(); + let path = Path::new(&root) .join(ARK_FOLDER) .join(PREVIEWS_STORAGE_FOLDER) - .join(id.to_string()) - .exists() - { - assert_eq!(save_preview, true) + .join(id.to_string()); + if path.exists() { + assert!(save_preview) } else { - assert_eq!(save_preview, false) + assert!(!save_preview) } } } diff --git a/src/meta.rs b/src/meta.rs deleted file mode 100644 index a5918c0..0000000 --- a/src/meta.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::fs::{self, File}; -use std::io::Write; -use std::path::Path; - -use serde::Serialize; - -use crate::id::ResourceId; -use crate::{ArklibError, Result, ARK_FOLDER, METADATA_STORAGE_FOLDER}; - -/// Dynamic metadata: stored as JSON and -/// interpreted differently depending on kind of a resource -pub fn store_meta>( - root: P, - id: ResourceId, - metadata: &S, -) -> Result<()> { - let path = root - .as_ref() - .join(ARK_FOLDER) - .join(METADATA_STORAGE_FOLDER); - fs::create_dir_all(path.to_owned())?; - let mut file = File::create(path.to_owned().join(id.to_string()))?; - - let json = - serde_json::to_string(&metadata).map_err(|_| ArklibError::Parse)?; - let _ = file.write(json.into_bytes().as_slice())?; - - Ok(()) -} - -/// The file must exist if this method is called -pub fn load_meta_bytes>( - root: P, - id: ResourceId, -) -> Result> { - let storage = root - .as_ref() - .join(ARK_FOLDER) - .join(METADATA_STORAGE_FOLDER); - let path = storage.join(id.to_string()); - - Ok(std::fs::read(path)?) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempdir::TempDir; - - use std::collections::HashMap; - type TestMetadata = HashMap; - - #[test] - fn test_store_and_load() { - let dir = TempDir::new("arklib_test").unwrap(); - let root = dir.path(); - log::debug!("temporary root: {}", root.display()); - - let id = ResourceId { - crc32: 0x342a3d4a, - data_size: 1, - }; - - let mut meta = TestMetadata::new(); - meta.insert("abc".to_string(), "def".to_string()); - meta.insert("xyz".to_string(), "123".to_string()); - - store_meta(root, id, &meta).unwrap(); - - let bytes = load_meta_bytes(root, id).unwrap(); - let meta2: TestMetadata = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(meta, meta2); - } -} diff --git a/src/pdf.rs b/src/pdf.rs index 1063a6e..3960c44 100644 --- a/src/pdf.rs +++ b/src/pdf.rs @@ -28,8 +28,11 @@ fn initialize_pdfium() { ) .or_else(|_| Pdfium::bind_to_system_library()) .unwrap(); - PDFIUM.set(Pdfium::new(bindings)); // Instead of returning the bindings, we - // cache them in the static initializer + PDFIUM + .set(Pdfium::new(bindings)) + .ok() + .expect("Can't set PDFIUM"); // Instead of returning the bindings, we + // cache them in the static initializer } pub fn render_preview_page(data: R, quailty: PDFQuality) -> DynamicImage diff --git a/src/storage/meta.rs b/src/storage/meta.rs new file mode 100644 index 0000000..3a54241 --- /dev/null +++ b/src/storage/meta.rs @@ -0,0 +1,93 @@ +use crate::atomic::{modify_json, AtomicFile}; +use serde::{de::DeserializeOwned, Serialize}; +use std::fmt::Debug; +use std::io::Read; +use std::path::Path; + +use crate::id::ResourceId; +use crate::{Result, ARK_FOLDER, METADATA_STORAGE_FOLDER}; + +pub fn store_metadata< + S: Serialize + DeserializeOwned + Clone + Debug, + P: AsRef, +>( + root: P, + id: ResourceId, + metadata: &S, +) -> Result<()> { + let file = AtomicFile::new( + root.as_ref() + .join(ARK_FOLDER) + .join(METADATA_STORAGE_FOLDER) + .join(id.to_string()), + )?; + modify_json(&file, |current_meta: &mut Option| { + let new_meta = metadata.clone(); + match current_meta { + Some(file_data) => { + // This is fine because generated metadata must always + // be generated in same way on any device. + *file_data = new_meta; + // Different versions of the lib should + // not be used on synced devices. + } + None => *current_meta = Some(new_meta), + } + })?; + Ok(()) +} + +/// The file must exist if this method is called +pub fn load_raw_metadata>( + root: P, + id: ResourceId, +) -> Result> { + let storage = root + .as_ref() + .join(ARK_FOLDER) + .join(METADATA_STORAGE_FOLDER) + .join(id.to_string()); + let file = AtomicFile::new(storage)?; + let read_file = file.load()?; + if let Some(mut real_file) = read_file.open()? { + let mut content = vec![]; + real_file.read_to_end(&mut content)?; + Ok(content) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "File not found", + ))? + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempdir::TempDir; + + use std::collections::HashMap; + type TestMetadata = HashMap; + + #[test] + fn test_store_and_load() { + let dir = TempDir::new("arklib_test").unwrap(); + let root = dir.path(); + log::debug!("temporary root: {}", root.display()); + + let id = ResourceId { + crc32: 0x342a3d4a, + data_size: 1, + }; + + let mut meta = TestMetadata::new(); + meta.insert("abc".to_string(), "def".to_string()); + meta.insert("xyz".to_string(), "123".to_string()); + + store_metadata(root, id, &meta).unwrap(); + + let bytes = load_raw_metadata(root, id).unwrap(); + let prop2: TestMetadata = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(meta, prop2); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..8b08441 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,2 @@ +pub mod meta; +pub mod prop; diff --git a/src/storage/prop.rs b/src/storage/prop.rs new file mode 100644 index 0000000..6442faf --- /dev/null +++ b/src/storage/prop.rs @@ -0,0 +1,93 @@ +use crate::atomic::{modify_json, AtomicFile}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::Value; +use std::fmt::Debug; +use std::io::Read; +use std::path::Path; + +use crate::id::ResourceId; +use crate::util::json::merge; +use crate::{Result, ARK_FOLDER, PROPERTIES_STORAGE_FOLDER}; + +pub fn store_properties< + S: Serialize + DeserializeOwned + Clone + Debug, + P: AsRef, +>( + root: P, + id: ResourceId, + properties: &S, +) -> Result<()> { + let file = AtomicFile::new( + root.as_ref() + .join(ARK_FOLDER) + .join(PROPERTIES_STORAGE_FOLDER) + .join(id.to_string()), + )?; + modify_json(&file, |current_data: &mut Option| { + let new_value = serde_json::to_value(properties).unwrap(); + match current_data { + Some(old_data) => { + // Should not failed unless serialize failed which should never happen + let old_value = serde_json::to_value(old_data).unwrap(); + *current_data = Some(merge(old_value, new_value)); + } + None => *current_data = Some(new_value), + } + })?; + Ok(()) +} + +/// The file must exist if this method is called +pub fn load_raw_properties>( + root: P, + id: ResourceId, +) -> Result> { + let storage = root + .as_ref() + .join(ARK_FOLDER) + .join(PROPERTIES_STORAGE_FOLDER) + .join(id.to_string()); + let file = AtomicFile::new(storage)?; + let read_file = file.load()?; + if let Some(mut real_file) = read_file.open()? { + let mut content = vec![]; + real_file.read_to_end(&mut content)?; + Ok(content) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "File not found", + ))? + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempdir::TempDir; + + use std::collections::HashMap; + type TestProperties = HashMap; + + #[test] + fn test_store_and_load() { + let dir = TempDir::new("arklib_test").unwrap(); + let root = dir.path(); + log::debug!("temporary root: {}", root.display()); + + let id = ResourceId { + crc32: 0x342a3d4a, + data_size: 1, + }; + + let mut prop = TestProperties::new(); + prop.insert("abc".to_string(), "def".to_string()); + prop.insert("xyz".to_string(), "123".to_string()); + + store_properties(root, id, &prop).unwrap(); + + let bytes = load_raw_properties(root, id).unwrap(); + let prop2: TestProperties = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(prop, prop2); + } +} diff --git a/src/util/json.rs b/src/util/json.rs new file mode 100644 index 0000000..cc66377 --- /dev/null +++ b/src/util/json.rs @@ -0,0 +1,157 @@ +use serde_json::json; +use serde_json::map::Entry; +use serde_json::Map; +use serde_json::Value; + +pub fn merge(origin: Value, new_data: Value) -> Value { + match (origin, new_data) { + (Value::Object(old), Value::Object(new)) => merge_object(old, new), + (Value::Array(old), Value::Array(new)) => merge_vec(old, new), + (Value::Array(mut old), new) => { + if !old.is_empty() + && std::mem::discriminant(&old[0]) + == std::mem::discriminant(&new) + { + old.push(new); + Value::Array(old) + } else if old.is_empty() { + json!([new]) + } else { + Value::Array(old) + } + } + (old, Value::Array(mut new_data)) => { + if !new_data.is_empty() + && std::mem::discriminant(&old) + == std::mem::discriminant(&new_data[0]) + { + new_data.insert(0, old); + Value::Array(new_data) + } else { + // Different types, keep old data + old + } + } + (old, Value::Null) => old, + (Value::Null, new) => new, + (old, new) => { + if std::mem::discriminant(&old) == std::mem::discriminant(&new) + && old != new + { + json!([old, new]) + } else { + // different types keep old data + old + } + } + } +} + +fn merge_object( + mut origin: Map, + new_data: Map, +) -> Value { + for (key, value) in new_data.into_iter() { + match origin.entry(&key) { + Entry::Vacant(e) => { + e.insert(value); + } + Entry::Occupied(prev) => { + // Extract entry to manipulate it + let prev = prev.remove(); + match (prev, value) { + (Value::Array(old_data), Value::Array(new_data)) => { + let updated = merge_vec(old_data, new_data); + origin.insert(key, updated); + } + (Value::Array(d), Value::Null) => { + origin.insert(key, Value::Array(d)); + } + (Value::Array(mut old_data), new_data) => { + if old_data.iter().all(|val| { + std::mem::discriminant(&new_data) + == std::mem::discriminant(val) + }) { + old_data.push(new_data); + } + origin.insert(key, json!(old_data)); + } + (old, Value::Array(mut new_data)) => { + if new_data.iter().all(|val| { + std::mem::discriminant(&old) + == std::mem::discriminant(val) + }) { + new_data.insert(0, old); + origin.insert(key, json!(new_data)); + } else { + // Different types, just keep old data + origin.insert(key, old); + } + } + (old, new) => { + // Only create array if same type + if std::mem::discriminant(&old) + == std::mem::discriminant(&new) + && old != new + { + origin.insert(key, json!([old, new])); + } else { + // Keep old value + origin.insert(key, old); + } + } + } + } + } + } + Value::Object(origin) +} + +fn merge_vec(original: Vec, new_data: Vec) -> Value { + if original.is_empty() { + Value::Array(new_data) + } else if new_data.is_empty() { + Value::Array(original) + } else { + // Check that values are the same type. Return array of type original[0] + let discriminant = std::mem::discriminant(&original[0]); + let mut filtered: Vec<_> = original + .into_iter() + .filter(|v| std::mem::discriminant(v) == discriminant) + .collect(); + let new: Vec<_> = new_data + .into_iter() + .filter(|v| { + std::mem::discriminant(v) == discriminant + && filtered.iter().all(|val| val != v) + }) + .collect(); + filtered.extend(new); + Value::Array(filtered) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case(json ! ("old"), json ! ("new"), json ! (["old", "new"]))] + #[case(json ! (["old1", "old2"]), json ! ("new"), json ! (["old1", "old2", "new"]))] + #[case(json ! ("same"), json ! ("same"), json ! ("same"))] + #[case(json ! ({ + "a": ["An array"], + "b": 1, + }), json ! ({"c": "A string"}), json ! ({"a": ["An array"], "b": 1, "c": "A string"}))] + #[case(json ! ({"a": "Object"}), json ! ("A string"), json ! ({"a": "Object"}))] + #[case(json ! ("Old string"), json ! ({"a": 1}), json ! ("Old string"))] + fn merging_as_expected( + #[case] old: Value, + #[case] new: Value, + #[case] expected: Value, + ) { + let merged = merge(old, new); + assert_eq!(merged, expected); + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..22fdbb3 --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1 @@ +pub mod json;