Skip to main content

archival/
lib.rs

1mod archival_error;
2#[cfg(test)]
3mod build_id_tests;
4mod definition_comments;
5mod file_system;
6mod file_system_memory;
7mod file_system_mutex;
8#[cfg(test)]
9mod file_system_tests;
10mod filters;
11mod lib_fs;
12mod liquid_parser;
13mod liquid_rewrite;
14#[cfg(feature = "lsp")]
15mod lsp;
16mod object_definition;
17mod page;
18mod read_toml;
19mod reserved_fields;
20#[cfg(test)]
21mod schema_files;
22pub mod schemas;
23mod site;
24mod tags;
25#[cfg(test)]
26mod test_utils;
27mod typescript_defs;
28mod util;
29mod value_path;
30use anyhow::Result;
31use events::{
32    AddChildEvent, AddObjectEvent, ArchivalEvent, DeleteObjectEvent, EditFieldEvent,
33    EditOrderEvent, RemoveChildEvent, RenameObjectEvent,
34};
35use events::{AddRootObjectEvent, ArchivalEventResponse};
36use manifest::Manifest;
37use mime_guess::MimeGuess;
38use seahash::SeaHasher;
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41use site::Site;
42use std::cmp::Ordering;
43use std::fmt::Debug;
44use std::hash::Hasher;
45use std::path::{Path, PathBuf};
46use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
47use tracing::{debug, error};
48#[cfg(feature = "binary")]
49pub mod binary;
50mod constants;
51#[cfg(feature = "stdlib-fs")]
52mod file_system_stdlib;
53#[cfg(feature = "json-schema")]
54mod json_schema;
55#[cfg(feature = "binary")]
56mod server;
57use file_system_mutex::FileSystemMutex;
58use object::{Object, ObjectEntry};
59use semver::{Version, VersionReq};
60
61// Re-exports
62pub mod events;
63pub mod fields;
64pub mod manifest;
65pub mod object;
66#[cfg(feature = "proto")]
67pub mod proto;
68pub use archival_error::ArchivalError;
69pub use constants::{
70    LEGACY_MANIFEST_FILE_NAME, LEGACY_OBJECT_DEFINITION_FILE_NAME, MANIFEST_FILE_NAME,
71    MIN_COMPAT_VERSION, OBJECT_DEFINITION_FILE_NAME,
72};
73pub use definition_comments::DefinitionComments;
74pub use fields::{
75    file::RenderedFile, FieldConfig, FieldType, FieldValue, RenderedFieldValue,
76    RenderedObjectValues,
77};
78pub use file_system::{unpack_zip, FileSystemAPI};
79pub use file_system_memory::MemoryFileSystem;
80#[cfg(feature = "json-schema")]
81pub use json_schema::{ObjectSchema, ObjectSchemaOptions};
82pub use object::{ObjectMap, RenderedObject, RenderedObjectMap, ValuePath};
83pub use object_definition::{FieldDefinition, FieldsMap, ObjectDefinition, ObjectDefinitions};
84#[cfg(feature = "proto")]
85pub use proto::archival_proto;
86pub use typescript_defs::generate_typescript_defs;
87
88pub type ArchivalBuildId = u64;
89
90#[cfg(feature = "typescript")]
91pub mod typedefs {
92    pub use crate::object::typedefs::*;
93    pub use crate::object_definition::typedefs::*;
94}
95
96#[derive(Debug, Default)]
97pub struct BuildOptions {
98    pub skip_static: bool,
99    pub skip_failures: bool,
100}
101
102impl BuildOptions {
103    pub fn no_static() -> Self {
104        Self {
105            skip_static: true,
106            skip_failures: false,
107        }
108    }
109    pub fn intermediate() -> Self {
110        Self {
111            skip_static: true,
112            skip_failures: true,
113        }
114    }
115}
116
117#[derive(Debug, Serialize, Deserialize)]
118pub struct DistFile {
119    pub path: PathBuf,
120    pub mime: String,
121    pub data: Vec<u8>,
122}
123impl DistFile {
124    fn new(path: PathBuf, data: Vec<u8>) -> Self {
125        Self {
126            mime: MimeGuess::from_path(&path)
127                .first_or_octet_stream()
128                .essence_str()
129                .to_string(),
130            data,
131            path,
132        }
133    }
134}
135
136pub static ARCHIVAL_VERSION: &str = env!("CARGO_PKG_VERSION");
137
138pub(crate) fn check_compatibility(version_string: &str) -> (bool, String) {
139    let req = VersionReq::parse(MIN_COMPAT_VERSION).unwrap();
140    match Version::parse(version_string) {
141        Ok(version) => {
142            if req.matches(&version) {
143                (true, "passed compatibility check.".to_owned())
144            } else {
145                (false, format!("site archival version {} is incompatible with this version of archival (minimum required version {}).", version, MIN_COMPAT_VERSION))
146            }
147        }
148        Err(e) => (false, format!("invalid version {}: {}", version_string, e)),
149    }
150}
151
152pub fn sha_for_data(data: &[u8]) -> String {
153    let mut hasher = Sha256::new();
154    hasher.update(data);
155    data_encoding::HEXLOWER.encode(&hasher.finalize())
156}
157
158#[derive(Debug)]
159pub struct Archival<F: FileSystemAPI + Clone + Debug> {
160    fs_mutex: FileSystemMutex<F>,
161    pub site: site::Site,
162    last_build_id: AtomicU64,
163}
164
165impl<F: FileSystemAPI + Clone + Debug> Archival<F> {
166    pub fn is_compatible(fs: &F) -> Result<bool> {
167        let site = Site::load(fs, Some(""))?;
168        if let Some(version_str) = &site.manifest.archival_version {
169            let (ok, msg) = check_compatibility(version_str);
170            if !ok {
171                error!("incompatible: {}", msg);
172            }
173            Ok(ok)
174        } else {
175            Ok(true)
176        }
177    }
178    pub fn new(fs: F) -> Result<Self> {
179        let site = Site::load(&fs, None)?;
180        let fs_mutex = FileSystemMutex::init(fs);
181        Ok(Self {
182            fs_mutex,
183            site,
184            last_build_id: AtomicU64::new(0),
185        })
186    }
187    pub fn new_with_upload_prefix(fs: F, upload_prefix: &str) -> Result<Self> {
188        let site = Site::load(&fs, Some(upload_prefix))?;
189        let fs_mutex = FileSystemMutex::init(fs);
190        Ok(Self {
191            fs_mutex,
192            site,
193            last_build_id: AtomicU64::new(0),
194        })
195    }
196    pub fn build(&self, options: BuildOptions) -> Result<ArchivalBuildId> {
197        let (build_id, built) = self.fs_mutex.with_fs(|fs| {
198            if !options.skip_static {
199                self.site.sync_static_files(fs)?;
200            }
201            let build_id = self.site.build_id();
202            let should_build =
203                build_id == 0 || self.last_build_id.load(AtomicOrdering::Relaxed) != build_id;
204            if should_build {
205                debug!("build {} {:#?}", self.site, options);
206                self.site.build(fs, options)?;
207            } else {
208                #[cfg(feature = "verbose-logging")]
209                debug!("skipping duplicate build");
210            }
211            // Recompute build_id after site.build() populates the cache
212            let final_build_id = self.site.build_id();
213            Ok((final_build_id, should_build))
214        })?;
215        // Only update last_build_id if we actually built
216        if built {
217            self.last_build_id
218                .fetch_update(AtomicOrdering::Relaxed, AtomicOrdering::Relaxed, |_| {
219                    Some(build_id)
220                })
221                .unwrap();
222        }
223        Ok(self.last_build_id.load(AtomicOrdering::Relaxed))
224    }
225    #[cfg(feature = "json-schema")]
226    pub fn dump_schemas(&self) -> Result<()> {
227        debug!("dump schemas {}", self.site);
228        self.fs_mutex.with_fs(|fs| self.site.dump_schemas(fs))
229    }
230    #[cfg(feature = "json-schema")]
231    pub fn generate_root_json_schema(&self, options: ObjectSchemaOptions) -> ObjectSchema {
232        json_schema::generate_root_json_schema(
233            &format!("{}/root.schema.json", self.site.schema_prefix()),
234            self.site.manifest.site_name.as_deref(),
235            &format!(
236                "Object definitions{}",
237                if let Some(name) = options
238                    .name
239                    .as_ref()
240                    .and(self.site.manifest.site_name.as_ref())
241                    .to_owned()
242                {
243                    format!(" for {}", name)
244                } else {
245                    "".to_string()
246                }
247            ),
248            &self.site.object_definitions,
249            &self
250                .fs_mutex
251                .with_fs(|fs| Ok(self.site.root_objects(fs)))
252                .unwrap(),
253            options,
254        )
255    }
256    pub fn dist_file(&self, path: &Path) -> Option<Vec<u8>> {
257        let path = self.site.manifest.build_dir.join(path);
258        self.fs_mutex.with_fs(|fs| fs.read(&path)).unwrap_or(None)
259    }
260    pub fn dist_files(&self) -> Vec<DistFile> {
261        let mut files = vec![];
262        self.fs_mutex
263            .with_fs(|fs| {
264                let build_dir = &self.site.manifest.build_dir;
265                for file in fs.walk_dir(build_dir, true)? {
266                    if let Some(data) = fs.read(build_dir.join(&file)).unwrap_or(None) {
267                        files.push(DistFile::new(file, data));
268                    }
269                }
270                Ok(())
271            })
272            .unwrap();
273        files
274    }
275    pub fn object_exists(&self, obj_type: &str, filename: &str) -> Result<bool> {
276        self.fs_mutex
277            .with_fs(|fs| fs.exists(&self.object_path_impl(obj_type, filename, fs)?))
278    }
279    pub fn object_path(&self, obj_type: &str, filename: &str) -> PathBuf {
280        self.fs_mutex
281            .with_fs(|fs| Ok(self.object_path_impl(obj_type, filename, fs).unwrap()))
282            .unwrap()
283    }
284    pub fn build_id(&self) -> u64 {
285        self.site.build_id()
286    }
287    /// See [Site::objects_generation].
288    pub fn objects_generation(&self) -> u64 {
289        self.site.objects_generation()
290    }
291    pub fn fs_id(&self) -> Result<u64> {
292        self.fs_mutex.with_fs(|fs| self.fs_id_for_fs(fs))
293    }
294    pub fn list_build_files(&self) -> Result<impl Iterator<Item = PathBuf> + use<'_, F>> {
295        self.fs_mutex.with_fs(|fs| self.list_build_files_for_fs(fs))
296    }
297    fn list_build_files_for_fs(
298        &self,
299        fs: &F,
300    ) -> Result<impl Iterator<Item = PathBuf> + use<'_, F>> {
301        let Manifest {
302            object_definition_file,
303            pages_dir,
304            layout_dir,
305            objects_dir,
306            static_dir,
307            ..
308        } = &self.site.manifest;
309        let root_files = [
310            Manifest::path_in(Path::new(""), fs)?,
311            object_definition_file.to_owned(),
312        ];
313        Ok(root_files
314            .into_iter()
315            .chain(fs.walk_dir(static_dir, false)?.map(|p| static_dir.join(p)))
316            .chain(fs.walk_dir(pages_dir, false)?.map(|p| pages_dir.join(p)))
317            .chain(fs.walk_dir(layout_dir, false)?.map(|p| layout_dir.join(p)))
318            .chain(
319                fs.walk_dir(objects_dir, false)?
320                    .map(|p| objects_dir.join(p)),
321            ))
322    }
323    fn fs_id_for_fs(&self, fs: &F) -> Result<u64> {
324        let mut hasher = SeaHasher::new();
325        for path in self.list_build_files_for_fs(fs)? {
326            if let Some(file) = fs.read(&path)? {
327                hasher.write(&file);
328            } else {
329                debug!("no content found for {}", path.display());
330            }
331        }
332        Ok(hasher.finish())
333    }
334    fn object_path_impl(&self, obj_type: &str, filename: &str, fs: &F) -> Result<PathBuf> {
335        let objects = self.site.get_objects(fs)?;
336        let entry = objects.get(obj_type).ok_or(ArchivalError::new(&format!(
337            "object type not found: {}",
338            obj_type
339        )))?;
340        Ok(if matches!(entry, ObjectEntry::Object(_)) {
341            self.site
342                .manifest
343                .objects_dir
344                .join(Path::new(&format!("{}.toml", obj_type)))
345        } else {
346            self.site
347                .manifest
348                .objects_dir
349                .join(Path::new(&obj_type))
350                .join(Path::new(&format!("{}.toml", filename)))
351        })
352    }
353    pub fn object_file(&self, obj_type: &str, filename: &str) -> Result<String> {
354        self.fs_mutex
355            .with_fs(|fs| self.modify_object_file(obj_type, filename, |o| Ok(o), fs))
356    }
357    pub fn sha_for_file(&self, file: &Path) -> Result<String> {
358        let file_data = self
359            .fs_mutex
360            .with_fs(|fs| fs.read(file))?
361            .ok_or_else(|| ArchivalError::new("failed generating sha"))?;
362        Ok(sha_for_data(&file_data))
363    }
364
365    pub fn write_file(&self, obj_type: &str, filename: &str, contents: String) -> Result<()> {
366        // Validate toml
367        let obj_def = self.get_object_definition(obj_type)?;
368        let table: toml::Table = toml::from_str(&contents)?;
369        // Note that this also fails when custom validation fails.
370        let _ = Object::from_table(
371            obj_def,
372            Path::new(filename),
373            &table,
374            &self.site.manifest.editor_types,
375            false,
376        )?;
377        // Object is valid, write it
378        self.fs_mutex
379            .with_fs(|fs| fs.write_str(&self.object_path_impl(obj_type, filename, fs)?, contents))
380    }
381    fn modify_object_file(
382        &self,
383        obj_type: &str,
384        filename: &str,
385        obj_cb: impl FnOnce(&mut Object) -> Result<&mut Object>,
386        fs: &F,
387    ) -> Result<String> {
388        let mut all_objects = self.site.get_objects(fs)?;
389        let definitions = &self.site.object_definitions;
390        if let Some(objects) = all_objects.get_mut(obj_type) {
391            if let Some(object) = objects.iter_mut().find(|o| o.filename == filename) {
392                let object = obj_cb(object)?;
393                let def = definitions.get(obj_type).ok_or_else(|| {
394                    ArchivalError::new(&format!("missing object definition: {obj_type}"))
395                })?;
396                Ok(object.to_toml(def)?)
397            } else {
398                Err(objects
399                    .as_list()
400                    .map(|list| {
401                        ArchivalError::new(&format!(
402                            "{} {} not found in [{}]",
403                            obj_type,
404                            filename,
405                            list.iter()
406                                .map(|o| o.filename.clone())
407                                .collect::<Vec<_>>()
408                                .join(", ")
409                        ))
410                        .into()
411                    })
412                    .unwrap_or_else(|| {
413                        ArchivalError::new(&format!("object {} not found", filename)).into()
414                    }))
415            }
416        } else {
417            Err(ArchivalError::new(&format!("no objects of type: {}", obj_type)).into())
418        }
419    }
420
421    pub fn send_event(
422        &self,
423        event: ArchivalEvent,
424        build_options: Option<BuildOptions>,
425    ) -> Result<ArchivalEventResponse> {
426        let r = match event {
427            ArchivalEvent::AddObject(event) => self.add_object(event)?,
428            ArchivalEvent::RenameObject(event) => self.rename_object(event)?,
429            ArchivalEvent::AddRootObject(event) => self.add_root_object(event)?,
430            ArchivalEvent::DeleteObject(event) => self.delete_object(event)?,
431            ArchivalEvent::EditField(event) => self.edit_field(event)?,
432            ArchivalEvent::EditOrder(event) => self.edit_order(event)?,
433            ArchivalEvent::AddChild(event) => self.add_child(event)?,
434            ArchivalEvent::RemoveChild(event) => self.remove_child(event)?,
435        };
436        if let Some(build_options) = build_options {
437            self.build(build_options)?;
438        }
439        Ok(r)
440    }
441
442    // Internal
443    fn add_root_object(&self, event: AddRootObjectEvent) -> Result<ArchivalEventResponse> {
444        let obj_def = self.get_object_definition(&event.object)?;
445        self.fs_mutex.with_fs(|fs| {
446            let dir_path = self
447                .site
448                .manifest
449                .objects_dir
450                .join(Path::new(&event.object));
451            if fs.is_dir(&dir_path)? && fs.walk_dir(&dir_path, false)?.next().is_some() {
452                return Err(ArchivalError::new(&format!(
453                    "cannod add root {} object, found existing non-roots.",
454                    event.object
455                ))
456                .into());
457            }
458            let path = self
459                .site
460                .manifest
461                .objects_dir
462                .join(Path::new(&format!("{}.toml", event.object)));
463            if fs.exists(&path)? {
464                return Err(ArchivalError::new(&format!(
465                    "cannod add root {}, file already exists.",
466                    event.object
467                ))
468                .into());
469            }
470            let object = Object::from_def(obj_def, &event.object, None, event.values)?;
471            fs.write_str(&path, object.to_toml(obj_def)?)?;
472            self.site.invalidate_file(&path);
473            Ok(())
474        })?;
475        Ok(ArchivalEventResponse::None)
476    }
477
478    fn add_object(&self, event: AddObjectEvent) -> Result<ArchivalEventResponse> {
479        let obj_def = self.get_object_definition(&event.object)?;
480        self.fs_mutex.with_fs(|fs| {
481            let obj_dir = self
482                .site
483                .manifest
484                .objects_dir
485                .join(Path::new(&event.object));
486            fs.create_dir_all(&obj_dir)?;
487            let path = obj_dir.join(Path::new(&format!("{}.toml", event.filename)));
488            if fs.exists(&path)? {
489                return Err(ArchivalError::new(&format!(
490                    "cannod add {} named {}, file already exists.",
491                    event.object, event.filename
492                ))
493                .into());
494            }
495            let root_path = self
496                .site
497                .manifest
498                .objects_dir
499                .join(Path::new(&format!("{}.toml", event.object)));
500            if fs.exists(&root_path)? {
501                return Err(ArchivalError::new(&format!(
502                    "cannod add {} named {}, there's already a root {}.",
503                    event.object, event.filename, event.object
504                ))
505                .into());
506            }
507            let object = Object::from_def(obj_def, &event.filename, event.order, event.values)?;
508            fs.write_str(&path, object.to_toml(obj_def)?)
509                .map_err(|error| {
510                    ArchivalError::new(&format!("failed writing to {}: {}", path.display(), error))
511                })?;
512            self.site.invalidate_file(&path);
513            Ok(())
514        })?;
515        Ok(ArchivalEventResponse::None)
516    }
517
518    fn rename_object(&self, event: RenameObjectEvent) -> Result<ArchivalEventResponse> {
519        let obj_def = self.get_object_definition(&event.object)?;
520        self.fs_mutex.with_fs(|fs| {
521            let root_objects = self.site.root_objects(fs);
522            if root_objects.contains(&event.from) {
523                return Err(ArchivalError::new(&format!(
524                    "cannot rename root object {}",
525                    event.from
526                ))
527                .into());
528            }
529            let from_path = self.object_path_impl(&obj_def.name, &event.from, fs)?;
530            let to_path = self.object_path_impl(&obj_def.name, &event.to, fs)?;
531            let content = fs.read(&from_path)?.ok_or(ArchivalError::new(&format!(
532                "file not found: {}",
533                event.from
534            )))?;
535            fs.write(&to_path, content)?;
536            fs.delete(&from_path)?;
537            self.site.invalidate_file(&from_path);
538            Ok(())
539        })?;
540        Ok(ArchivalEventResponse::None)
541    }
542
543    fn delete_object(&self, event: DeleteObjectEvent) -> Result<ArchivalEventResponse> {
544        let obj_def = self.get_object_definition(&event.object)?;
545        self.fs_mutex.with_fs(|fs| {
546            let path = self.object_path_impl(&obj_def.name, &event.filename, fs)?;
547            fs.delete(&path)?;
548            self.site.invalidate_file(&path);
549            Ok(())
550        })?;
551        Ok(ArchivalEventResponse::None)
552    }
553
554    pub fn manifest_content(&self) -> Result<String> {
555        self.fs_mutex.with_fs(|fs| self.site.manifest_content(fs))
556    }
557
558    pub fn get_object_definition(&self, name: &str) -> Result<&ObjectDefinition, ArchivalError> {
559        self.site
560            .object_definitions
561            .get(name)
562            .ok_or(ArchivalError::new(&format!("object not found: {}", name)))
563    }
564
565    pub fn get_objects(&self) -> Result<ObjectMap> {
566        self.fs_mutex.with_fs(|fs| self.site.get_objects(fs))
567    }
568
569    pub fn get_object(&self, name: &str, filename: Option<&str>) -> Result<Object> {
570        self.fs_mutex
571            .with_fs(|fs| self.site.get_object(name, filename, fs))
572    }
573
574    pub fn get_objects_sorted(
575        &self,
576        sort: impl Fn(&Object, &Object) -> Ordering,
577    ) -> Result<ObjectMap> {
578        self.fs_mutex
579            .with_fs(|fs| self.site.get_objects_sorted(fs, Some(sort)))
580    }
581
582    pub fn get_rendered_objects(&self) -> Result<RenderedObjectMap> {
583        self.fs_mutex
584            .with_fs(|fs| self.site.get_rendered_objects(fs))
585    }
586
587    pub fn get_rendered_object(
588        &self,
589        name: &str,
590        filename: Option<&str>,
591    ) -> Result<RenderedObject> {
592        self.fs_mutex
593            .with_fs(|fs| self.site.get_rendered_object(name, filename, fs))
594    }
595
596    pub fn get_rendered_objects_sorted(
597        &self,
598        sort: impl Fn(&Object, &Object) -> Ordering,
599    ) -> Result<RenderedObjectMap> {
600        self.fs_mutex
601            .with_fs(|fs| self.site.get_rendered_objects_sorted(fs, Some(sort)))
602    }
603
604    fn edit_field(&self, event: EditFieldEvent) -> Result<ArchivalEventResponse> {
605        let def = self
606            .site
607            .object_definitions
608            .get(&event.object)
609            .ok_or_else(|| {
610                ArchivalError::new(&format!("object type not found: {}", event.object))
611            })?;
612        if let Some(value) = &event.value {
613            value.validate(
614                &event
615                    .path
616                    .clone()
617                    .concat(ValuePath::from_string(&event.field)),
618                def,
619                &self.site.manifest.editor_types,
620            )?;
621        }
622        self.write_object(&event.object, &event.filename, |existing| {
623            event
624                .path
625                .append((&(*event.field)).into())
626                .set_in_object(existing, event.value)?;
627            Ok(existing)
628        })?;
629        Ok(ArchivalEventResponse::None)
630    }
631    fn edit_order(&self, event: EditOrderEvent) -> Result<ArchivalEventResponse> {
632        self.write_object(&event.object, &event.filename, |existing| {
633            existing.order = event.order;
634            Ok(existing)
635        })?;
636        Ok(ArchivalEventResponse::None)
637    }
638
639    fn add_child(&self, event: AddChildEvent) -> Result<ArchivalEventResponse> {
640        let mut added_idx = usize::MAX;
641        let def = self
642            .site
643            .object_definitions
644            .get(&event.object)
645            .ok_or_else(|| {
646                ArchivalError::new(&format!("object type not found: {}", event.object))
647            })?;
648        // Validate any initial values
649        for value in &event.values {
650            value.value.validate(
651                &event.path.clone().concat(value.path.clone()),
652                def,
653                &self.site.manifest.editor_types,
654            )?;
655        }
656        // Seed the new child from its definition so it carries the same field
657        // scaffolding (notably empty child collections) as objects created any
658        // other way - `from_def` does this via `empty_object`. Without it a new
659        // child is a bare map with every value undefined.
660        let child_def = event.path.get_definition(def)?;
661        self.write_object(&event.object, &event.filename, |existing| {
662            added_idx = event.path.add_child(existing, event.index, |child| {
663                *child = child_def.empty_object();
664                for value in event.values {
665                    value.path.set_in_tree(child, Some(value.value))?;
666                }
667                Ok(())
668            })?;
669            Ok(existing)
670        })?;
671        Ok(ArchivalEventResponse::Index(added_idx))
672    }
673    fn remove_child(&self, event: RemoveChildEvent) -> Result<ArchivalEventResponse> {
674        self.write_object(&event.object, &event.filename, move |existing| {
675            let mut path = event.path;
676            path.remove_child(existing)?;
677            Ok(existing)
678        })?;
679        Ok(ArchivalEventResponse::None)
680    }
681
682    fn write_object(
683        &self,
684        obj_type: &str,
685        filename: &str,
686        obj_cb: impl FnOnce(&mut Object) -> Result<&mut Object>,
687    ) -> Result<()> {
688        debug!("write object {}: {}", obj_type, filename);
689        self.fs_mutex.with_fs(|fs| {
690            let path = self.object_path_impl(obj_type, filename, fs)?;
691            let contents = self.modify_object_file(obj_type, filename, obj_cb, fs)?;
692            fs.write_str(&path, contents)?;
693            self.site.invalidate_file(&path);
694            Ok(())
695        })
696    }
697
698    pub fn modify_manifest(&mut self, modify: impl FnOnce(&mut Manifest)) -> Result<()> {
699        self.fs_mutex.with_fs(|fs| {
700            self.site.modify_manifest(fs, modify)?;
701            Ok(())
702        })
703    }
704
705    /// Deletes all the object files for the given object types, except the ones
706    /// (optionally) specified in the list of keep_objects.
707    /// This only deletes the files, and does not generate events or rebuild the
708    /// archival site.
709    pub fn delete_objects(
710        &self,
711        object_names: impl IntoIterator<Item = impl AsRef<str>>,
712        keep_objects: Option<Vec<ValuePath>>,
713    ) -> Result<()> {
714        let objects = self.get_objects()?;
715        self.fs_mutex.with_fs(|fs| {
716            for on in object_names {
717                let object_name = on.as_ref();
718                let current_path: ValuePath =
719                    ValuePath::empty().append(ValuePath::key(object_name));
720                if keep_objects
721                    .as_ref()
722                    .is_some_and(|ko| ko.contains(&current_path))
723                {
724                    continue;
725                }
726                let entry = objects.get(object_name).ok_or_else(|| {
727                    ArchivalError::new(&format!("object {} does not exist", object_name))
728                })?;
729                let filenames = match entry {
730                    ObjectEntry::Object(object) => {
731                        vec![&object.filename]
732                    }
733                    ObjectEntry::List(objects) => objects.iter().map(|o| &o.filename).collect(),
734                };
735                for filename in filenames {
736                    let current_path = current_path.clone().append(ValuePath::key(filename));
737                    if keep_objects
738                        .as_ref()
739                        .is_some_and(|ko| ko.contains(&current_path))
740                    {
741                        continue;
742                    }
743                    let path = self.object_path_impl(object_name, filename, fs)?;
744                    fs.delete(&path)?;
745                    self.site.invalidate_file(&path);
746                }
747            }
748            Ok(())
749        })
750    }
751
752    pub fn take_fs(self) -> F {
753        self.fs_mutex.take_fs()
754    }
755    pub fn clone_fs(&self) -> Result<F> {
756        self.fs_mutex.with_fs(|fs| Ok(fs.clone()))
757    }
758}
759
760#[cfg(test)]
761mod lib {
762    use anyhow::Result;
763
764    use crate::{file_system::unpack_zip, test_utils::as_path_str, value_path::ValuePath};
765    use events::AddObjectValue;
766    use tracing_test::traced_test;
767
768    use super::*;
769
770    #[test]
771    #[traced_test]
772    fn load_and_build_site_from_zip() -> Result<()> {
773        let mut fs = MemoryFileSystem::default();
774        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
775        unpack_zip(zip.to_vec(), &mut fs)?;
776        let archival = Archival::new(fs)?;
777        assert_eq!(archival.site.object_definitions.len(), 4);
778        assert!(archival.site.object_definitions.contains_key("section"));
779        assert!(archival.site.object_definitions.contains_key("post"));
780        assert!(archival.site.object_definitions.contains_key("site"));
781        let objects = archival.get_objects()?;
782        let section_objs = objects.get("section").unwrap();
783        assert!(matches!(section_objs, ObjectEntry::List(_)));
784        let site_obj = objects.get("site").unwrap();
785        assert!(matches!(site_obj, ObjectEntry::Object(_)));
786        let post_obj = objects.get("post").unwrap();
787        assert!(matches!(post_obj, ObjectEntry::List(_)));
788        let fp = post_obj.into_iter().next().unwrap();
789        let m = ValuePath::from_string("media").get_in_object(fp).unwrap();
790        assert!(matches!(m, FieldValue::Oneof(..)));
791        let fc = &archival.site.field_config;
792        if let FieldValue::Oneof((t, val)) = m {
793            assert_eq!(t, "image");
794            assert!(matches!(**val, Some(FieldValue::File(_))));
795            if let Some(FieldValue::File(img)) = val.as_ref() {
796                assert_eq!(img.filename, "test.jpg");
797                assert_eq!(img.mime, "image/jpg");
798                assert_eq!(img.name, Some("Test".to_string()));
799                assert_eq!(img.sha, "test-sha");
800                assert_eq!(img.url(fc), "test://uploads-url/test-sha/test.jpg");
801            }
802        }
803        archival.build(BuildOptions::default())?;
804        let dist_files = archival
805            .dist_files()
806            .into_iter()
807            .map(|f| f.path.display().to_string())
808            .collect::<Vec<String>>();
809        println!("dist_files: \n{:#?}", dist_files);
810        assert!(dist_files.contains(&as_path_str("index.html")));
811        assert!(dist_files.contains(&as_path_str("404.html")));
812        assert!(dist_files.contains(&as_path_str("post/a-post.html")));
813        assert!(dist_files.contains(&as_path_str("img/guy.webp")));
814        assert!(dist_files.contains(&as_path_str("rss.rss")));
815        assert_eq!(dist_files.len(), 17);
816        let guy = archival.dist_file(Path::new("img/guy.webp"));
817        assert!(guy.is_some());
818        let post_html = archival
819            .fs_mutex
820            .with_fs(|fs| {
821                fs.read_to_string(archival.site.manifest.build_dir.join("post/a-post.html"))
822            })?
823            .unwrap();
824        println!("{}", post_html);
825        assert!(post_html.contains("test://uploads-url/test-sha/test.jpg"));
826        assert!(post_html.contains("title=\"Test\""));
827        Ok(())
828    }
829
830    #[test]
831    #[traced_test]
832    fn definitions_carry_their_objects_toml_comments() -> Result<()> {
833        let mut fs = MemoryFileSystem::default();
834        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
835        unpack_zip(zip.to_vec(), &mut fs)?;
836        let archival = Archival::new(fs)?;
837
838        let post = archival.get_object_definition("post")?;
839        assert_eq!(post.description, Some("An entry on the blog.".to_string()));
840
841        let described = |field: &str| post.fields.get(field).unwrap().description.clone();
842        assert_eq!(
843            described("title"),
844            Some("The post's headline.\nUsed for the page title as well.".to_string())
845        );
846        // An enum, declared as an array of strings.
847        assert_eq!(
848            described("state"),
849            Some("Whether the post is visible on the site.".to_string())
850        );
851        // A oneof, declared as `[[post.media]]`.
852        assert_eq!(
853            described("media"),
854            Some("A single piece of media to show alongside the post.".to_string())
855        );
856        // A child object, declared as `[post.links]`.
857        assert_eq!(
858            post.children.get("links").unwrap().description,
859            Some("Related links to show at the end of the post.".to_string())
860        );
861
862        // The fixture opens with a file header separated by a blank line, which
863        // must not be read as a description of the first object.
864        let section = archival.get_object_definition("section")?;
865        assert_eq!(section.description, None);
866        assert_eq!(section.fields.get("name").unwrap().description, None);
867        Ok(())
868    }
869
870    #[test]
871    fn add_object_to_site() -> Result<()> {
872        let mut fs = MemoryFileSystem::default();
873        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
874        unpack_zip(zip.to_vec(), &mut fs)?;
875        let archival = Archival::new(fs)?;
876        archival.send_event(
877            ArchivalEvent::AddObject(AddObjectEvent {
878                object: "section".to_string(),
879                filename: "my-section".to_string(),
880                order: Some(3.),
881                // Sections require a name field, so we have to add it or we'll get a build error
882                values: vec![AddObjectValue {
883                    path: ValuePath::from_string("name"),
884                    value: FieldValue::String("section three".to_string()),
885                }],
886            }),
887            Some(BuildOptions::default()),
888        )?;
889        // Sending an event should result in an updated fs
890        let sections_dir = archival.site.manifest.objects_dir.join("section");
891        let sections = archival.fs_mutex.with_fs(|fs| {
892            fs.walk_dir(&sections_dir, false)
893                .map(|d| d.collect::<Vec<PathBuf>>())
894        })?;
895        println!("SECTIONS: {:?}", sections);
896        assert_eq!(sections.len(), 3);
897        let section_toml = archival
898            .fs_mutex
899            .with_fs(|fs| fs.read_to_string(sections_dir.join("my-section.toml")));
900        assert!(section_toml.is_ok());
901        let index_html = archival
902            .fs_mutex
903            .with_fs(|fs| fs.read_to_string(archival.site.manifest.build_dir.join("index.html")))?
904            .unwrap();
905        let rendered_sections: Vec<_> = index_html.match_indices("<h2>").collect();
906        println!("MATCHED: {:?}", rendered_sections);
907        assert_eq!(rendered_sections.len(), 3);
908        Ok(())
909    }
910
911    #[test]
912    fn edit_object() -> Result<()> {
913        let mut fs = MemoryFileSystem::default();
914        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
915        unpack_zip(zip.to_vec(), &mut fs)?;
916        let archival = Archival::new(fs)?;
917        archival.send_event(
918            ArchivalEvent::EditField(EditFieldEvent {
919                object: "section".to_string(),
920                filename: "first".to_string(),
921                path: ValuePath::empty(),
922                field: "name".to_string(),
923                value: Some(FieldValue::String("This is the new name".to_string())),
924                source: None,
925            }),
926            Some(BuildOptions::default()),
927        )?;
928        // Sending an event should result in an updated fs
929        let index_html = archival
930            .fs_mutex
931            .with_fs(|fs| fs.read_to_string(archival.site.manifest.build_dir.join("index.html")))?
932            .unwrap();
933        println!("index: {}", index_html);
934        assert!(index_html.contains("This is the new name"));
935        Ok(())
936    }
937
938    #[test]
939    fn delete_object() -> Result<()> {
940        let mut fs = MemoryFileSystem::default();
941        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
942        unpack_zip(zip.to_vec(), &mut fs)?;
943        let archival = Archival::new(fs)?;
944        archival.send_event(
945            ArchivalEvent::DeleteObject(DeleteObjectEvent {
946                object: "section".to_string(),
947                filename: "first".to_string(),
948                source: None,
949            }),
950            Some(BuildOptions::default()),
951        )?;
952        // Sending an event should result in an updated fs
953        let sections_dir = archival.site.manifest.objects_dir.join("section");
954        let sections = archival.fs_mutex.with_fs(|fs| {
955            fs.walk_dir(&sections_dir, false)
956                .map(|d| d.collect::<Vec<PathBuf>>())
957        })?;
958        println!("SECTIONS: {:?}", sections);
959        assert_eq!(sections.len(), 1);
960        let index_html = archival
961            .fs_mutex
962            .with_fs(|fs| fs.read_to_string(archival.site.manifest.build_dir.join("index.html")))?
963            .unwrap();
964        println!("index: {}", index_html);
965        assert!(!index_html.contains("This is the new title"));
966        Ok(())
967    }
968
969    #[test]
970    fn rename_object() -> Result<()> {
971        let mut fs = MemoryFileSystem::default();
972        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
973        unpack_zip(zip.to_vec(), &mut fs)?;
974        let archival = Archival::new(fs)?;
975        let sections_dir = archival.site.manifest.objects_dir.join("section");
976        let sections_before_rename = archival.fs_mutex.with_fs(|fs| {
977            fs.walk_dir(&sections_dir, false)
978                .map(|d| d.collect::<Vec<PathBuf>>())
979        })?;
980        archival.send_event(
981            ArchivalEvent::RenameObject(RenameObjectEvent {
982                object: "section".to_string(),
983                from: "first".to_string(),
984                to: "renamed".to_string(),
985            }),
986            Some(BuildOptions::default()),
987        )?;
988        // Sending an event should result in an updated fs
989        let sections_dir = archival.site.manifest.objects_dir.join("section");
990        let sections = archival.fs_mutex.with_fs(|fs| {
991            fs.walk_dir(&sections_dir, false)
992                .map(|d| d.collect::<Vec<PathBuf>>())
993        })?;
994        println!("SECTIONS: {:?}", sections);
995        assert_eq!(sections.len(), sections_before_rename.len());
996        assert!(sections.iter().any(|path| path.ends_with("renamed.toml")));
997        Ok(())
998    }
999
1000    #[test]
1001    fn rename_object_with_modifications() -> Result<()> {
1002        let mut fs = MemoryFileSystem::default();
1003        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
1004        unpack_zip(zip.to_vec(), &mut fs)?;
1005        let archival = Archival::new(fs)?;
1006        let sections_dir = archival.site.manifest.objects_dir.join("section");
1007        let sections_before_rename = archival.fs_mutex.with_fs(|fs| {
1008            fs.walk_dir(&sections_dir, false)
1009                .map(|d| d.collect::<Vec<PathBuf>>())
1010        })?;
1011        archival.send_event(
1012            ArchivalEvent::EditField(EditFieldEvent {
1013                object: "section".to_string(),
1014                filename: "first".to_string(),
1015                path: ValuePath::empty(),
1016                field: "name".to_string(),
1017                value: Some(FieldValue::String("This is the new name".to_string())),
1018                source: None,
1019            }),
1020            Some(BuildOptions::default()),
1021        )?;
1022        archival.send_event(
1023            ArchivalEvent::RenameObject(RenameObjectEvent {
1024                object: "section".to_string(),
1025                from: "first".to_string(),
1026                to: "renamed".to_string(),
1027            }),
1028            Some(BuildOptions::default()),
1029        )?;
1030        // Sending an event should result in an updated fs
1031        let sections_dir = archival.site.manifest.objects_dir.join("section");
1032        let sections = archival.fs_mutex.with_fs(|fs| {
1033            fs.walk_dir(&sections_dir, false)
1034                .map(|d| d.collect::<Vec<PathBuf>>())
1035        })?;
1036        println!("SECTIONS: {:?}", sections);
1037        assert_eq!(sections.len(), sections_before_rename.len());
1038        assert!(sections.iter().any(|path| path.ends_with("renamed.toml")));
1039        let renamed = archival
1040            .get_object("section", Some("renamed"))
1041            .expect("missing renamed object");
1042        let name = ValuePath::from_string("name")
1043            .get_in_object(&renamed)
1044            .expect("missing name");
1045        if let FieldValue::String(name) = name {
1046            assert_eq!(name, "This is the new name");
1047        } else {
1048            panic!("name not string");
1049        }
1050        archival.send_event(
1051            ArchivalEvent::EditField(EditFieldEvent {
1052                object: "section".to_string(),
1053                filename: "renamed".to_string(),
1054                path: ValuePath::empty(),
1055                field: "name".to_string(),
1056                value: Some(FieldValue::String("This is another name".to_string())),
1057                source: None,
1058            }),
1059            Some(BuildOptions::default()),
1060        )?;
1061        let renamed = archival
1062            .get_object("section", Some("renamed"))
1063            .expect("missing renamed object");
1064        let name = ValuePath::from_string("name")
1065            .get_in_object(&renamed)
1066            .expect("missing name");
1067        if let FieldValue::String(name) = name {
1068            assert_eq!(name, "This is another name");
1069        } else {
1070            panic!("name not string");
1071        }
1072        Ok(())
1073    }
1074
1075    #[test]
1076    #[traced_test]
1077    fn edit_object_order() -> Result<()> {
1078        let mut fs = MemoryFileSystem::default();
1079        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
1080        unpack_zip(zip.to_vec(), &mut fs)?;
1081        let archival = Archival::new(fs)?;
1082        archival.build(BuildOptions::default())?;
1083        let index_html = archival
1084            .fs_mutex
1085            .with_fs(|fs| fs.read_to_string(archival.site.manifest.build_dir.join("index.html")))?
1086            .unwrap();
1087        println!("index: {}", index_html);
1088        let c1 = index_html.find("1 Some Content").unwrap();
1089        let c2 = index_html.find("2 More Content").unwrap();
1090        assert!(c1 < c2);
1091        archival.send_event(
1092            ArchivalEvent::EditOrder(EditOrderEvent {
1093                object: "section".to_string(),
1094                filename: "first".to_string(),
1095                order: Some(12.),
1096                source: None,
1097            }),
1098            Some(BuildOptions::default()),
1099        )?;
1100        // Sending an event should result in an updated fs
1101        let index_html = archival
1102            .fs_mutex
1103            .with_fs(|fs| fs.read_to_string(archival.site.manifest.build_dir.join("index.html")))?
1104            .unwrap();
1105        println!("index: {}", index_html);
1106        let c1 = index_html.find("12 Some Content").unwrap();
1107        let c2 = index_html.find("2 More Content").unwrap();
1108        assert!(c2 < c1);
1109        Ok(())
1110    }
1111
1112    #[test]
1113    #[traced_test]
1114    fn add_child() {
1115        let mut fs = MemoryFileSystem::default();
1116        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
1117        unpack_zip(zip.to_vec(), &mut fs).unwrap();
1118        let archival = Archival::new(fs).unwrap();
1119        archival.build(BuildOptions::default()).unwrap();
1120        let post_html = archival
1121            .fs_mutex
1122            .with_fs(|fs| {
1123                fs.read_to_string(
1124                    archival
1125                        .site
1126                        .manifest
1127                        .build_dir
1128                        .join(Path::new("post/a-post.html")),
1129                )
1130            })
1131            .unwrap()
1132            .unwrap();
1133        // println!("post: {}", post_html);
1134        let rendered_links: Vec<_> = post_html.match_indices("<a href=").collect();
1135        // println!("LINKS: {:?}", rendered_links);
1136        assert_eq!(rendered_links.len(), 2);
1137        archival
1138            .send_event(
1139                ArchivalEvent::AddChild(AddChildEvent {
1140                    object: "post".to_string(),
1141                    filename: "a-post".to_string(),
1142                    path: ValuePath::default().append(ValuePath::key("links")),
1143                    values: vec![
1144                        AddObjectValue {
1145                            path: ValuePath::from_string("url"),
1146                            value: FieldValue::String("http://foo.com".to_string()),
1147                        },
1148                        AddObjectValue {
1149                            path: ValuePath::from_string("name"),
1150                            value: FieldValue::String("another link".to_string()),
1151                        },
1152                    ],
1153                    index: None,
1154                }),
1155                Some(BuildOptions::no_static()),
1156            )
1157            .unwrap();
1158        let objects = archival.get_objects().unwrap();
1159        let posts = objects.get("post").unwrap();
1160        let mut found = false;
1161        for post in posts {
1162            if post.filename == "a-post" {
1163                found = true;
1164                let links = ValuePath::from_string("links").get_in_object(post).unwrap();
1165                assert!(matches!(links, FieldValue::Objects(_)));
1166                if let FieldValue::Objects(links) = links {
1167                    assert_eq!(links.len(), 3);
1168                }
1169            }
1170        }
1171        assert!(found, "a-post not found in posts: {:?}", posts);
1172        // Sending an event should result in an updated fs
1173        let post_html = archival
1174            .fs_mutex
1175            .with_fs(|fs| {
1176                fs.read_to_string(
1177                    archival
1178                        .site
1179                        .manifest
1180                        .build_dir
1181                        .join(Path::new("post/a-post.html")),
1182                )
1183            })
1184            .unwrap()
1185            .unwrap();
1186        println!("post: {}", post_html);
1187        let rendered_links: Vec<_> = post_html.match_indices("<a href=").collect();
1188        println!("LINKS: {:?}", rendered_links);
1189        assert_eq!(rendered_links.len(), 3);
1190        let rendered_link_url: Vec<_> = post_html.match_indices("foo.com").collect();
1191        assert_eq!(rendered_link_url.len(), 1);
1192        let rendered_link_name: Vec<_> = post_html.match_indices("another link").collect();
1193        assert_eq!(rendered_link_name.len(), 1);
1194    }
1195
1196    #[test]
1197    fn add_child_after_add_object() -> Result<()> {
1198        let mut fs = MemoryFileSystem::default();
1199        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
1200        unpack_zip(zip.to_vec(), &mut fs)?;
1201        let archival = Archival::new(fs)?;
1202        archival.build(BuildOptions::default())?;
1203        archival.send_event(
1204            ArchivalEvent::AddObject(AddObjectEvent {
1205                object: "post".to_string(),
1206                filename: "new-name".to_string(),
1207                order: None,
1208                values: vec![
1209                    AddObjectValue {
1210                        path: ValuePath::from_string("title"),
1211                        value: FieldValue::String("new-name".to_string()),
1212                    },
1213                    AddObjectValue {
1214                        path: ValuePath::from_string("date"),
1215                        value: FieldValue::Date(fields::DateTime::from_ymd(2024, 4, 22)),
1216                    },
1217                    AddObjectValue {
1218                        path: ValuePath::from_string("content"),
1219                        value: FieldValue::Markdown("content".to_string()),
1220                    },
1221                ],
1222            }),
1223            None,
1224        )?;
1225
1226        let result = archival.send_event(
1227            ArchivalEvent::AddChild(AddChildEvent {
1228                object: "post".to_string(),
1229                filename: "new-name".to_string(),
1230                path: ValuePath::default().append(ValuePath::key("links")),
1231                values: vec![
1232                    AddObjectValue {
1233                        path: ValuePath::from_string("name"),
1234                        value: FieldValue::String("added child".to_string()),
1235                    },
1236                    AddObjectValue {
1237                        path: ValuePath::from_string("url"),
1238                        value: FieldValue::String("https://foo.test".to_string()),
1239                    },
1240                ],
1241                index: None,
1242            }),
1243            None,
1244        )?;
1245        assert!(matches!(result, ArchivalEventResponse::Index(0)));
1246
1247        let post = archival.get_object("post", Some("new-name"))?;
1248        let links = ValuePath::from_string("links")
1249            .get_in_object(&post)
1250            .unwrap();
1251        assert!(matches!(links, FieldValue::Objects(_)));
1252        if let FieldValue::Objects(links) = links {
1253            assert_eq!(links.len(), 1);
1254        }
1255
1256        Ok(())
1257    }
1258
1259    #[test]
1260    fn remove_child() -> Result<()> {
1261        let mut fs = MemoryFileSystem::default();
1262        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
1263        unpack_zip(zip.to_vec(), &mut fs)?;
1264        let archival = Archival::new(fs)?;
1265        archival
1266            .send_event(
1267                ArchivalEvent::RemoveChild(RemoveChildEvent {
1268                    object: "post".to_string(),
1269                    filename: "a-post".to_string(),
1270                    path: ValuePath::default()
1271                        .append(ValuePath::key("links"))
1272                        .append(ValuePath::index(0)),
1273                    source: None,
1274                }),
1275                Some(BuildOptions::default()),
1276            )
1277            .unwrap();
1278        // Sending an event should result in an updated fs
1279        let post_html = archival
1280            .fs_mutex
1281            .with_fs(|fs| {
1282                fs.read_to_string(
1283                    archival
1284                        .site
1285                        .manifest
1286                        .build_dir
1287                        .join(Path::new("post/a-post.html")),
1288                )
1289            })?
1290            .unwrap();
1291        println!("post: {}", post_html);
1292        let rendered_links: Vec<_> = post_html.match_indices("<a href=").collect();
1293        println!("LINKS: {:?}", rendered_links);
1294        assert_eq!(rendered_links.len(), 1);
1295        Ok(())
1296    }
1297
1298    #[test]
1299    fn modify_manifest() -> Result<()> {
1300        let mut fs = MemoryFileSystem::default();
1301        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
1302        unpack_zip(zip.to_vec(), &mut fs)?;
1303        let mut archival = Archival::new(fs)?;
1304        archival.modify_manifest(|m| {
1305            m.site_url = Some("test.com".to_string());
1306            m.prebuild = vec!["test".to_string()];
1307        })?;
1308        let output = archival.site.manifest.to_toml()?;
1309        println!("{}", output);
1310        assert!(output.contains("site_url = \"test.com\""));
1311        // Doesn't fill defaults
1312        assert!(!output.contains("objects_dir"));
1313        assert!(!output.contains("objects"));
1314        // Does show non-defaults
1315        assert!(output.contains("prebuild = [\"test\"]"));
1316        Ok(())
1317    }
1318
1319    #[test]
1320    fn bulk_delete_objects() -> Result<()> {
1321        let mut fs = MemoryFileSystem::default();
1322        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
1323        unpack_zip(zip.to_vec(), &mut fs)?;
1324        let archival = Archival::new(fs)?;
1325        archival.delete_objects(
1326            vec!["section", "site"],
1327            Some(vec![ValuePath::from_string("section.second")]),
1328        )?;
1329        // This should result in the relevant files being missing
1330        let sections_dir = archival.site.manifest.objects_dir.join("section");
1331        let sections = archival.fs_mutex.with_fs(|fs| {
1332            fs.walk_dir(&sections_dir, false)
1333                .map(|d| d.collect::<Vec<PathBuf>>())
1334        })?;
1335        println!("SECTIONS: {:?}", sections);
1336        assert_eq!(sections.len(), 1);
1337        let site_file_exists = archival
1338            .fs_mutex
1339            .with_fs(|fs| fs.exists(archival.site.manifest.objects_dir.join("site.toml")))?;
1340        assert!(!site_file_exists);
1341        Ok(())
1342    }
1343
1344    #[test]
1345    fn edit_enum() -> Result<()> {
1346        let mut fs = MemoryFileSystem::default();
1347        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
1348        unpack_zip(zip.to_vec(), &mut fs)?;
1349        let archival = Archival::new(fs)?;
1350        archival.send_event(
1351            ArchivalEvent::EditField(EditFieldEvent {
1352                object: "post".to_string(),
1353                filename: "a-post".to_string(),
1354                path: ValuePath::empty(),
1355                field: "state".to_string(),
1356                value: Some(FieldValue::Enum("draft".to_string())),
1357                source: None,
1358            }),
1359            Some(BuildOptions::default()),
1360        )?;
1361        let post_html = archival
1362            .fs_mutex
1363            .with_fs(|fs| {
1364                fs.read_to_string(
1365                    archival
1366                        .site
1367                        .manifest
1368                        .build_dir
1369                        .join(Path::new("post/a-post.html")),
1370                )
1371            })?
1372            .unwrap();
1373        println!("post: {}", post_html);
1374        assert!(post_html.contains("State: draft"));
1375        Ok(())
1376    }
1377    #[test]
1378    fn edit_enum_fails_when_invalid() -> Result<()> {
1379        let mut fs = MemoryFileSystem::default();
1380        let zip = include_bytes!("../tests/fixtures/archival-website.zip");
1381        unpack_zip(zip.to_vec(), &mut fs)?;
1382        let archival = Archival::new(fs)?;
1383        assert!(archival
1384            .send_event(
1385                ArchivalEvent::EditField(EditFieldEvent {
1386                    object: "post".to_string(),
1387                    filename: "a-post".to_string(),
1388                    path: ValuePath::empty(),
1389                    field: "state".to_string(),
1390                    value: Some(FieldValue::Enum("poopoo".to_string())),
1391                    source: None,
1392                }),
1393                Some(BuildOptions::default()),
1394            )
1395            .is_err());
1396        Ok(())
1397    }
1398
1399    fn named_site_fs(manifest: &str, objects: &str, site_url: &str) -> Result<MemoryFileSystem> {
1400        let mut fs = MemoryFileSystem::default();
1401        fs.write_str(
1402            manifest,
1403            format!("upload_prefix = \"\"\nsite_url = \"{}\"\n", site_url),
1404        )?;
1405        fs.write_str(objects, "[site]\nname = \"string\"\n".to_string())?;
1406        Ok(fs)
1407    }
1408
1409    #[test]
1410    fn legacy_file_names_are_still_supported() -> Result<()> {
1411        let fs = named_site_fs(
1412            LEGACY_MANIFEST_FILE_NAME,
1413            LEGACY_OBJECT_DEFINITION_FILE_NAME,
1414            "legacy.example",
1415        )?;
1416        let mut archival = Archival::new(fs)?;
1417        assert_eq!(
1418            archival.site.manifest.site_url.as_deref(),
1419            Some("legacy.example")
1420        );
1421        assert_eq!(
1422            archival.site.manifest.object_definition_file,
1423            Path::new(LEGACY_OBJECT_DEFINITION_FILE_NAME).to_path_buf()
1424        );
1425        // Writes go back to the file the site already has rather than creating
1426        // a second manifest under the canonical name.
1427        archival.modify_manifest(|m| m.site_url = Some("legacy-edited.example".to_string()))?;
1428        archival.fs_mutex.with_fs(|fs| {
1429            assert!(!fs.exists(Path::new(MANIFEST_FILE_NAME))?);
1430            let written = fs
1431                .read_to_string(Path::new(LEGACY_MANIFEST_FILE_NAME))?
1432                .unwrap_or_default();
1433            assert!(
1434                written.contains("site_url = \"legacy-edited.example\""),
1435                "{}",
1436                written
1437            );
1438            // A legacy object definition file is still a default, so it isn't
1439            // written out as an explicit object_file.
1440            assert!(!written.contains("object_file"), "{}", written);
1441            Ok(())
1442        })?;
1443        Ok(())
1444    }
1445
1446    #[test]
1447    fn canonical_file_names_take_precedence() -> Result<()> {
1448        let mut fs = named_site_fs(
1449            MANIFEST_FILE_NAME,
1450            OBJECT_DEFINITION_FILE_NAME,
1451            "canonical.example",
1452        )?;
1453        fs.write_str(
1454            LEGACY_MANIFEST_FILE_NAME,
1455            "upload_prefix = \"\"\nsite_url = \"legacy.example\"\n".to_string(),
1456        )?;
1457        fs.write_str(
1458            LEGACY_OBJECT_DEFINITION_FILE_NAME,
1459            "[legacy_only]\nname = \"string\"\n".to_string(),
1460        )?;
1461        let mut archival = Archival::new(fs)?;
1462        assert_eq!(
1463            archival.site.manifest.site_url.as_deref(),
1464            Some("canonical.example")
1465        );
1466        assert!(archival.site.object_definitions.contains_key("site"));
1467        assert!(!archival.site.object_definitions.contains_key("legacy_only"));
1468        archival.modify_manifest(|m| m.site_url = Some("canonical-edited.example".to_string()))?;
1469        archival.fs_mutex.with_fs(|fs| {
1470            let legacy = fs
1471                .read_to_string(Path::new(LEGACY_MANIFEST_FILE_NAME))?
1472                .unwrap_or_default();
1473            assert!(!legacy.contains("canonical-edited"), "{}", legacy);
1474            let canonical = fs
1475                .read_to_string(Path::new(MANIFEST_FILE_NAME))?
1476                .unwrap_or_default();
1477            assert!(
1478                canonical.contains("site_url = \"canonical-edited.example\""),
1479                "{}",
1480                canonical
1481            );
1482            Ok(())
1483        })?;
1484        Ok(())
1485    }
1486
1487    #[test]
1488    fn an_explicit_object_file_is_used_as_written() -> Result<()> {
1489        let mut fs = named_site_fs(
1490            MANIFEST_FILE_NAME,
1491            LEGACY_OBJECT_DEFINITION_FILE_NAME,
1492            "explicit.example",
1493        )?;
1494        fs.write_str(
1495            MANIFEST_FILE_NAME,
1496            "upload_prefix = \"\"\nobject_file = \"custom_objects.toml\"\n".to_string(),
1497        )?;
1498        fs.write_str(
1499            "custom_objects.toml",
1500            "[custom]\nname = \"string\"\n".to_string(),
1501        )?;
1502        let archival = Archival::new(fs)?;
1503        assert_eq!(
1504            archival.site.manifest.object_definition_file,
1505            Path::new("custom_objects.toml").to_path_buf()
1506        );
1507        assert!(archival.site.object_definitions.contains_key("custom"));
1508        let written = archival.site.manifest.to_toml()?;
1509        assert!(
1510            written.contains("object_file = \"custom_objects.toml\""),
1511            "{}",
1512            written
1513        );
1514        Ok(())
1515    }
1516}
1517
1518#[cfg(test)]
1519#[cfg(feature = "typescript")]
1520mod typescript_definitions {
1521    use typescript_type_def::{write_definition_file, DefinitionFileOptions};
1522    use value_path::ValuePath;
1523
1524    use crate::{
1525        fields::FieldType,
1526        object::{RenderedObject, RenderedObjectEntry},
1527    };
1528
1529    use super::*;
1530
1531    #[test]
1532    fn run() {
1533        let mut buf = Vec::new();
1534        let options = DefinitionFileOptions {
1535            header: Some("// AUTO-GENERATED by typescript-type-def\n"),
1536            root_namespace: None,
1537        };
1538        type ExportedTypes = (
1539            ArchivalEvent,
1540            ObjectDefinition,
1541            ValuePath,
1542            FieldType,
1543            RenderedObject,
1544            RenderedObjectEntry,
1545        );
1546        write_definition_file::<_, ExportedTypes>(&mut buf, options).unwrap();
1547    }
1548}