noosphere_cli/native/render/
writer.rs

1use anyhow::{anyhow, Result};
2use cid::Cid;
3use noosphere_core::context::{AsyncFileBody, SphereFile};
4use noosphere_core::data::Did;
5use pathdiff::diff_paths;
6use std::{
7    path::{Path, PathBuf},
8    sync::{Arc, OnceLock},
9};
10use symlink::{remove_symlink_dir, remove_symlink_file, symlink_dir, symlink_file};
11use tokio::{fs::File, io::copy};
12
13use crate::native::paths::{
14    SpherePaths, IDENTITY_FILE, LINK_RECORD_FILE, MOUNT_DIRECTORY, VERSION_FILE,
15};
16
17use super::JobKind;
18
19/// A [SphereWriter] encapsulates most file system operations to a workspace. It
20/// enables batch operations to be performed via domain-specific verbs. Some of
21/// its implementation will vary based on [JobKind] due to the asymmetric
22/// structural representation of root and peer spheres when rendered to a
23/// filesystem.
24#[derive(Debug, Clone)]
25pub struct SphereWriter {
26    kind: JobKind,
27    paths: Arc<SpherePaths>,
28    base: OnceLock<PathBuf>,
29    mount: OnceLock<PathBuf>,
30    private: OnceLock<PathBuf>,
31}
32
33impl SphereWriter {
34    /// Construct a [SphereWriter] with the given [JobKind] and initialized
35    /// [SpherePaths].
36    pub fn new(kind: JobKind, paths: Arc<SpherePaths>) -> Self {
37        SphereWriter {
38            kind,
39            paths,
40            base: Default::default(),
41            mount: Default::default(),
42            private: Default::default(),
43        }
44    }
45
46    fn is_root_writer(&self) -> bool {
47        matches!(self.kind, JobKind::Root { .. })
48    }
49
50    fn petname(&self, name: &str) -> PathBuf {
51        self.mount().join(format!("@{}", name))
52    }
53
54    /// The path to the directory where content and peers will be rendered to
55    pub fn mount(&self) -> &Path {
56        self.mount.get_or_init(|| match &self.kind {
57            JobKind::Root { .. } | JobKind::RefreshPeers => self.base().to_owned(),
58            JobKind::Peer(_, _, _) => self.base().join(MOUNT_DIRECTORY),
59        })
60    }
61
62    /// The path to the root directory associated with this sphere, varying
63    /// based on [JobKind]
64    pub fn base(&self) -> &Path {
65        self.base.get_or_init(|| match &self.kind {
66            JobKind::Root { .. } | JobKind::RefreshPeers => self.paths.root().to_owned(),
67            JobKind::Peer(did, cid, _) => self.paths.peer(did, cid),
68        })
69    }
70
71    /// The path to the directory containing "private" structural information
72    pub fn private(&self) -> &Path {
73        self.private.get_or_init(|| match &self.kind {
74            JobKind::Root { .. } | JobKind::RefreshPeers => self.paths().sphere().to_owned(),
75            JobKind::Peer(_, _, _) => self.base().to_owned(),
76        })
77    }
78
79    /// A reference to the [SpherePaths] in use by this [SphereWriter]
80    pub fn paths(&self) -> &SpherePaths {
81        &self.paths
82    }
83
84    /// Write the [LinkRecord] to an appropriate location on disk (a noop for [JobKind]s that
85    /// do not have a [LinkRecord])
86    pub async fn write_link_record(&self) -> Result<()> {
87        if let JobKind::Peer(_, _, link_record) = &self.kind {
88            tokio::fs::write(self.private().join(LINK_RECORD_FILE), link_record.encode()?).await?;
89        }
90        Ok(())
91    }
92
93    /// Write the identity and version to an appropriate location on disk
94    pub async fn write_identity_and_version(&self, identity: &Did, version: &Cid) -> Result<()> {
95        let private = self.private();
96
97        tokio::try_join!(
98            tokio::fs::write(private.join(IDENTITY_FILE), identity.to_string()),
99            tokio::fs::write(private.join(VERSION_FILE), version.to_string())
100        )?;
101
102        Ok(())
103    }
104
105    /// Resolves the path to the hard link-equivalent file that contains the
106    /// content for this slug. A [SphereFile] is required because we need a
107    /// [MemoIpld] in the case of rendering root, and we need the [Cid] of that
108    /// [MemoIpld] when rendering a peer. Both are conveniently bundled by
109    /// a [SphereFile].
110    pub fn content_hard_link<R>(&self, slug: &str, file: &SphereFile<R>) -> Result<PathBuf> {
111        if self.is_root_writer() {
112            self.paths.root_hard_link(slug, &file.memo)
113        } else {
114            Ok(self.paths.peer_hard_link(&file.memo_version))
115        }
116    }
117
118    /// Remove a link from the filesystem for a given slug
119    #[instrument(level = "trace")]
120    pub async fn remove_content(&self, slug: &str) -> Result<()> {
121        if self.is_root_writer() {
122            let slug_path = self.paths.slug(slug)?;
123
124            if !slug_path.exists() {
125                trace!(
126                    "No slug link found at '{}', skipping removal of '{slug}'...",
127                    slug_path.display()
128                );
129            }
130
131            let file_path = tokio::fs::read_link(&slug_path).await?;
132
133            let _ = remove_symlink_file(slug_path);
134
135            if file_path.exists() {
136                trace!("Removing '{}'", file_path.display());
137                tokio::fs::remove_file(&file_path).await?;
138            }
139
140            Ok(())
141        } else {
142            Err(anyhow!("Cannot 'remove' individual peer content"))
143        }
144    }
145
146    /// Write content to the file system for a given slug
147    #[instrument(level = "trace", skip(file))]
148    pub async fn write_content<R>(&self, slug: &str, file: &mut SphereFile<R>) -> Result<()>
149    where
150        R: AsyncFileBody,
151    {
152        let file_path = self.content_hard_link(slug, file)?;
153
154        trace!("Final file path will be '{}'", file_path.display());
155
156        let file_directory = file_path
157            .parent()
158            .ok_or_else(|| anyhow!("Unable to determine base directory for '{}'", slug))?;
159
160        tokio::fs::create_dir_all(file_directory).await?;
161
162        match tokio::fs::try_exists(&file_path).await {
163            Ok(true) => {
164                trace!("'{}' content already exists, not re-rendering...", slug);
165            }
166            Err(error) => {
167                warn!("Error checking for existing file: {}", error);
168            }
169            _ => {
170                debug!("Rendering content for '{}'...", slug);
171                let mut fs_file = File::create(&file_path).await?;
172                copy(&mut file.contents, &mut fs_file).await?;
173            }
174        };
175
176        // If we are writing root, we need to symlink from inside .sphere to the
177        // rendered file (we use this backlink to determine how moves / deletes
178        // should be recorded when saving); if we are writing to a peer, we need
179        // to symlink from the end-user visible filesystem location into the
180        // content location within .sphere (so the links go the other
181        // direction).
182        if self.is_root_writer() {
183            self.symlink_slug(slug, &file_path).await?;
184        } else {
185            self.symlink_content(
186                &file.memo_version,
187                &self.paths.file(self.mount(), slug, &file.memo)?,
188            )
189            .await?;
190        }
191
192        Ok(())
193    }
194
195    /// Remove a symlink to a peer by petname
196    pub async fn unlink_peer(&self, petname: &str) -> Result<()> {
197        let absolute_peer_destination = self.petname(petname);
198        if absolute_peer_destination.is_symlink() {
199            trace!(?petname, ?absolute_peer_destination, "Unlinking peer");
200            remove_symlink_dir(absolute_peer_destination)?;
201        }
202        Ok(())
203    }
204
205    /// Create a symlink to a peer, given a [Did], sphere version [Cid] and petname
206    pub async fn symlink_peer(&self, peer: &Did, version: &Cid, petname: &str) -> Result<()> {
207        let absolute_peer_destination = self.petname(petname);
208        let peer_directory_path = absolute_peer_destination.parent().ok_or_else(|| {
209            anyhow!(
210                "Unable to determine base directory for '{}'",
211                absolute_peer_destination.display()
212            )
213        })?;
214
215        tokio::fs::create_dir_all(peer_directory_path).await?;
216
217        let relative_peer_source = diff_paths(
218            self.paths.peer(peer, version).join(MOUNT_DIRECTORY),
219            self.mount(),
220        )
221        .ok_or_else(|| anyhow!("Could not resolve relative path for to '@{petname}'",))?;
222
223        self.unlink_peer(petname).await?;
224
225        trace!(?petname, ?absolute_peer_destination, "Symlinking peer");
226
227        symlink_dir(relative_peer_source, absolute_peer_destination)?;
228
229        Ok(())
230    }
231
232    async fn symlink_content(&self, memo_cid: &Cid, file_path: &PathBuf) -> Result<()> {
233        let file_directory_path = file_path.parent().ok_or_else(|| {
234            anyhow!(
235                "Unable to determine base directory for '{}'",
236                file_path.display()
237            )
238        })?;
239
240        tokio::fs::create_dir_all(file_directory_path).await?;
241
242        let relative_peer_content_path =
243            diff_paths(self.paths.peer_hard_link(memo_cid), file_directory_path).ok_or_else(
244                || {
245                    anyhow!(
246                        "Could not resolve relative path for '{}'",
247                        file_path.display()
248                    )
249                },
250            )?;
251
252        trace!(
253            "Symlinking from '{}' to '{}'...",
254            relative_peer_content_path.display(),
255            file_path.display()
256        );
257
258        if file_path.exists() {
259            remove_symlink_file(file_path)?;
260        }
261
262        symlink_file(relative_peer_content_path, file_path)?;
263
264        Ok(())
265    }
266
267    async fn symlink_slug(&self, slug: &str, file_path: &PathBuf) -> Result<()> {
268        let slug_path = self.paths.slug(slug)?;
269        let slug_base = slug_path
270            .parent()
271            .ok_or_else(|| anyhow!("Can't resolve parent directory of {}", slug_path.display()))?;
272
273        let relative_file_path = diff_paths(file_path, slug_base).ok_or_else(|| {
274            anyhow!(
275                "Could not resolve relative path for '{}'",
276                file_path.display()
277            )
278        })?;
279
280        if slug_path.exists() {
281            remove_symlink_file(&slug_path)?;
282        }
283
284        symlink_file(relative_file_path, slug_path)?;
285
286        Ok(())
287    }
288}