603 lines
22 KiB
Rust
603 lines
22 KiB
Rust
#[cfg(not(target_arch = "wasm32"))]
|
|
use std::{
|
|
fs::{create_dir_all, read_to_string, File},
|
|
io::Write,
|
|
path::Path,
|
|
};
|
|
|
|
use async_trait::async_trait;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub use clap_serde_derive::{self, clap, serde, ClapSerde};
|
|
use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
|
|
|
|
pub mod proto;
|
|
|
|
/// A media provider addressed like a file system.
|
|
///
|
|
/// Every node and track has a `/`-separated absolute path whose first
|
|
/// segment names the provider, e.g. `/tidal/playlists/<id>/<track-id>`.
|
|
/// The path encodes the position in the library tree: ancestors are
|
|
/// obtained by trimming trailing segments.
|
|
#[async_trait]
|
|
pub trait ProviderClient: std::fmt::Debug + Send + Sync {
|
|
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError>
|
|
where
|
|
Self: Sized;
|
|
fn settings(&self) -> String;
|
|
/// Whether the path addresses a single track (as opposed to a node).
|
|
fn is_track_path(&self, path: &str) -> bool;
|
|
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError>;
|
|
async fn get_metadata_for_track(&self, track_path: &str) -> Result<Track, ProviderError>;
|
|
fn get_lib_root(&self) -> LibraryNode;
|
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
|
|
/// Creates a child node under a creatable parent (`LibraryNode.is_creatable`).
|
|
///
|
|
/// What creation means is provider-defined; under `/tidal/search` the
|
|
/// `title` is a search term and the created node holds its results.
|
|
/// Idempotent: an existing title returns the existing node. Errors:
|
|
/// [`ProviderError::NotSupported`] when the parent is not creatable,
|
|
/// [`ProviderError::InvalidInput`] when the title is empty or
|
|
/// whitespace-only.
|
|
async fn create_lib_node(
|
|
&self,
|
|
parent_path: &str,
|
|
title: &str,
|
|
) -> Result<LibraryNode, ProviderError>;
|
|
/// Renames a node advertised as editable (`LibraryNodeChild.is_editable`).
|
|
///
|
|
/// For a search term the title is the query, so a rename re-runs the
|
|
/// search; the node's path changes with the title. Renaming onto an
|
|
/// existing sibling title merges with it (that node is returned).
|
|
/// Errors: [`ProviderError::NotSupported`] when the path is not
|
|
/// editable, [`ProviderError::InvalidInput`] when the new title is empty
|
|
/// or whitespace-only.
|
|
async fn rename_lib_node(
|
|
&self,
|
|
path: &str,
|
|
new_title: &str,
|
|
) -> Result<LibraryNode, ProviderError>;
|
|
/// Deletes a node advertised as deletable (`LibraryNodeChild.is_deletable`).
|
|
///
|
|
/// Idempotent: deleting an already-gone node succeeds. Returns the
|
|
/// refreshed parent node (what a client should display next). Errors:
|
|
/// [`ProviderError::NotSupported`] when the path is not deletable.
|
|
async fn delete_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError>;
|
|
/// Streams the playable tracks under `path` into `chunk_tx`, in playback
|
|
/// order.
|
|
///
|
|
/// This is a local bounded channel used as a stream, and its delivery
|
|
/// semantics are the contract:
|
|
///
|
|
/// - Zero or more non-empty chunks are sent, in playback order.
|
|
/// - Resolution is finished when the **sender** is dropped (this method
|
|
/// returning). There is no end-of-stream marker.
|
|
/// - Dropping the **receiver** cancels resolution: the provider stops
|
|
/// fetching at the next send and returns `Ok`.
|
|
/// - An unreadable node inside the walk is skipped with a warning; only
|
|
/// a `path` that cannot be resolved at all is an `Err`.
|
|
///
|
|
/// A track path yields exactly one single-track chunk. The default
|
|
/// implementation walks the node's queueable descendants depth-first in
|
|
/// pre-order and emits one chunk per node holding tracks; providers
|
|
/// should override it when they can produce finer-grained chunks (e.g.
|
|
/// one per fetched page of a large collection).
|
|
async fn resolve_tracks_into(
|
|
&self,
|
|
path: &str,
|
|
chunk_tx: flume::Sender<Vec<Track>>,
|
|
) -> Result<(), ProviderError> {
|
|
if self.is_track_path(path) {
|
|
match self.get_metadata_for_track(path).await {
|
|
Ok(track) => {
|
|
let _ = chunk_tx.send_async(vec![track]).await;
|
|
}
|
|
Err(err) => tracing::warn!(path, "failed to resolve track: {err}"),
|
|
}
|
|
return Ok(());
|
|
}
|
|
// Depth-first pre-order so tracks arrive in listing order; children
|
|
// are pushed reversed because the worklist pops from the back.
|
|
let mut nodes_to_go = vec![path.to_string()];
|
|
let mut at_root = true;
|
|
while let Some(node_path) = nodes_to_go.pop() {
|
|
let node = match self.get_lib_node(&node_path).await {
|
|
Ok(node) => node,
|
|
Err(err) if at_root => return Err(err),
|
|
Err(err) => {
|
|
tracing::warn!(node = node_path, "skipping unreadable node: {err}");
|
|
continue;
|
|
}
|
|
};
|
|
at_root = false;
|
|
if !node.is_queable {
|
|
continue;
|
|
}
|
|
if !node.tracks.is_empty() && chunk_tx.send_async(node.tracks).await.is_err() {
|
|
// Receiver gone: the consumer cancelled, stop fetching.
|
|
return Ok(());
|
|
}
|
|
nodes_to_go.extend(node.children.into_iter().rev().map(|c| c.path));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
|
pub enum ProviderError {
|
|
Config(String),
|
|
UnknownUser,
|
|
CouldNotLogin,
|
|
FetchError,
|
|
MalformedPath,
|
|
/// The operation is not supported at this path (e.g. creating a node
|
|
/// under a parent that is not creatable).
|
|
NotSupported,
|
|
/// User-supplied input was rejected (e.g. an empty node title).
|
|
InvalidInput,
|
|
InternalError,
|
|
Other,
|
|
}
|
|
|
|
impl std::fmt::Display for ProviderError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{:?}", self)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ProviderError {}
|
|
|
|
/// The path of the global library root.
|
|
pub const ROOT_PATH: &str = "/";
|
|
|
|
/// Returns the parent path, or `None` when at the root.
|
|
///
|
|
/// `/tidal/playlists/abc` -> `/tidal/playlists` -> `/tidal` -> `/`.
|
|
pub fn parent_path(path: &str) -> Option<&str> {
|
|
let trimmed = path.trim_end_matches('/');
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
match trimmed.rfind('/') {
|
|
Some(0) => Some(ROOT_PATH),
|
|
Some(idx) => Some(&trimmed[..idx]),
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
/// Appends a segment to a path.
|
|
pub fn join_path(base: &str, segment: &str) -> String {
|
|
let base = base.trim_end_matches('/');
|
|
format!("{base}/{segment}")
|
|
}
|
|
|
|
/// Splits a path into its segments: `/tidal/playlists/x` -> ["tidal", "playlists", "x"].
|
|
pub fn path_segments(path: &str) -> Vec<&str> {
|
|
path.split('/').filter(|s| !s.is_empty()).collect()
|
|
}
|
|
|
|
/// Percent-encodes arbitrary user text into a single path segment.
|
|
///
|
|
/// `/`, `%`, whitespace and every other character that would confuse
|
|
/// `path_segments` or a URL are escaped: `AC/DC` -> `AC%2FDC`. The result is
|
|
/// never empty for non-empty input and round-trips through
|
|
/// [`decode_segment`].
|
|
pub fn encode_segment(text: &str) -> String {
|
|
/// Everything except ASCII alphanumerics and `-`, `_`, `.`, `~` is
|
|
/// escaped — the URL "unreserved" set. Notably `/`, `%` and whitespace.
|
|
const SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
|
|
.remove(b'-')
|
|
.remove(b'_')
|
|
.remove(b'.')
|
|
.remove(b'~');
|
|
percent_encoding::utf8_percent_encode(text, SEGMENT).to_string()
|
|
}
|
|
|
|
/// Decodes a segment produced by [`encode_segment`] back to the original
|
|
/// text. Invalid or lone percent escapes decode lossily (the raw bytes are
|
|
/// kept) rather than erroring: paths come from clients and must not panic.
|
|
pub fn decode_segment(segment: &str) -> String {
|
|
percent_encoding::percent_decode_str(segment)
|
|
.decode_utf8_lossy()
|
|
.into_owned()
|
|
}
|
|
|
|
impl LibraryNode {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
path: ROOT_PATH.to_string(),
|
|
title: "/".to_string(),
|
|
children: Vec::new(),
|
|
parent: None,
|
|
tracks: Vec::new(),
|
|
is_queable: false,
|
|
is_creatable: false,
|
|
is_downloadable: false,
|
|
tracks_deletable: false,
|
|
is_captured: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LibraryNodeChild {
|
|
/// A regular, non-creatable, immutable child. Creatable/editable/
|
|
/// deletable/downloadable children (e.g. the search node and its
|
|
/// terms, or Tidal's queueable subtrees) set the capability flags
|
|
/// explicitly via struct update.
|
|
pub fn new(path: String, title: String, is_queable: bool) -> Self {
|
|
Self {
|
|
path,
|
|
title,
|
|
is_queable,
|
|
is_creatable: false,
|
|
is_editable: false,
|
|
is_deletable: false,
|
|
is_downloadable: false,
|
|
is_captured: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub enum QueueError {
|
|
NotQueable,
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub fn init_config<T>(config_file_name: &str) -> T
|
|
where
|
|
T: Default + ClapSerde + serde::Serialize + std::fmt::Debug,
|
|
{
|
|
if let Some(config_dir) = dirs::config_dir() {
|
|
let dir = Path::new(&config_dir).join("crabidy");
|
|
if !dir.is_dir() {
|
|
create_dir_all(&dir).expect("Could not create crabidy config directory");
|
|
}
|
|
let config_file_path = dir.join(config_file_name);
|
|
if !config_file_path.is_file() {
|
|
let config = T::default().merge_clap();
|
|
let content = toml::to_string_pretty(&config).expect("Could not serialize config");
|
|
let mut config_file =
|
|
File::create(config_file_path).expect("Could not open config file for writing");
|
|
config_file
|
|
.write_all(content.as_bytes())
|
|
.expect("Failed to write to file");
|
|
return config;
|
|
} else {
|
|
let content = read_to_string(config_file_path).expect("Could not read config file");
|
|
let parsed = toml::from_str::<<T as ClapSerde>::Opt>(&content).unwrap();
|
|
let config: T = T::from(parsed).merge_clap();
|
|
return config;
|
|
}
|
|
}
|
|
T::default().merge_clap()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parent_path_walks_up_to_root() {
|
|
assert_eq!(
|
|
parent_path("/tidal/playlists/abc"),
|
|
Some("/tidal/playlists")
|
|
);
|
|
assert_eq!(parent_path("/tidal/playlists"), Some("/tidal"));
|
|
assert_eq!(parent_path("/tidal"), Some("/"));
|
|
assert_eq!(parent_path("/"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn join_path_appends_segments() {
|
|
assert_eq!(join_path("/", "tidal"), "/tidal");
|
|
assert_eq!(join_path("/tidal", "playlists"), "/tidal/playlists");
|
|
}
|
|
|
|
#[test]
|
|
fn path_segments_splits() {
|
|
assert_eq!(path_segments("/"), Vec::<&str>::new());
|
|
assert_eq!(
|
|
path_segments("/tidal/artists/1/2"),
|
|
vec!["tidal", "artists", "1", "2"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn encode_segment_round_trips_arbitrary_text() {
|
|
for term in [
|
|
"AC/DC",
|
|
"100% wrong",
|
|
"Björk",
|
|
"hello world",
|
|
"a%2Fb",
|
|
"?!#&=",
|
|
] {
|
|
let encoded = encode_segment(term);
|
|
assert_eq!(decode_segment(&encoded), term, "round trip of {term:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn encoded_segments_are_path_safe() {
|
|
for term in ["AC/DC", "a/b/c", "//", "term with spaces"] {
|
|
let encoded = encode_segment(term);
|
|
assert!(!encoded.is_empty());
|
|
assert!(!encoded.contains('/'), "{encoded:?} must be one segment");
|
|
// Joining under a parent yields exactly one extra segment.
|
|
let path = join_path("/tidal/search", &encoded);
|
|
assert_eq!(path_segments(&path).len(), 3, "path {path:?}");
|
|
assert_eq!(parent_path(&path), Some("/tidal/search"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn decode_segment_is_lossy_not_panicky() {
|
|
// Invalid or truncated escapes must never panic — paths come from
|
|
// clients. Exact output is unspecified, only totality matters.
|
|
for bad in ["%", "%2", "%zz", "abc%", "%%25"] {
|
|
let _ = decode_segment(bad);
|
|
}
|
|
}
|
|
|
|
/// A scripted in-memory provider for exercising the default
|
|
/// `resolve_tracks_into` walk. Node lookups are recorded so tests can
|
|
/// assert what was (not) fetched.
|
|
#[derive(Debug, Default)]
|
|
struct FakeProvider {
|
|
nodes: std::collections::HashMap<String, Result<LibraryNode, ProviderError>>,
|
|
track_paths: Vec<String>,
|
|
fetched: std::sync::Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl FakeProvider {
|
|
fn node(path: &str, tracks: &[&str], children: &[&str], is_queable: bool) -> LibraryNode {
|
|
LibraryNode {
|
|
path: path.to_string(),
|
|
title: path.to_string(),
|
|
children: children
|
|
.iter()
|
|
.map(|c| LibraryNodeChild::new(c.to_string(), c.to_string(), true))
|
|
.collect(),
|
|
parent: None,
|
|
tracks: tracks
|
|
.iter()
|
|
.map(|t| Track {
|
|
path: t.to_string(),
|
|
artist: "artist".to_string(),
|
|
title: t.to_string(),
|
|
duration: None,
|
|
album: None,
|
|
is_skipped: false,
|
|
provider_item_id: String::new(),
|
|
is_captured: false,
|
|
})
|
|
.collect(),
|
|
is_queable,
|
|
is_creatable: false,
|
|
is_downloadable: false,
|
|
tracks_deletable: false,
|
|
is_captured: false,
|
|
}
|
|
}
|
|
|
|
fn fetched(&self) -> Vec<String> {
|
|
self.fetched.lock().map(|f| f.clone()).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProviderClient for FakeProvider {
|
|
async fn init(_: &str) -> Result<Self, ProviderError> {
|
|
Ok(Self::default())
|
|
}
|
|
fn settings(&self) -> String {
|
|
String::new()
|
|
}
|
|
fn is_track_path(&self, path: &str) -> bool {
|
|
self.track_paths.iter().any(|p| p == path)
|
|
}
|
|
async fn get_urls_for_track(&self, _: &str) -> Result<Vec<String>, ProviderError> {
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
async fn get_metadata_for_track(&self, path: &str) -> Result<Track, ProviderError> {
|
|
Ok(Track {
|
|
path: path.to_string(),
|
|
artist: "artist".to_string(),
|
|
title: path.to_string(),
|
|
duration: None,
|
|
album: None,
|
|
is_skipped: false,
|
|
provider_item_id: String::new(),
|
|
is_captured: false,
|
|
})
|
|
}
|
|
fn get_lib_root(&self) -> LibraryNode {
|
|
LibraryNode::new()
|
|
}
|
|
async fn get_lib_node(&self, path: &str) -> Result<LibraryNode, ProviderError> {
|
|
if let Ok(mut fetched) = self.fetched.lock() {
|
|
fetched.push(path.to_string());
|
|
}
|
|
self.nodes
|
|
.get(path)
|
|
.cloned()
|
|
.unwrap_or(Err(ProviderError::MalformedPath))
|
|
}
|
|
async fn create_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> {
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
async fn rename_lib_node(&self, _: &str, _: &str) -> Result<LibraryNode, ProviderError> {
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
async fn delete_lib_node(&self, _: &str) -> Result<LibraryNode, ProviderError> {
|
|
Err(ProviderError::NotSupported)
|
|
}
|
|
}
|
|
|
|
/// Runs the default resolve against the fake and collects the chunks it
|
|
/// streamed. The channel is bounded but larger than any test tree, so
|
|
/// the resolve never blocks on a full buffer here.
|
|
async fn resolve_chunks(provider: &FakeProvider, path: &str) -> Vec<Vec<String>> {
|
|
let (chunk_tx, chunk_rx) = flume::bounded(32);
|
|
provider
|
|
.resolve_tracks_into(path, chunk_tx)
|
|
.await
|
|
.expect("resolve failed");
|
|
chunk_rx
|
|
.into_iter()
|
|
.map(|chunk| chunk.into_iter().map(|t| t.path).collect())
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_resolve_streams_chunks_per_node_in_preorder() {
|
|
let mut provider = FakeProvider::default();
|
|
// artist -> [album1, album2], each album carries tracks; the artist
|
|
// node itself has none. Pre-order and *listing order*: album1's
|
|
// tracks must come before album2's (the old walk popped LIFO and
|
|
// reversed siblings).
|
|
provider.nodes.insert(
|
|
"/p/artist".into(),
|
|
Ok(FakeProvider::node(
|
|
"/p/artist",
|
|
&[],
|
|
&["/p/artist/al1", "/p/artist/al2"],
|
|
true,
|
|
)),
|
|
);
|
|
provider.nodes.insert(
|
|
"/p/artist/al1".into(),
|
|
Ok(FakeProvider::node(
|
|
"/p/artist/al1",
|
|
&["/p/artist/al1/t1", "/p/artist/al1/t2"],
|
|
&[],
|
|
true,
|
|
)),
|
|
);
|
|
provider.nodes.insert(
|
|
"/p/artist/al2".into(),
|
|
Ok(FakeProvider::node(
|
|
"/p/artist/al2",
|
|
&["/p/artist/al2/t3"],
|
|
&[],
|
|
true,
|
|
)),
|
|
);
|
|
let chunks = resolve_chunks(&provider, "/p/artist").await;
|
|
// One chunk per track-bearing node; the trackless artist node adds
|
|
// no empty chunk.
|
|
assert_eq!(
|
|
chunks,
|
|
vec![
|
|
vec!["/p/artist/al1/t1".to_string(), "/p/artist/al1/t2".into()],
|
|
vec!["/p/artist/al2/t3".to_string()],
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_resolve_yields_one_chunk_for_track_paths() {
|
|
let mut provider = FakeProvider::default();
|
|
provider.track_paths.push("/p/al/t9".into());
|
|
let chunks = resolve_chunks(&provider, "/p/al/t9").await;
|
|
assert_eq!(chunks, vec![vec!["/p/al/t9".to_string()]]);
|
|
assert!(
|
|
provider.fetched().is_empty(),
|
|
"a track path must not fetch nodes"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_resolve_skips_unreadable_nodes_and_unqueable_subtrees() {
|
|
let mut provider = FakeProvider::default();
|
|
provider.nodes.insert(
|
|
"/p/root".into(),
|
|
Ok(FakeProvider::node(
|
|
"/p/root",
|
|
&["/p/root/t0"],
|
|
&["/p/root/broken", "/p/root/private", "/p/root/ok"],
|
|
true,
|
|
)),
|
|
);
|
|
provider
|
|
.nodes
|
|
.insert("/p/root/broken".into(), Err(ProviderError::FetchError));
|
|
provider.nodes.insert(
|
|
"/p/root/private".into(),
|
|
Ok(FakeProvider::node(
|
|
"/p/root/private",
|
|
&["/p/root/private/hidden"],
|
|
&[],
|
|
false,
|
|
)),
|
|
);
|
|
provider.nodes.insert(
|
|
"/p/root/ok".into(),
|
|
Ok(FakeProvider::node(
|
|
"/p/root/ok",
|
|
&["/p/root/ok/t1"],
|
|
&[],
|
|
true,
|
|
)),
|
|
);
|
|
let chunks = resolve_chunks(&provider, "/p/root").await;
|
|
// The broken sibling is skipped, the non-queueable subtree
|
|
// contributes nothing, the rest still resolves in order.
|
|
assert_eq!(
|
|
chunks,
|
|
vec![
|
|
vec!["/p/root/t0".to_string()],
|
|
vec!["/p/root/ok/t1".to_string()],
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_resolve_stops_fetching_once_the_receiver_is_gone() {
|
|
let mut provider = FakeProvider::default();
|
|
provider.nodes.insert(
|
|
"/p/a".into(),
|
|
Ok(FakeProvider::node(
|
|
"/p/a",
|
|
&["/p/a/t1"],
|
|
&["/p/a/b", "/p/a/c"],
|
|
true,
|
|
)),
|
|
);
|
|
provider.nodes.insert(
|
|
"/p/a/b".into(),
|
|
Ok(FakeProvider::node("/p/a/b", &["/p/a/b/t2"], &[], true)),
|
|
);
|
|
provider.nodes.insert(
|
|
"/p/a/c".into(),
|
|
Ok(FakeProvider::node("/p/a/c", &["/p/a/c/t3"], &[], true)),
|
|
);
|
|
let (chunk_tx, chunk_rx) = flume::bounded(32);
|
|
drop(chunk_rx);
|
|
// A dropped receiver is cancellation, not an error ...
|
|
provider
|
|
.resolve_tracks_into("/p/a", chunk_tx)
|
|
.await
|
|
.expect("cancellation must not be an error");
|
|
// ... and the walk stops fetching instead of draining the tree.
|
|
assert_eq!(provider.fetched(), vec!["/p/a".to_string()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_resolve_errors_only_for_an_unresolvable_root() {
|
|
let provider = FakeProvider::default();
|
|
let (chunk_tx, _chunk_rx) = flume::bounded::<Vec<Track>>(1);
|
|
let result = provider.resolve_tracks_into("/p/unknown", chunk_tx).await;
|
|
assert!(result.is_err(), "an unreadable root path is an error");
|
|
}
|
|
|
|
#[test]
|
|
fn child_new_defaults_all_capability_flags_off() {
|
|
// Wire contract: plain children are immutable; providers opt into
|
|
// capabilities explicitly via struct update.
|
|
let child = LibraryNodeChild::new("/tidal/x".to_string(), "x".to_string(), true);
|
|
assert!(!child.is_creatable);
|
|
assert!(!child.is_editable);
|
|
assert!(!child.is_deletable);
|
|
}
|
|
}
|