noosphere_cli/native/
workspace.rs

1//! Operations that are common to most CLI commands
2
3use anyhow::{anyhow, Result};
4use cid::Cid;
5use directories::ProjectDirs;
6use noosphere::{platform::PlatformStorage, sphere::SphereContextBuilder};
7use noosphere_core::authority::Author;
8use noosphere_core::context::{
9    SphereContentRead, SphereContext, SphereCursor, COUNTERPART, GATEWAY_URL,
10};
11use noosphere_core::data::{Did, Link, LinkRecord, MemoIpld};
12use noosphere_storage::{KeyValueStore, SphereDb, StorageConfig};
13use serde_json::Value;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use tokio::io::AsyncReadExt;
17use ucan::crypto::KeyMaterial;
18use url::Url;
19
20use noosphere::key::InsecureKeyStorage;
21use tokio::sync::{Mutex, OnceCell};
22
23use crate::native::paths::{IDENTITY_FILE, LINK_RECORD_FILE, VERSION_FILE};
24
25use super::paths::SpherePaths;
26use super::render::SphereRenderer;
27
28/// The flavor of [SphereContext] used through the CLI
29pub type CliSphereContext = SphereContext<PlatformStorage>;
30
31/// Metadata about a given sphere, including the sphere ID, a [Link]
32/// to it and a corresponding [LinkRecord] (if one is available).
33pub type SphereDetails = (Did, Link<MemoIpld>, Option<LinkRecord>);
34
35/// The [Workspace] is the kernel of the CLI. It implements it keeps state and
36/// implements routines that are common to most CLI commands.
37pub struct Workspace {
38    sphere_paths: Option<Arc<SpherePaths>>,
39    key_storage: InsecureKeyStorage,
40    sphere_context: OnceCell<Arc<Mutex<CliSphereContext>>>,
41    working_directory: PathBuf,
42    storage_config: Option<StorageConfig>,
43}
44
45impl Workspace {
46    /// The current working directory as given to the [Workspace] when it was
47    /// created
48    pub fn working_directory(&self) -> &Path {
49        &self.working_directory
50    }
51
52    /// Get a mutex-guarded reference to the [SphereContext] for the current workspace
53    pub async fn sphere_context(&self) -> Result<Arc<Mutex<CliSphereContext>>> {
54        Ok(self
55            .sphere_context
56            .get_or_try_init(|| async {
57                let mut builder = SphereContextBuilder::default()
58                    .open_sphere(None)
59                    .at_storage_path(self.require_sphere_paths()?.root())
60                    .reading_keys_from(self.key_storage.clone());
61
62                if let Some(storage_config) = self.storage_config.as_ref() {
63                    builder = builder.with_storage_config(storage_config);
64                }
65
66                Ok(Arc::new(Mutex::new(builder.build().await?.into())))
67                    as Result<Arc<Mutex<CliSphereContext>>, anyhow::Error>
68            })
69            .await?
70            .clone())
71    }
72
73    /// Release the internally-held [SphereContext] (if any), causing its
74    /// related resources to be dropped (e.g., database locks). Accessing it
75    /// via [Workspace::sphere_context] will initialize it again.
76    pub fn release_sphere_context(&mut self) {
77        self.sphere_context = OnceCell::new();
78    }
79
80    /// Get an owned referenced to the [SphereDb] that backs the local sphere.
81    /// Note that this will initialize the [SphereContext] if it has not been
82    /// already.
83    pub async fn db(&self) -> Result<SphereDb<PlatformStorage>> {
84        let context = self.sphere_context().await?;
85        let context = context.lock().await;
86        Ok(context.db().clone())
87    }
88
89    /// Get the [KeyStorage] that is supported on the current platform
90    pub fn key_storage(&self) -> &InsecureKeyStorage {
91        &self.key_storage
92    }
93
94    /// Get the [Author] that is configured to work on the local sphere
95    pub async fn author(&self) -> Result<Author<impl KeyMaterial + Clone>> {
96        Ok(self.sphere_context().await?.lock().await.author().clone())
97    }
98
99    /// Same as [Workspace::sphere_paths] but returns an error result if the
100    /// [SpherePaths] have not been initialized for this [Workspace].
101    pub fn require_sphere_paths(&self) -> Result<&Arc<SpherePaths>> {
102        self.sphere_paths
103            .as_ref()
104            .ok_or_else(|| anyhow!("Sphere paths not discovered for this location"))
105    }
106
107    /// Get the [SpherePaths] for this workspace, if they have been initialized
108    /// and/or discovered.
109    pub fn sphere_paths(&self) -> Option<&Arc<SpherePaths>> {
110        self.sphere_paths.as_ref()
111    }
112
113    /// Gets the [Did] of the sphere
114    pub async fn sphere_identity(&self) -> Result<Did> {
115        let context = self.sphere_context().await?;
116        let context = context.lock().await;
117
118        Ok(context.identity().clone())
119    }
120
121    /// Get the configured counterpart sphere's identity (for a gateway, this is
122    /// the client sphere ID; for a client, this is the gateway's sphere ID)
123    pub async fn counterpart_identity(&self) -> Result<Did> {
124        self.db().await?.require_key(COUNTERPART).await
125    }
126
127    /// Get the configured gateway URL for the local workspace
128    pub async fn gateway_url(&self) -> Result<Url> {
129        self.db().await?.require_key(GATEWAY_URL).await
130    }
131
132    /// Returns true if the local sphere has been initialized
133    pub fn is_sphere_initialized(&self) -> bool {
134        if let Some(sphere_paths) = self.sphere_paths() {
135            sphere_paths.sphere().exists()
136        } else {
137            false
138        }
139    }
140
141    /// Asserts that a local sphere has been intiialized
142    pub fn ensure_sphere_initialized(&self) -> Result<()> {
143        let sphere_paths = self.require_sphere_paths()?;
144        if !sphere_paths.sphere().exists() {
145            return Err(anyhow!(
146                "Expected {} to exist!",
147                sphere_paths.sphere().display()
148            ));
149        }
150        Ok(())
151    }
152
153    /// Asserts that a local sphere has _not_ been intiialized
154    pub fn ensure_sphere_uninitialized(&self) -> Result<()> {
155        if let Some(sphere_paths) = self.sphere_paths() {
156            match sphere_paths.sphere().exists() {
157                true => {
158                    return Err(anyhow!(
159                        "A sphere is already initialized in {}",
160                        sphere_paths.root().display()
161                    ))
162                }
163                false => (),
164            }
165        }
166
167        Ok(())
168    }
169
170    /// For a given location on disk, describe the closest sphere by traversing
171    /// file system ancestors until a sphere is found (either the root workspace
172    /// or one of the rendered peers within that workspace).
173    #[instrument(level = "trace", skip(self))]
174    pub async fn describe_closest_sphere(
175        &self,
176        starting_from: Option<&Path>,
177    ) -> Result<Option<SphereDetails>> {
178        trace!("Looking for closest sphere...");
179
180        let sphere_paths = self.require_sphere_paths()?;
181
182        let canonical =
183            tokio::fs::canonicalize(starting_from.unwrap_or_else(|| self.working_directory()))
184                .await?;
185
186        let peers = sphere_paths.peers();
187        let root = sphere_paths.root();
188
189        let mut sphere_base: &Path = &canonical;
190
191        while let Some(parent) = sphere_base.parent() {
192            trace!("Looking in {}...", parent.display());
193
194            if parent == peers || parent == root {
195                trace!("Found!");
196
197                let (identity, version, link_record) = tokio::join!(
198                    tokio::fs::read_to_string(sphere_base.join(IDENTITY_FILE)),
199                    tokio::fs::read_to_string(sphere_base.join(VERSION_FILE)),
200                    tokio::fs::read_to_string(sphere_base.join(LINK_RECORD_FILE)),
201                );
202                let identity = identity?;
203                let version = version?;
204                let link_record = if let Ok(link_record) = link_record {
205                    LinkRecord::try_from(link_record).ok()
206                } else {
207                    None
208                };
209
210                return Ok(Some((
211                    identity.into(),
212                    Cid::try_from(version)?.into(),
213                    link_record,
214                )));
215            } else {
216                sphere_base = parent;
217            }
218        }
219
220        Ok(None)
221    }
222
223    /// Reads a nickname from a blessed slug `_profile_`, which is used by
224    /// Subconscious (the first embedder of Noosphere) to store user profile
225    /// data as JSON.
226    #[instrument(level = "trace", skip(self))]
227    pub async fn read_subconscious_flavor_profile_nickname(
228        &self,
229        identity: &Did,
230        version: &Link<MemoIpld>,
231    ) -> Result<Option<String>> {
232        trace!("Looking for profile nickname");
233        let sphere_context = self.sphere_context().await?;
234        let peer_sphere_context = Arc::new(sphere_context.lock().await.to_visitor(identity).await?);
235        let cursor = SphereCursor::mounted_at(peer_sphere_context, version);
236
237        if let Some(mut profile) = cursor.read("_profile_").await? {
238            let mut profile_json = String::new();
239            profile.contents.read_to_string(&mut profile_json).await?;
240            match serde_json::from_str(&profile_json)? {
241                Value::Object(object) => match object.get("nickname") {
242                    Some(Value::String(nickname)) => Ok(Some(nickname.to_owned())),
243                    _ => Ok(None),
244                },
245                _ => Ok(None),
246            }
247        } else {
248            Ok(None)
249        }
250    }
251
252    /// Given a path, look for a petname within the path by traversing ancestors until a
253    /// path component that starts with '@' is found.
254    #[instrument(level = "trace", skip(self))]
255    fn find_petname_in_path(&self, path: &Path) -> Result<Option<(String, PathBuf)>> {
256        let mut current_path: Option<&Path> = Some(path);
257
258        debug!("Looking for the petname of the local sphere...");
259        while let Some(path) = current_path {
260            trace!("Looking for petname in {}", path.display());
261            if let Some(tail) = path.components().last() {
262                if let Some(str) = tail.as_os_str().to_str() {
263                    if str.starts_with('@') {
264                        let petname = str.split('@').last().unwrap_or_default().to_owned();
265                        debug!("Found petname @{}", petname);
266                        return Ok(Some((petname, path.to_owned())));
267                    }
268                }
269            }
270
271            current_path = path.parent();
272        }
273
274        debug!("No petname found");
275        Ok(None)
276    }
277
278    /// Reads the latest local version of the sphere and renders its contents to
279    /// files in the workspace. Note that this will overwrite any existing files
280    /// in the workspace.
281    #[instrument(level = "debug", skip(self))]
282    pub async fn render(&self, depth: Option<u32>, force_full: bool) -> Result<()> {
283        let renderer = SphereRenderer::new(
284            self.sphere_context().await?,
285            self.require_sphere_paths()?.clone(),
286        );
287
288        renderer.render(depth, force_full).await?;
289
290        Ok(())
291    }
292
293    /// Initialize a [Workspace] in place with a given set of [SpherePaths].
294    pub fn initialize(&mut self, sphere_paths: SpherePaths) -> Result<()> {
295        self.ensure_sphere_uninitialized()?;
296
297        self.sphere_paths = Some(Arc::new(sphere_paths));
298
299        Ok(())
300    }
301
302    /// Create a new (possibly uninitialized) [Workspace] for a given working
303    /// directory and optional global configuration directory.
304    ///
305    /// This constructor will attempt to discover the [SpherePaths] by traversing
306    /// ancestors from the provided working directory. The [Workspace] will be considered
307    /// initialized if [SpherePaths] are discovered, otherwise it will be considered
308    /// uninitialized.
309    ///
310    /// If no global configuration directory is specified, one will be automatically
311    /// chosen based on the current platform:
312    ///
313    /// - Linux: /home/<user>/.config/noosphere
314    /// - MacOS: /Users/<user>/Library/Application Support/network.subconscious.noosphere
315    /// - Windows: C:\Users\<user>\AppData\Roaming\subconscious\noosphere\config
316    ///
317    /// On Linux, an $XDG_CONFIG_HOME environment variable will be respected if set.
318    ///
319    /// Additionally, a [StorageConfig] may be provided to configure the storage used
320    /// by the opened sphere.
321    pub fn new(
322        working_directory: &Path,
323        custom_noosphere_directory: Option<&Path>,
324        storage_config: Option<StorageConfig>,
325    ) -> Result<Self> {
326        let sphere_paths = SpherePaths::discover(Some(working_directory)).map(Arc::new);
327
328        let noosphere_directory = match custom_noosphere_directory {
329            Some(path) => path.to_owned(),
330            None => {
331                // NOTE: Breaking change for key storage location here
332                let project_dirs = ProjectDirs::from("network", "subconscious", "noosphere")
333                    .ok_or_else(|| anyhow!("Unable to determine noosphere config directory"))?;
334                project_dirs.config_dir().to_owned()
335            }
336        };
337
338        debug!(
339            "Initializing key storage from {}",
340            noosphere_directory.display()
341        );
342
343        let key_storage = InsecureKeyStorage::new(&noosphere_directory)?;
344
345        let workspace = Workspace {
346            sphere_paths,
347            key_storage,
348            sphere_context: OnceCell::new(),
349            working_directory: working_directory.to_owned(),
350            storage_config,
351        };
352
353        Ok(workspace)
354    }
355}