use anyhow::{anyhow, Result};
use cid::Cid;
use globset::{Glob, GlobSet, GlobSetBuilder};
use noosphere_core::{
authority::Authorization,
data::{BodyChunkIpld, ContentType, Did, Header},
view::Sphere,
};
use noosphere_storage::{BlockStore, KeyValueStore, NativeStorage, SphereDb, Store};
use pathdiff::diff_paths;
use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
};
use subtext::util::to_slug;
use tokio::fs::{self, File};
use tokio::io::copy;
use tokio::sync::Mutex;
use tokio::sync::OnceCell;
use tokio_stream::StreamExt;
use ucan_key_support::ed25519::Ed25519KeyMaterial;
use url::Url;
use noosphere::{
key::{InsecureKeyStorage, KeyStorage},
sphere::SphereContextBuilder,
};
use noosphere_sphere::{
HasSphereContext, SphereContentRead, SphereContext, AUTHORIZATION, GATEWAY_URL, USER_KEY_NAME,
};
use tempfile::TempDir;
const SPHERE_DIRECTORY: &str = ".sphere";
const NOOSPHERE_DIRECTORY: &str = ".noosphere";
pub type CliSphereContext = SphereContext<NativeStorage>;
#[derive(Default)]
pub struct ContentChanges {
pub new: BTreeMap<String, Option<ContentType>>,
pub updated: BTreeMap<String, Option<ContentType>>,
pub removed: BTreeMap<String, Option<ContentType>>,
pub unchanged: BTreeSet<String>,
}
impl ContentChanges {
pub fn is_empty(&self) -> bool {
self.new.is_empty() && self.updated.is_empty() && self.removed.is_empty()
}
}
#[derive(Default)]
pub struct Content {
pub matched: BTreeMap<String, FileReference>,
pub ignored: BTreeSet<String>,
}
impl Content {
pub fn is_empty(&self) -> bool {
self.matched.is_empty()
}
}
pub struct FileReference {
pub cid: Cid,
pub content_type: ContentType,
pub extension: Option<String>,
}
use super::commands::config::COUNTERPART;
pub struct Workspace {
root_directory: PathBuf,
sphere_directory: PathBuf,
key_storage: InsecureKeyStorage,
sphere_context: OnceCell<Arc<Mutex<CliSphereContext>>>,
}
impl Workspace {
pub async fn sphere_context(&self) -> Result<Arc<Mutex<CliSphereContext>>> {
Ok(self
.sphere_context
.get_or_try_init(|| async {
Ok(Arc::new(Mutex::new(
SphereContextBuilder::default()
.open_sphere(None)
.at_storage_path(&self.root_directory)
.reading_keys_from(self.key_storage.clone())
.build()
.await?
.into(),
))) as Result<Arc<Mutex<CliSphereContext>>, anyhow::Error>
})
.await?
.clone())
}
pub async fn db(&self) -> Result<SphereDb<NativeStorage>> {
let context = self.sphere_context().await?;
let context = context.lock().await;
Ok(context.db().clone())
}
pub fn key_storage(&self) -> &InsecureKeyStorage {
&self.key_storage
}
pub fn root_directory(&self) -> &Path {
&self.root_directory
}
pub fn sphere_directory(&self) -> &Path {
&self.sphere_directory
}
pub fn key_directory(&self) -> &Path {
self.key_storage().storage_path()
}
pub async fn sphere_identity(&self) -> Result<Did> {
let context = self.sphere_context().await?;
let context = context.lock().await;
Ok(context.identity().clone())
}
pub fn ensure_sphere_initialized(&self) -> Result<()> {
match self.sphere_directory().exists() {
false => Err(anyhow!(
"No sphere initialized in {:?}",
self.root_directory()
)),
true => Ok(()),
}
}
pub fn ensure_sphere_uninitialized(&self) -> Result<()> {
match self.sphere_directory().exists() {
true => Err(anyhow!(
"A sphere is already initialized in {:?}",
self.root_directory()
)),
false => Ok(()),
}
}
fn has_sphere_directory(path: &Path) -> bool {
path.is_absolute() && path.join(SPHERE_DIRECTORY).is_dir()
}
fn find_root_directory(from: Option<&Path>) -> Option<PathBuf> {
debug!("Looking for .sphere in {:?}", from);
match from {
Some(directory) => {
if Workspace::has_sphere_directory(directory) {
Some(directory.into())
} else {
Workspace::find_root_directory(directory.parent())
}
}
None => None,
}
}
pub async fn get_file_content_changes<S: Store>(
&self,
new_blocks: &mut S,
) -> Result<Option<(Content, ContentChanges)>> {
let db = self.db().await?;
let sphere_context = self.sphere_context().await?;
let sphere_cid = sphere_context.version().await?;
let file_content = self.read_file_content(new_blocks).await?;
let sphere = Sphere::at(&sphere_cid, &db);
let content = sphere.get_content().await?;
let mut stream = content.stream().await?;
let mut changes = ContentChanges::default();
while let Some(Ok((slug, memo))) = stream.next().await {
if file_content.ignored.contains(slug) {
continue;
}
match file_content.matched.get(slug) {
Some(FileReference {
cid: body_cid,
content_type,
extension: _,
}) => {
let sphere_file = sphere_context.read(slug).await?.ok_or_else(|| {
anyhow!(
"Expected sphere file at slug {:?} but it was missing!",
slug
)
})?;
if &sphere_file.memo.body == body_cid {
changes.unchanged.insert(slug.clone());
continue;
}
changes
.updated
.insert(slug.clone(), Some(content_type.clone()));
}
None => {
let memo = memo.load_from(&db).await?;
changes.removed.insert(slug.clone(), memo.content_type());
}
}
}
for (slug, FileReference { content_type, .. }) in &file_content.matched {
if changes.updated.contains_key(slug)
|| changes.removed.contains_key(slug)
|| changes.unchanged.contains(slug)
{
continue;
}
changes.new.insert(slug.clone(), Some(content_type.clone()));
}
Ok(Some((file_content, changes)))
}
pub async fn read_file_content<S: BlockStore>(&self, store: &mut S) -> Result<Content> {
let root_path = &self.root_directory;
let mut directories = vec![(None, tokio::fs::read_dir(root_path).await?)];
let ignore_patterns = self.get_ignored_patterns().await?;
let mut content = Content::default();
while let Some((slug_prefix, mut directory)) = directories.pop() {
while let Some(entry) = directory.next_entry().await? {
let path = entry.path();
let relative_path = diff_paths(&path, root_path)
.ok_or_else(|| anyhow!("Could not determine relative path to {:?}", path))?;
if ignore_patterns.is_match(&relative_path) {
continue;
}
if path.is_dir() {
let slug_prefix = relative_path.to_string_lossy().to_string();
directories.push((Some(slug_prefix), tokio::fs::read_dir(path).await?));
continue;
}
let ignored = false;
let name = match path.file_stem() {
Some(name) => name.to_string_lossy(),
None => continue,
};
let name = match &slug_prefix {
Some(prefix) => format!("{prefix}/{name}"),
None => name.to_string(),
};
let slug = match to_slug(&name) {
Ok(slug) if slug == name => slug,
_ => continue,
};
if ignored {
content.ignored.insert(slug);
continue;
}
let extension = path
.extension()
.map(|extension| String::from(extension.to_string_lossy()));
let content_type = match &extension {
Some(extension) => self.infer_content_type(extension).await?,
None => ContentType::Bytes,
};
let file_bytes = fs::read(path).await?;
let body_cid = BodyChunkIpld::store_bytes(&file_bytes, store).await?;
content.matched.insert(
slug,
FileReference {
cid: body_cid,
content_type,
extension,
},
);
}
}
Ok(content)
}
pub async fn render(&self) -> Result<()> {
let context = self.sphere_context().await?;
let sphere = context.to_sphere().await?;
let content = sphere.get_content().await?;
let mut stream = content.stream().await?;
while let Some(Ok((slug, _cid))) = stream.next().await {
debug!("Rendering {}...", slug);
let mut sphere_file = match context.read(slug).await? {
Some(file) => file,
None => {
warn!("Could not resolve content for {slug}");
continue;
}
};
let extension = match sphere_file.memo.get_first_header(&Header::FileExtension) {
Some(extension) => Some(extension),
None => match sphere_file.memo.content_type() {
Some(content_type) => self.infer_file_extension(content_type).await,
None => {
warn!("No content type specified for {slug}; it will be rendered without a file extension");
None
}
},
};
let file_fragment = match extension {
Some(extension) => [slug.as_str(), &extension].join("."),
None => slug.into(),
};
let file_path = self.root_directory.join(file_fragment);
let file_directory = file_path
.parent()
.ok_or_else(|| anyhow!("Unable to determine root directory for {}", slug))?;
fs::create_dir_all(&file_directory).await?;
let mut fs_file = File::create(file_path).await?;
copy(&mut sphere_file.contents, &mut fs_file).await?;
}
Ok(())
}
async fn get_ignored_patterns(&self) -> Result<GlobSet> {
let ignored_patterns = vec!["@*", ".*"];
let mut builder = GlobSetBuilder::new();
for pattern in ignored_patterns {
builder.add(Glob::new(pattern)?);
}
Ok(builder.build()?)
}
pub async fn infer_content_type(&self, extension: &str) -> Result<ContentType> {
Ok(match extension {
"subtext" => ContentType::Subtext,
"sphere" => ContentType::Sphere,
_ => ContentType::from_str(
mime_guess::from_ext(extension)
.first_raw()
.unwrap_or("raw/bytes"),
)?,
})
}
pub async fn infer_file_extension(&self, content_type: ContentType) -> Option<String> {
match content_type {
ContentType::Text => Some("txt".into()),
ContentType::Subtext => Some("subtext".into()),
ContentType::Sphere => Some("sphere".into()),
ContentType::Bytes => None,
ContentType::Unknown(content_type) => {
match mime_guess::get_mime_extensions_str(&content_type) {
Some(extensions) => extensions.first().map(|str| String::from(*str)),
None => None,
}
}
ContentType::Cbor => Some("json".into()),
ContentType::Json => Some("cbor".into()),
}
}
pub async fn key(&self) -> Result<Ed25519KeyMaterial> {
let key_name: String = self.db().await?.require_key(USER_KEY_NAME).await?;
self.key_storage().require_key(&key_name).await
}
pub async fn counterpart_identity(&self) -> Result<Did> {
self.db().await?.require_key(COUNTERPART).await
}
pub async fn authorization(&self) -> Result<Authorization> {
Ok(self
.db()
.await?
.require_key::<_, Cid>(AUTHORIZATION)
.await?
.into())
}
pub async fn gateway_url(&self) -> Result<Url> {
self.db().await?.require_key(GATEWAY_URL).await
}
pub fn new(
current_working_directory: &Path,
noosphere_directory: Option<&Path>,
) -> Result<Self> {
let root_directory = match Workspace::find_root_directory(Some(current_working_directory)) {
Some(directory) => directory,
None => current_working_directory.into(),
};
let noosphere_directory = match noosphere_directory {
Some(custom_root) => custom_root.into(),
None => home::home_dir()
.ok_or_else(|| {
anyhow!(
"Could not discover home directory for {}",
whoami::username()
)
})?
.join(NOOSPHERE_DIRECTORY),
};
let key_storage = InsecureKeyStorage::new(&noosphere_directory)?;
let sphere_directory = root_directory.join(SPHERE_DIRECTORY);
Ok(Workspace {
root_directory,
sphere_directory,
key_storage,
sphere_context: OnceCell::new(),
})
}
pub fn temporary() -> Result<(Self, (TempDir, TempDir))> {
let root = TempDir::new()?;
let global_root = TempDir::new()?;
Ok((
Workspace::new(root.path(), Some(global_root.path()))?,
(root, global_root),
))
}
}
#[cfg(test)]
mod tests {
use crate::native::commands::{key, sphere};
use tokio::fs;
use super::Workspace;
#[tokio::test]
async fn it_chooses_an_ancestor_sphere_directory_as_root_if_one_exists() {
let (workspace, _temporary_directories) = Workspace::temporary().unwrap();
key::key_create("FOO", &workspace).await.unwrap();
sphere::sphere_create("FOO", &workspace).await.unwrap();
let subdirectory = workspace.root_directory().join("foo/bar");
fs::create_dir_all(&subdirectory).await.unwrap();
let new_workspace =
Workspace::new(&subdirectory, workspace.key_directory().parent()).unwrap();
assert_eq!(workspace.root_directory(), new_workspace.root_directory());
}
}