An encrypted personal cloud built on the AT Protocol.

Add tree, cat, mv commands and upload directory placement

Four features implemented in parallel:

- tree: Display directory hierarchy with box-drawing characters.
Uses DirectoryTree::load_full() which loads both directories and
documents. Entries sorted directories-first, alphabetical.

- cat: Decrypt and print a document to stdout. Supports bare names,
paths, and AT-URIs. Handles both direct and keyring encryption.

- mv: Move or rename documents and directories. Destination resolution
handles trailing / (must be directory), existing directories (move
into), and non-existent names (rename). Cycle detection prevents
moving a directory into itself or its descendants.

- upload --dir: Place uploaded documents into a directory. Resolves
the directory path via DirectoryTree, calls add_entry after upload.

Closes #90, closes #154, closes #157, closes #158

+829 -2
+3
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html) 13 13 14 14 ### Added 15 + - Add cat command to read and display file contents [#154](https://issues.opake.app/issues/154.html) 15 16 - Add directory record type and mkdir command [#98](https://issues.opake.app/issues/98.html) 16 17 - Add issue tracker link to README [#142](https://issues.opake.app/issues/142.html) 17 18 - Add crosslink-issue-renderer submodule and CI/CD pipeline [#141](https://issues.opake.app/issues/141.html) ··· 50 51 - Fix missing HTTP status checks in XRPC client [#104](https://issues.opake.app/issues/104.html) 51 52 52 53 ### Changed 54 + - Add path-aware mv command [#157](https://issues.opake.app/issues/157.html) 55 + - Add path-aware upload with directory placement [#158](https://issues.opake.app/issues/158.html) 53 56 - Add path-aware rm with recursive directory deletion [#156](https://issues.opake.app/issues/156.html) 54 57 - Update docs to reflect keyring rotation history [#46](https://issues.opake.app/issues/46.html) 55 58 - Add keyring member management (add-member, remove-member) [#56](https://issues.opake.app/issues/56.html)
+81
crates/opake-cli/src/commands/cat.rs
··· 1 + use std::io::Write; 2 + 3 + use anyhow::{Context, Result}; 4 + use clap::Args; 5 + use opake_core::atproto; 6 + use opake_core::client::Session; 7 + use opake_core::directories::DirectoryTree; 8 + use opake_core::documents; 9 + 10 + use crate::commands::Execute; 11 + use crate::identity; 12 + use crate::keyring_store; 13 + use crate::session::{self, CommandContext}; 14 + 15 + #[derive(Args)] 16 + /// Print a decrypted file to stdout 17 + pub struct CatCommand { 18 + /// Path, filename, or AT-URI of the document 19 + reference: String, 20 + } 21 + 22 + impl Execute for CatCommand { 23 + async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 24 + let id = identity::load_identity(&ctx.did).context("run `opake login` first")?; 25 + let private_key = id.private_key_bytes()?; 26 + let mut client = session::load_client(&ctx.did)?; 27 + 28 + // Resolve the reference to an AT-URI. 29 + let uri = if self.reference.starts_with("at://") { 30 + self.reference.clone() 31 + } else if self.reference.contains('/') { 32 + // Path — needs the directory tree. 33 + let tree = DirectoryTree::load(&mut client).await?; 34 + let resolved = tree.resolve(&mut client, &self.reference).await?; 35 + resolved.uri 36 + } else { 37 + // Bare name — lightweight document resolution. 38 + documents::resolve_uri(&mut client, &self.reference).await? 39 + }; 40 + 41 + // Peek at the document to check for keyring encryption. 42 + let at_uri = atproto::parse_at_uri(&uri)?; 43 + let entry = client 44 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 45 + .await?; 46 + let doc: opake_core::records::Document = serde_json::from_value(entry.value)?; 47 + 48 + let group_key = match &doc.encryption { 49 + opake_core::records::Encryption::Keyring(kr_enc) => { 50 + let kr_uri = atproto::parse_at_uri(&kr_enc.keyring_ref.keyring)?; 51 + Some( 52 + keyring_store::load_group_key( 53 + &ctx.did, 54 + &kr_uri.rkey, 55 + kr_enc.keyring_ref.rotation, 56 + ) 57 + .context( 58 + "if you're a keyring member (not the creator), use: \ 59 + opake download --keyring-member <document-uri>", 60 + )?, 61 + ) 62 + } 63 + opake_core::records::Encryption::Direct(_) => None, 64 + }; 65 + 66 + let (_name, plaintext) = documents::download_with_group_key( 67 + &mut client, 68 + &id.did, 69 + &private_key, 70 + group_key.as_ref(), 71 + &uri, 72 + ) 73 + .await?; 74 + 75 + std::io::stdout() 76 + .write_all(&plaintext) 77 + .context("failed to write to stdout")?; 78 + 79 + Ok(session::refreshed_session(&client)) 80 + } 81 + }
+3
crates/opake-cli/src/commands/mod.rs
··· 1 1 pub mod accounts; 2 + pub mod cat; 2 3 pub mod download; 3 4 pub mod inbox; 4 5 pub mod keyring; ··· 6 7 pub mod logout; 7 8 pub mod ls; 8 9 pub mod mkdir; 10 + pub mod mv; 9 11 pub mod resolve; 10 12 pub mod revoke; 11 13 pub mod rm; 12 14 pub mod set_default; 13 15 pub mod share; 14 16 pub mod shared; 17 + pub mod tree; 15 18 pub mod upload; 16 19 17 20 use anyhow::Result;
+96
crates/opake-cli/src/commands/mv.rs
··· 1 + use anyhow::Result; 2 + use chrono::Utc; 3 + use clap::Args; 4 + use opake_core::client::Session; 5 + use opake_core::directories::{ 6 + self, check_cycle, move_entry, DirectoryTree, EntryKind, MoveDestination, 7 + }; 8 + use opake_core::error::Error as CoreError; 9 + 10 + use crate::commands::Execute; 11 + use crate::session::{self, CommandContext}; 12 + 13 + #[derive(Args)] 14 + /// Move or rename a document or directory 15 + pub struct MvCommand { 16 + /// Source path, filename, or AT-URI 17 + source: String, 18 + 19 + /// Destination path or new name 20 + destination: String, 21 + } 22 + 23 + impl Execute for MvCommand { 24 + async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 25 + let mut client = session::load_client(&ctx.did)?; 26 + let now = Utc::now().to_rfc3339(); 27 + 28 + let tree = DirectoryTree::load(&mut client).await?; 29 + let source = tree.resolve(&mut client, &self.source).await?; 30 + 31 + let destination = 32 + resolve_destination(&tree, &mut client, &self.destination, &source).await?; 33 + 34 + // Cycle guard for directory moves. 35 + if source.kind == EntryKind::Directory { 36 + if let MoveDestination::IntoDirectory { ref directory_uri } = destination { 37 + check_cycle(&tree, &source.uri, directory_uri)?; 38 + } 39 + } 40 + 41 + let result = move_entry(&mut client, &tree, &source, &destination, &now).await?; 42 + 43 + match (&result.new_name, &destination) { 44 + (Some(new_name), _) => println!("renamed {:?} → {:?}", source.name, new_name), 45 + (None, MoveDestination::IntoDirectory { .. }) => { 46 + println!("moved {:?} → {}", source.name, self.destination) 47 + } 48 + _ => println!("moved {}", result.uri), 49 + } 50 + 51 + Ok(session::refreshed_session(&client)) 52 + } 53 + } 54 + 55 + /// Interpret the destination argument. 56 + /// 57 + /// - Trailing `/` → must resolve to an existing directory 58 + /// - Resolves to an existing directory → move into it 59 + /// - Otherwise → rename (new name = last path segment or bare name) 60 + async fn resolve_destination( 61 + tree: &DirectoryTree, 62 + client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>, 63 + destination: &str, 64 + _source: &directories::ResolvedPath, 65 + ) -> Result<MoveDestination, CoreError> { 66 + let explicit_directory = destination.ends_with('/'); 67 + let trimmed = destination.trim_end_matches('/'); 68 + 69 + // Try resolving as path/name in the tree. 70 + match tree.resolve(client, trimmed).await { 71 + Ok(resolved) if resolved.kind == EntryKind::Directory => { 72 + Ok(MoveDestination::IntoDirectory { 73 + directory_uri: resolved.uri, 74 + }) 75 + } 76 + Ok(_) if explicit_directory => Err(CoreError::NotFound(format!( 77 + "{trimmed:?} is not a directory" 78 + ))), 79 + Ok(_) => { 80 + // Resolved to a document — that's a naming conflict. 81 + Err(CoreError::InvalidRecord(format!( 82 + "a document named {trimmed:?} already exists" 83 + ))) 84 + } 85 + Err(CoreError::NotFound(_)) if explicit_directory => Err(CoreError::NotFound(format!( 86 + "directory not found: {trimmed:?}" 87 + ))), 88 + Err(CoreError::NotFound(_)) => { 89 + // Doesn't exist — treat as a rename. 90 + Ok(MoveDestination::Rename { 91 + new_name: trimmed.to_string(), 92 + }) 93 + } 94 + Err(e) => Err(e), 95 + } 96 + }
+22
crates/opake-cli/src/commands/tree.rs
··· 1 + use anyhow::Result; 2 + use clap::Args; 3 + use opake_core::client::Session; 4 + use opake_core::directories::DirectoryTree; 5 + 6 + use crate::commands::Execute; 7 + use crate::session::{self, CommandContext}; 8 + 9 + #[derive(Args)] 10 + /// Display directory hierarchy as a tree 11 + pub struct TreeCommand; 12 + 13 + impl Execute for TreeCommand { 14 + async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 15 + let mut client = session::load_client(&ctx.did)?; 16 + let (tree, documents) = DirectoryTree::load_full(&mut client).await?; 17 + 18 + println!("{}", tree.render(&documents)); 19 + 20 + Ok(session::refreshed_session(&client)) 21 + } 22 + }
+20 -1
crates/opake-cli/src/commands/upload.rs
··· 6 6 use clap::Args; 7 7 use opake_core::atproto; 8 8 use opake_core::crypto::OsRng; 9 + use opake_core::directories::{self, DirectoryTree, EntryKind}; 9 10 use opake_core::documents::{self, KeyringUploadParams, UploadParams}; 10 11 use opake_core::keyrings; 11 12 ··· 29 30 /// Comma-separated tags for categorization 30 31 #[arg(long, value_delimiter = ',')] 31 32 tags: Vec<String>, 33 + 34 + /// Place the uploaded document into a directory 35 + #[arg(long)] 36 + dir: Option<String>, 32 37 } 33 38 34 39 impl Execute for UploadCommand { ··· 84 89 documents::encrypt_and_upload(&mut client, &params, &mut OsRng).await? 85 90 }; 86 91 87 - println!("{} → {}", filename, uri); 92 + if let Some(dir_path) = &self.dir { 93 + let tree = DirectoryTree::load(&mut client).await?; 94 + let resolved = tree.resolve(&mut client, dir_path).await?; 95 + 96 + if resolved.kind != EntryKind::Directory { 97 + anyhow::bail!("{dir_path:?} is not a directory"); 98 + } 99 + 100 + directories::add_entry(&mut client, &resolved.uri, &uri, &now).await?; 101 + println!("{} → {} (in {})", filename, uri, dir_path); 102 + } else { 103 + println!("{} → {}", filename, uri); 104 + } 105 + 88 106 Ok(session::refreshed_session(&client)) 89 107 } 90 108 } ··· 100 118 path: PathBuf::from("/tmp/opake-test-nonexistent-file-abc123"), 101 119 keyring: None, 102 120 tags: vec![], 121 + dir: None, 103 122 }; 104 123 let ctx = CommandContext { 105 124 did: "did:plc:test".into(),
+6
crates/opake-cli/src/main.rs
··· 37 37 SetDefault(commands::set_default::SetDefaultCommand), 38 38 Upload(commands::upload::UploadCommand), 39 39 Download(commands::download::DownloadCommand), 40 + Cat(commands::cat::CatCommand), 40 41 Inbox(commands::inbox::InboxCommand), 41 42 Ls(commands::ls::LsCommand), 42 43 Mkdir(commands::mkdir::MkdirCommand), 44 + Mv(commands::mv::MvCommand), 43 45 Rm(commands::rm::RmCommand), 44 46 Resolve(commands::resolve::ResolveCommand), 45 47 Share(commands::share::ShareCommand), 46 48 Shared(commands::shared::SharedCommand), 47 49 Revoke(commands::revoke::RevokeCommand), 48 50 Keyring(commands::keyring::KeyringCommand), 51 + Tree(commands::tree::TreeCommand), 49 52 } 50 53 51 54 async fn run_with_context(as_flag: Option<&str>, cmd: impl Execute) -> anyhow::Result<()> { ··· 94 97 95 98 Command::Upload(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 96 99 Command::Download(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 100 + Command::Cat(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 97 101 Command::Inbox(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 98 102 Command::Ls(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 99 103 Command::Mkdir(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 104 + Command::Mv(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 100 105 Command::Rm(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 101 106 Command::Resolve(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 102 107 Command::Share(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 103 108 Command::Shared(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 104 109 Command::Revoke(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 105 110 Command::Keyring(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 111 + Command::Tree(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 106 112 } 107 113 108 114 Ok(())
+2
crates/opake-core/src/directories/mod.rs
··· 9 9 mod entries; 10 10 mod get_or_create_root; 11 11 mod list; 12 + mod mv; 12 13 mod remove; 13 14 mod tree; 14 15 ··· 17 18 pub use entries::{add_entry, remove_entry}; 18 19 pub use get_or_create_root::get_or_create_root; 19 20 pub use list::{list_directories, DirectoryEntry}; 21 + pub use mv::{check_cycle, move_entry, MoveDestination, MoveResult}; 20 22 pub use remove::{remove, RemoveResult}; 21 23 pub use tree::{DirectoryTree, EntryKind, ResolvedPath}; 22 24
+175
crates/opake-core/src/directories/mv.rs
··· 1 + // Move and rename operations for documents and directories. 2 + // 3 + // Move = re-parent (remove from old directory, add to new directory). 4 + // Rename = update the record's name field via putRecord. 5 + // Both can happen in a single `opake mv` invocation. 6 + 7 + use log::debug; 8 + 9 + use crate::atproto; 10 + use crate::client::{Transport, XrpcClient}; 11 + use crate::documents::DOCUMENT_COLLECTION; 12 + use crate::error::Error; 13 + use crate::records::{self, Directory, Document}; 14 + 15 + use super::entries::{add_entry, remove_entry}; 16 + use super::tree::{DirectoryTree, EntryKind, ResolvedPath}; 17 + use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_RKEY}; 18 + 19 + #[derive(Debug)] 20 + pub struct MoveResult { 21 + pub uri: String, 22 + pub new_name: Option<String>, 23 + } 24 + 25 + /// Move and/or rename a document or directory. 26 + /// 27 + /// `destination` is the resolved target — either a directory to move into, 28 + /// or a new name for the source. The caller (CLI) handles the ambiguity of 29 + /// destination interpretation before calling this. 30 + pub async fn move_entry( 31 + client: &mut XrpcClient<impl Transport>, 32 + _tree: &DirectoryTree, 33 + source: &ResolvedPath, 34 + destination: &MoveDestination, 35 + modified_at: &str, 36 + ) -> Result<MoveResult, Error> { 37 + match destination { 38 + MoveDestination::IntoDirectory { directory_uri } => { 39 + move_into_directory(client, source, directory_uri, modified_at).await 40 + } 41 + MoveDestination::Rename { new_name } => { 42 + rename_entry(client, source, new_name, modified_at).await 43 + } 44 + MoveDestination::MoveAndRename { 45 + directory_uri, 46 + new_name, 47 + } => { 48 + // Can't happen in current CLI UX, but the core supports it. 49 + move_into_directory(client, source, directory_uri, modified_at).await?; 50 + rename_entry(client, source, new_name, modified_at).await 51 + } 52 + } 53 + } 54 + 55 + /// What the destination resolves to. 56 + #[derive(Debug)] 57 + pub enum MoveDestination { 58 + /// Move into an existing directory, keeping the current name. 59 + IntoDirectory { directory_uri: String }, 60 + /// Rename in place (no directory change). 61 + Rename { new_name: String }, 62 + /// Move into a directory AND rename. 63 + MoveAndRename { 64 + directory_uri: String, 65 + new_name: String, 66 + }, 67 + } 68 + 69 + /// Check that moving a directory into a target doesn't create a cycle. 70 + /// 71 + /// A directory can't be moved into itself or any of its descendants. 72 + pub fn check_cycle( 73 + tree: &DirectoryTree, 74 + source_uri: &str, 75 + target_dir_uri: &str, 76 + ) -> Result<(), Error> { 77 + if source_uri == target_dir_uri { 78 + return Err(Error::InvalidRecord( 79 + "cannot move a directory into itself".into(), 80 + )); 81 + } 82 + 83 + let descendants = tree.collect_descendants(source_uri); 84 + for (uri, kind) in &descendants { 85 + if kind == &EntryKind::Directory && uri == target_dir_uri { 86 + return Err(Error::InvalidRecord( 87 + "cannot move a directory into one of its descendants".into(), 88 + )); 89 + } 90 + } 91 + 92 + Ok(()) 93 + } 94 + 95 + async fn move_into_directory( 96 + client: &mut XrpcClient<impl Transport>, 97 + source: &ResolvedPath, 98 + target_dir_uri: &str, 99 + modified_at: &str, 100 + ) -> Result<MoveResult, Error> { 101 + // Remove from old parent if tracked. 102 + if let Some(parent_uri) = &source.parent_uri { 103 + if parent_uri == target_dir_uri { 104 + return Err(Error::InvalidRecord(format!( 105 + "{:?} is already in that directory", 106 + source.name, 107 + ))); 108 + } 109 + debug!("removing {} from old parent {}", source.uri, parent_uri); 110 + remove_entry(client, parent_uri, &source.uri, modified_at).await?; 111 + } 112 + 113 + debug!("adding {} to new parent {}", source.uri, target_dir_uri); 114 + add_entry(client, target_dir_uri, &source.uri, modified_at).await?; 115 + 116 + Ok(MoveResult { 117 + uri: source.uri.clone(), 118 + new_name: None, 119 + }) 120 + } 121 + 122 + async fn rename_entry( 123 + client: &mut XrpcClient<impl Transport>, 124 + source: &ResolvedPath, 125 + new_name: &str, 126 + modified_at: &str, 127 + ) -> Result<MoveResult, Error> { 128 + let at_uri = atproto::parse_at_uri(&source.uri)?; 129 + 130 + match source.kind { 131 + EntryKind::Document => { 132 + let entry = client 133 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 134 + .await?; 135 + let mut doc: Document = serde_json::from_value(entry.value)?; 136 + records::check_version(doc.version)?; 137 + 138 + doc.name = new_name.to_string(); 139 + doc.modified_at = Some(modified_at.to_string()); 140 + 141 + client 142 + .put_record(DOCUMENT_COLLECTION, &at_uri.rkey, &doc) 143 + .await?; 144 + } 145 + EntryKind::Directory => { 146 + if at_uri.rkey == ROOT_DIRECTORY_RKEY { 147 + return Err(Error::InvalidRecord( 148 + "cannot rename the root directory".into(), 149 + )); 150 + } 151 + 152 + let entry = client 153 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 154 + .await?; 155 + let mut dir: Directory = serde_json::from_value(entry.value)?; 156 + records::check_version(dir.version)?; 157 + 158 + dir.name = new_name.to_string(); 159 + dir.modified_at = Some(modified_at.to_string()); 160 + 161 + client 162 + .put_record(DIRECTORY_COLLECTION, &at_uri.rkey, &dir) 163 + .await?; 164 + } 165 + } 166 + 167 + Ok(MoveResult { 168 + uri: source.uri.clone(), 169 + new_name: Some(new_name.to_string()), 170 + }) 171 + } 172 + 173 + #[cfg(test)] 174 + #[path = "mv_tests.rs"] 175 + mod tests;
+302
crates/opake-core/src/directories/mv_tests.rs
··· 1 + use super::*; 2 + use crate::client::{HttpResponse, RequestBody}; 3 + use crate::records::{Directory, Document}; 4 + use crate::test_utils::MockTransport; 5 + 6 + use super::super::tests::{ 7 + dummy_directory, dummy_directory_with_entries, get_record_response, list_records_response, 8 + mock_client, put_record_response, 9 + }; 10 + use crate::documents::tests::dummy_document; 11 + 12 + fn record_response<T: serde::Serialize>(uri: &str, record: &T) -> HttpResponse { 13 + HttpResponse { 14 + status: 200, 15 + body: serde_json::to_vec(&serde_json::json!({ 16 + "uri": uri, 17 + "cid": "bafyrecord", 18 + "value": record, 19 + })) 20 + .unwrap(), 21 + } 22 + } 23 + 24 + const ROOT_URI: &str = "at://did:plc:test/app.opake.cloud.directory/self"; 25 + const DIR_A_URI: &str = "at://did:plc:test/app.opake.cloud.directory/dirA"; 26 + const DIR_B_URI: &str = "at://did:plc:test/app.opake.cloud.directory/dirB"; 27 + const DOC_URI: &str = "at://did:plc:test/app.opake.cloud.document/doc1"; 28 + 29 + async fn load_tree_with(dirs: &[(&str, Directory)]) -> DirectoryTree { 30 + let mock = MockTransport::new(); 31 + mock.enqueue(list_records_response(dirs, None)); 32 + let mut client = mock_client(mock); 33 + DirectoryTree::load(&mut client).await.unwrap() 34 + } 35 + 36 + fn source_doc(parent_uri: Option<&str>) -> ResolvedPath { 37 + ResolvedPath { 38 + uri: DOC_URI.to_string(), 39 + kind: EntryKind::Document, 40 + name: "beach.jpg".to_string(), 41 + parent_uri: parent_uri.map(String::from), 42 + } 43 + } 44 + 45 + fn source_dir(uri: &str, name: &str, parent_uri: Option<&str>) -> ResolvedPath { 46 + ResolvedPath { 47 + uri: uri.to_string(), 48 + kind: EntryKind::Directory, 49 + name: name.to_string(), 50 + parent_uri: parent_uri.map(String::from), 51 + } 52 + } 53 + 54 + // -- move into directory -- 55 + 56 + #[tokio::test] 57 + async fn move_doc_into_directory() { 58 + let tree = load_tree_with(&[ 59 + ( 60 + "self", 61 + dummy_directory_with_entries("/", vec![DOC_URI.into()]), 62 + ), 63 + ("dirA", dummy_directory("Photos")), 64 + ]) 65 + .await; 66 + 67 + let mock = MockTransport::new(); 68 + // remove_entry: get old parent, put old parent 69 + mock.enqueue(get_record_response( 70 + ROOT_URI, 71 + &dummy_directory_with_entries("/", vec![DOC_URI.into()]), 72 + )); 73 + mock.enqueue(put_record_response(ROOT_URI)); 74 + // add_entry: get new parent, put new parent 75 + mock.enqueue(get_record_response(DIR_A_URI, &dummy_directory("Photos"))); 76 + mock.enqueue(put_record_response(DIR_A_URI)); 77 + 78 + let mut client = mock_client(mock.clone()); 79 + let source = source_doc(Some(ROOT_URI)); 80 + let dest = MoveDestination::IntoDirectory { 81 + directory_uri: DIR_A_URI.to_string(), 82 + }; 83 + 84 + let result = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 85 + .await 86 + .unwrap(); 87 + 88 + assert_eq!(result.uri, DOC_URI); 89 + assert!(result.new_name.is_none()); 90 + 91 + let reqs = mock.requests(); 92 + assert_eq!(reqs.len(), 4); 93 + 94 + // Old parent should have doc removed 95 + match &reqs[1].body { 96 + Some(RequestBody::Json(v)) => { 97 + let dir: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 98 + assert!(dir.entries.is_empty()); 99 + } 100 + _ => panic!("expected JSON body"), 101 + } 102 + 103 + // New parent should have doc added 104 + match &reqs[3].body { 105 + Some(RequestBody::Json(v)) => { 106 + let dir: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 107 + assert_eq!(dir.entries, vec![DOC_URI]); 108 + } 109 + _ => panic!("expected JSON body"), 110 + } 111 + } 112 + 113 + #[tokio::test] 114 + async fn move_untracked_doc_into_directory() { 115 + let tree = load_tree_with(&[ 116 + ("self", dummy_directory("/")), 117 + ("dirA", dummy_directory("Photos")), 118 + ]) 119 + .await; 120 + 121 + let mock = MockTransport::new(); 122 + // No remove_entry — doc has no parent. Just add_entry. 123 + mock.enqueue(get_record_response(DIR_A_URI, &dummy_directory("Photos"))); 124 + mock.enqueue(put_record_response(DIR_A_URI)); 125 + 126 + let mut client = mock_client(mock.clone()); 127 + let source = source_doc(None); 128 + let dest = MoveDestination::IntoDirectory { 129 + directory_uri: DIR_A_URI.to_string(), 130 + }; 131 + 132 + let result = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 133 + .await 134 + .unwrap(); 135 + 136 + assert_eq!(result.uri, DOC_URI); 137 + assert_eq!(mock.requests().len(), 2); 138 + } 139 + 140 + #[tokio::test] 141 + async fn move_into_same_directory_rejected() { 142 + let tree = load_tree_with(&[( 143 + "self", 144 + dummy_directory_with_entries("/", vec![DOC_URI.into()]), 145 + )]) 146 + .await; 147 + 148 + let mock = MockTransport::new(); 149 + let mut client = mock_client(mock); 150 + let source = source_doc(Some(ROOT_URI)); 151 + let dest = MoveDestination::IntoDirectory { 152 + directory_uri: ROOT_URI.to_string(), 153 + }; 154 + 155 + let err = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 156 + .await 157 + .unwrap_err(); 158 + 159 + assert!(err.to_string().contains("already in that directory")); 160 + } 161 + 162 + // -- rename -- 163 + 164 + #[tokio::test] 165 + async fn rename_document() { 166 + let tree = load_tree_with(&[("self", dummy_directory("/"))]).await; 167 + 168 + let doc = dummy_document("beach.jpg", 1000, vec![]); 169 + let mock = MockTransport::new(); 170 + mock.enqueue(record_response(DOC_URI, &doc)); 171 + mock.enqueue(put_record_response(DOC_URI)); 172 + 173 + let mut client = mock_client(mock.clone()); 174 + let source = source_doc(Some(ROOT_URI)); 175 + let dest = MoveDestination::Rename { 176 + new_name: "sunset.jpg".to_string(), 177 + }; 178 + 179 + let result = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 180 + .await 181 + .unwrap(); 182 + 183 + assert_eq!(result.uri, DOC_URI); 184 + assert_eq!(result.new_name.as_deref(), Some("sunset.jpg")); 185 + 186 + let reqs = mock.requests(); 187 + match &reqs[1].body { 188 + Some(RequestBody::Json(v)) => { 189 + let updated: Document = serde_json::from_value(v["record"].clone()).unwrap(); 190 + assert_eq!(updated.name, "sunset.jpg"); 191 + assert_eq!(updated.modified_at.unwrap(), "2026-03-01T12:00:00Z"); 192 + } 193 + _ => panic!("expected JSON body"), 194 + } 195 + } 196 + 197 + #[tokio::test] 198 + async fn rename_directory() { 199 + let tree = load_tree_with(&[ 200 + ( 201 + "self", 202 + dummy_directory_with_entries("/", vec![DIR_A_URI.into()]), 203 + ), 204 + ("dirA", dummy_directory("Photos")), 205 + ]) 206 + .await; 207 + 208 + let mock = MockTransport::new(); 209 + mock.enqueue(get_record_response(DIR_A_URI, &dummy_directory("Photos"))); 210 + mock.enqueue(put_record_response(DIR_A_URI)); 211 + 212 + let mut client = mock_client(mock.clone()); 213 + let source = source_dir(DIR_A_URI, "Photos", Some(ROOT_URI)); 214 + let dest = MoveDestination::Rename { 215 + new_name: "Memories".to_string(), 216 + }; 217 + 218 + let result = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 219 + .await 220 + .unwrap(); 221 + 222 + assert_eq!(result.uri, DIR_A_URI); 223 + assert_eq!(result.new_name.as_deref(), Some("Memories")); 224 + 225 + let reqs = mock.requests(); 226 + match &reqs[1].body { 227 + Some(RequestBody::Json(v)) => { 228 + let updated: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 229 + assert_eq!(updated.name, "Memories"); 230 + } 231 + _ => panic!("expected JSON body"), 232 + } 233 + } 234 + 235 + #[tokio::test] 236 + async fn rename_root_directory_rejected() { 237 + let tree = load_tree_with(&[("self", dummy_directory("/"))]).await; 238 + 239 + let mock = MockTransport::new(); 240 + let mut client = mock_client(mock); 241 + let source = source_dir(ROOT_URI, "/", None); 242 + let dest = MoveDestination::Rename { 243 + new_name: "newroot".to_string(), 244 + }; 245 + 246 + let err = move_entry(&mut client, &tree, &source, &dest, "2026-03-01T12:00:00Z") 247 + .await 248 + .unwrap_err(); 249 + 250 + assert!(err.to_string().contains("cannot rename the root directory")); 251 + } 252 + 253 + // -- cycle detection -- 254 + 255 + #[tokio::test] 256 + async fn cycle_self_reference() { 257 + let tree = load_tree_with(&[ 258 + ( 259 + "self", 260 + dummy_directory_with_entries("/", vec![DIR_A_URI.into()]), 261 + ), 262 + ("dirA", dummy_directory("Photos")), 263 + ]) 264 + .await; 265 + 266 + let err = check_cycle(&tree, DIR_A_URI, DIR_A_URI).unwrap_err(); 267 + assert!(err.to_string().contains("into itself")); 268 + } 269 + 270 + #[tokio::test] 271 + async fn cycle_into_descendant() { 272 + let tree = load_tree_with(&[ 273 + ( 274 + "self", 275 + dummy_directory_with_entries("/", vec![DIR_A_URI.into()]), 276 + ), 277 + ( 278 + "dirA", 279 + dummy_directory_with_entries("Photos", vec![DIR_B_URI.into()]), 280 + ), 281 + ("dirB", dummy_directory("Archive")), 282 + ]) 283 + .await; 284 + 285 + let err = check_cycle(&tree, DIR_A_URI, DIR_B_URI).unwrap_err(); 286 + assert!(err.to_string().contains("descendants")); 287 + } 288 + 289 + #[tokio::test] 290 + async fn no_cycle_for_sibling() { 291 + let tree = load_tree_with(&[ 292 + ( 293 + "self", 294 + dummy_directory_with_entries("/", vec![DIR_A_URI.into(), DIR_B_URI.into()]), 295 + ), 296 + ("dirA", dummy_directory("Photos")), 297 + ("dirB", dummy_directory("Archive")), 298 + ]) 299 + .await; 300 + 301 + check_cycle(&tree, DIR_A_URI, DIR_B_URI).unwrap(); 302 + }
+119 -1
crates/opake-core/src/directories/tree.rs
··· 18 18 use crate::error::Error; 19 19 use crate::records::{self, Directory, Document}; 20 20 21 - use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_RKEY}; 21 + use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_NAME, ROOT_DIRECTORY_RKEY}; 22 22 23 23 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 24 24 pub enum EntryKind { ··· 154 154 } 155 155 156 156 self.resolve_bare_name(client, reference).await 157 + } 158 + 159 + /// Load the full directory hierarchy including document names. 160 + /// 161 + /// Makes two paginated API calls: one for all directories, one for all 162 + /// documents. Returns a tree that can render without additional API calls. 163 + pub async fn load_full( 164 + client: &mut XrpcClient<impl Transport>, 165 + ) -> Result<(Self, HashMap<String, String>), Error> { 166 + let tree = Self::load(client).await?; 167 + 168 + let doc_entries: Vec<(String, String)> = 169 + list_collection(client, DOCUMENT_COLLECTION, |uri, doc: Document| { 170 + (uri.to_owned(), doc.name) 171 + }) 172 + .await?; 173 + 174 + let documents: HashMap<String, String> = doc_entries.into_iter().collect(); 175 + 176 + debug!("loaded {} documents for full tree", documents.len()); 177 + 178 + Ok((tree, documents)) 179 + } 180 + 181 + /// Build a tree-formatted string of the entire hierarchy. 182 + /// 183 + /// Requires the document name map from `load_full`. Entries within each 184 + /// directory are sorted: directories first (alphabetical), then documents 185 + /// (alphabetical). 186 + pub fn render(&self, documents: &HashMap<String, String>) -> String { 187 + let mut output = String::from(ROOT_DIRECTORY_NAME); 188 + 189 + let root_uri = match &self.root_uri { 190 + Some(uri) => uri, 191 + None => return output, 192 + }; 193 + 194 + let dir = match self.directories.get(root_uri) { 195 + Some(d) => d, 196 + None => return output, 197 + }; 198 + 199 + let sorted = self.sort_entries(&dir.entries, documents); 200 + self.render_entries(&sorted, documents, &mut output, ""); 201 + 202 + output 203 + } 204 + 205 + fn sort_entries( 206 + &self, 207 + entries: &[String], 208 + documents: &HashMap<String, String>, 209 + ) -> Vec<(String, EntryKind, String)> { 210 + let mut dirs: Vec<(String, EntryKind, String)> = Vec::new(); 211 + let mut docs: Vec<(String, EntryKind, String)> = Vec::new(); 212 + 213 + for uri in entries { 214 + match entry_kind_from_uri(uri) { 215 + Some(EntryKind::Directory) => { 216 + let name = self 217 + .directories 218 + .get(uri.as_str()) 219 + .map(|d| d.name.clone()) 220 + .unwrap_or_else(|| "?".into()); 221 + dirs.push((uri.clone(), EntryKind::Directory, name)); 222 + } 223 + Some(EntryKind::Document) => { 224 + let name = documents 225 + .get(uri.as_str()) 226 + .cloned() 227 + .unwrap_or_else(|| "?".into()); 228 + docs.push((uri.clone(), EntryKind::Document, name)); 229 + } 230 + None => {} 231 + } 232 + } 233 + 234 + dirs.sort_by(|a, b| a.2.to_lowercase().cmp(&b.2.to_lowercase())); 235 + docs.sort_by(|a, b| a.2.to_lowercase().cmp(&b.2.to_lowercase())); 236 + dirs.extend(docs); 237 + dirs 238 + } 239 + 240 + fn render_entries( 241 + &self, 242 + entries: &[(String, EntryKind, String)], 243 + documents: &HashMap<String, String>, 244 + output: &mut String, 245 + prefix: &str, 246 + ) { 247 + let count = entries.len(); 248 + for (i, (uri, kind, name)) in entries.iter().enumerate() { 249 + let is_last = i == count - 1; 250 + let connector = if is_last { "└── " } else { "├── " }; 251 + let suffix = if *kind == EntryKind::Directory { 252 + "/" 253 + } else { 254 + "" 255 + }; 256 + 257 + output.push('\n'); 258 + output.push_str(prefix); 259 + output.push_str(connector); 260 + output.push_str(name); 261 + output.push_str(suffix); 262 + 263 + if *kind == EntryKind::Directory { 264 + if let Some(dir) = self.directories.get(uri.as_str()) { 265 + let child_prefix = if is_last { 266 + format!("{prefix} ") 267 + } else { 268 + format!("{prefix}│ ") 269 + }; 270 + let sorted = self.sort_entries(&dir.entries, documents); 271 + self.render_entries(&sorted, documents, output, &child_prefix); 272 + } 273 + } 274 + } 157 275 } 158 276 159 277 /// Count descendant documents and directories under a directory URI.