An encrypted personal cloud built on the AT Protocol.

Add path-aware rm with recursive directory deletion

Rewrite the rm command to handle paths (Photos/beach.jpg), directory
targets (Photos), and recursive deletion (rm -r Photos). Resolution
uses a DirectoryTree that loads only the directory collection (one
paginated API call) and fetches document names on demand via getRecord.

Fast path preserved: bare document names resolve via the existing
single-call document resolver. Only paths and directory targets pay
the tree loading cost.

New modules in opake-core:
- directories::tree — in-memory directory hierarchy with lazy document
resolution. Supports AT-URI, slash-delimited path, and bare name
input forms.
- directories::remove — post-order recursive deletion with parent
entry cleanup and root deletion guard.

Adds flow documentation for all directory operations including path
resolution cost breakdown.

Closes #156

+1500 -13
+1
CHANGELOG.md
··· 50 50 - Fix missing HTTP status checks in XRPC client [#104](https://issues.opake.app/issues/104.html) 51 51 52 52 ### Changed 53 + - Add path-aware rm with recursive directory deletion [#156](https://issues.opake.app/issues/156.html) 53 54 - Update docs to reflect keyring rotation history [#46](https://issues.opake.app/issues/46.html) 54 55 - Add keyring member management (add-member, remove-member) [#56](https://issues.opake.app/issues/56.html) 55 56 - Add CLI keyring commands and local group key store [#57](https://issues.opake.app/issues/57.html)
+121 -7
crates/opake-cli/src/commands/rm.rs
··· 1 1 use anyhow::{Context, Result}; 2 + use chrono::Utc; 2 3 use clap::Args; 3 4 use opake_core::client::Session; 4 - use opake_core::documents; 5 + use opake_core::directories::{self, DirectoryTree, EntryKind, ResolvedPath}; 6 + use opake_core::error::Error as CoreError; 7 + use opake_core::{atproto, documents}; 5 8 6 9 use crate::commands::Execute; 7 10 use crate::session::{self, CommandContext}; 8 11 9 12 #[derive(Args)] 10 - /// Delete a document 13 + /// Delete a document or directory 11 14 pub struct RmCommand { 12 - /// AT URI or filename of the document 15 + /// Path, filename, or AT-URI 13 16 reference: String, 14 17 18 + /// Recursively delete directory contents 19 + #[arg(short = 'r', long)] 20 + recursive: bool, 21 + 15 22 /// Skip confirmation prompt 16 23 #[arg(short, long)] 17 24 yes: bool, 25 + } 26 + 27 + /// Determine whether a reference needs the full directory tree or can use 28 + /// the lightweight document-only resolver. 29 + /// 30 + /// Paths with `/` always need the tree. AT-URIs targeting directories need 31 + /// the tree. Bare names try document resolution first, falling back to the 32 + /// tree only when no document matches. 33 + enum Resolution { 34 + /// Resolved cheaply without building the full tree. 35 + Fast(ResolvedPath), 36 + /// Needs the full directory tree (path resolution, directory target, etc). 37 + NeedsTree, 38 + } 39 + 40 + async fn try_fast_resolve( 41 + client: &mut opake_core::client::XrpcClient<impl opake_core::client::Transport>, 42 + reference: &str, 43 + ) -> Result<Resolution, CoreError> { 44 + // Paths always need the tree. 45 + if reference.contains('/') { 46 + return Ok(Resolution::NeedsTree); 47 + } 48 + 49 + // AT-URIs targeting directories need the tree for emptiness checks / recursion. 50 + if reference.starts_with("at://") { 51 + let at_uri = atproto::parse_at_uri(reference)?; 52 + if at_uri.collection == directories::DIRECTORY_COLLECTION { 53 + return Ok(Resolution::NeedsTree); 54 + } 55 + // Document AT-URI — no tree needed, no parent cleanup. 56 + return Ok(Resolution::Fast(ResolvedPath { 57 + uri: reference.to_owned(), 58 + kind: EntryKind::Document, 59 + name: at_uri.rkey.clone(), 60 + parent_uri: None, 61 + })); 62 + } 63 + 64 + // Bare name — try document-only resolution (1 paginated API call). 65 + match documents::resolve_uri(client, reference).await { 66 + Ok(uri) => Ok(Resolution::Fast(ResolvedPath { 67 + uri, 68 + kind: EntryKind::Document, 69 + name: reference.to_owned(), 70 + parent_uri: None, 71 + })), 72 + // No document match — might be a directory name. Need the tree. 73 + Err(CoreError::NotFound(_)) => Ok(Resolution::NeedsTree), 74 + Err(e) => Err(e), 75 + } 18 76 } 19 77 20 78 impl Execute for RmCommand { 21 79 async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 22 80 let mut client = session::load_client(&ctx.did)?; 23 - let uri = documents::resolve_uri(&mut client, &self.reference).await?; 81 + let now = Utc::now().to_rfc3339(); 82 + 83 + let resolution = try_fast_resolve(&mut client, &self.reference).await?; 84 + 85 + // Fast path: document by bare name or AT-URI, no tree needed. 86 + if let Resolution::Fast(resolved) = resolution { 87 + if !self.yes { 88 + eprint!("delete {}? [y/N] ", resolved.name); 89 + let mut answer = String::new(); 90 + std::io::stdin() 91 + .read_line(&mut answer) 92 + .context("failed to read confirmation")?; 93 + if !answer.trim().eq_ignore_ascii_case("y") { 94 + println!("aborted"); 95 + return Ok(session::refreshed_session(&client)); 96 + } 97 + } 98 + 99 + documents::delete_document(&mut client, &resolved.uri).await?; 100 + println!("deleted {}", resolved.uri); 101 + return Ok(session::refreshed_session(&client)); 102 + } 103 + 104 + // Full tree path: paths, directories, recursive deletion. 105 + let tree = DirectoryTree::load(&mut client).await?; 106 + let resolved = tree.resolve(&mut client, &self.reference).await?; 24 107 25 108 if !self.yes { 26 - eprint!("delete {}? [y/N] ", uri); 109 + let prompt = match resolved.kind { 110 + EntryKind::Document => format!("delete {}?", resolved.name), 111 + EntryKind::Directory => { 112 + let (docs, dirs) = tree.count_descendants(&resolved.uri); 113 + if docs == 0 && dirs == 0 { 114 + format!("delete {}/?", resolved.name) 115 + } else if self.recursive { 116 + format!( 117 + "delete {}/? ({} documents, {} subdirectories)", 118 + resolved.name, docs, dirs, 119 + ) 120 + } else { 121 + format!("delete {}/? ({} entries)", resolved.name, docs + dirs) 122 + } 123 + } 124 + }; 125 + 126 + eprint!("{prompt} [y/N] "); 27 127 let mut answer = String::new(); 28 128 std::io::stdin() 29 129 .read_line(&mut answer) ··· 34 134 } 35 135 } 36 136 37 - documents::delete_document(&mut client, &uri).await?; 38 - println!("deleted {}", uri); 137 + let result = 138 + directories::remove(&mut client, &tree, &resolved, self.recursive, &now).await?; 139 + 140 + match resolved.kind { 141 + EntryKind::Document => println!("deleted {}", resolved.uri), 142 + EntryKind::Directory => { 143 + if result.documents_deleted == 0 && result.directories_deleted == 1 { 144 + println!("deleted {}", resolved.uri); 145 + } else { 146 + println!( 147 + "deleted {} ({} documents, {} directories)", 148 + resolved.uri, result.documents_deleted, result.directories_deleted, 149 + ); 150 + } 151 + } 152 + } 39 153 40 154 Ok(session::refreshed_session(&client)) 41 155 }
+4
crates/opake-core/src/directories/mod.rs
··· 9 9 mod entries; 10 10 mod get_or_create_root; 11 11 mod list; 12 + mod remove; 13 + mod tree; 12 14 13 15 pub use create::create_directory; 14 16 pub use delete::delete_directory; 15 17 pub use entries::{add_entry, remove_entry}; 16 18 pub use get_or_create_root::get_or_create_root; 17 19 pub use list::{list_directories, DirectoryEntry}; 20 + pub use remove::{remove, RemoveResult}; 21 + pub use tree::{DirectoryTree, EntryKind, ResolvedPath}; 18 22 19 23 pub const DIRECTORY_COLLECTION: &str = "app.opake.cloud.directory"; 20 24 pub const ROOT_DIRECTORY_RKEY: &str = "self";
+127
crates/opake-core/src/directories/remove.rs
··· 1 + // Recursive and non-recursive removal of documents and directories. 2 + // 3 + // Operates on a pre-loaded DirectoryTree snapshot. Deletion order is 4 + // post-order (children before parents) so the PDS never sees dangling 5 + // references mid-operation. 6 + 7 + use log::debug; 8 + 9 + use crate::atproto; 10 + use crate::client::{Transport, XrpcClient}; 11 + use crate::error::Error; 12 + 13 + use super::entries::remove_entry; 14 + use super::tree::{DirectoryTree, EntryKind, ResolvedPath}; 15 + use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_RKEY}; 16 + 17 + #[derive(Debug)] 18 + pub struct RemoveResult { 19 + pub documents_deleted: usize, 20 + pub directories_deleted: usize, 21 + } 22 + 23 + /// Delete a resolved target (document or directory) from the PDS. 24 + /// 25 + /// For directories, `recursive` must be true if the directory has children. 26 + /// The root directory cannot be deleted. 27 + pub async fn remove( 28 + client: &mut XrpcClient<impl Transport>, 29 + tree: &DirectoryTree, 30 + target: &ResolvedPath, 31 + recursive: bool, 32 + modified_at: &str, 33 + ) -> Result<RemoveResult, Error> { 34 + match target.kind { 35 + EntryKind::Document => remove_document(client, target, modified_at).await, 36 + EntryKind::Directory => { 37 + remove_directory(client, tree, target, recursive, modified_at).await 38 + } 39 + } 40 + } 41 + 42 + async fn remove_document( 43 + client: &mut XrpcClient<impl Transport>, 44 + target: &ResolvedPath, 45 + modified_at: &str, 46 + ) -> Result<RemoveResult, Error> { 47 + let at_uri = atproto::parse_at_uri(&target.uri)?; 48 + 49 + debug!("deleting document {}", target.uri); 50 + client 51 + .delete_record(&at_uri.collection, &at_uri.rkey) 52 + .await?; 53 + 54 + if let Some(parent_uri) = &target.parent_uri { 55 + remove_entry(client, parent_uri, &target.uri, modified_at).await?; 56 + } 57 + 58 + Ok(RemoveResult { 59 + documents_deleted: 1, 60 + directories_deleted: 0, 61 + }) 62 + } 63 + 64 + async fn remove_directory( 65 + client: &mut XrpcClient<impl Transport>, 66 + tree: &DirectoryTree, 67 + target: &ResolvedPath, 68 + recursive: bool, 69 + modified_at: &str, 70 + ) -> Result<RemoveResult, Error> { 71 + let at_uri = atproto::parse_at_uri(&target.uri)?; 72 + 73 + if at_uri.rkey == ROOT_DIRECTORY_RKEY { 74 + return Err(Error::InvalidRecord( 75 + "cannot delete the root directory".into(), 76 + )); 77 + } 78 + 79 + let (child_docs, child_dirs) = tree.count_descendants(&target.uri); 80 + let is_empty = child_docs == 0 && child_dirs == 0; 81 + 82 + if !is_empty && !recursive { 83 + return Err(Error::InvalidRecord(format!( 84 + "directory is not empty ({} documents, {} subdirectories) — use -r to delete recursively", 85 + child_docs, child_dirs, 86 + ))); 87 + } 88 + 89 + let mut documents_deleted = 0; 90 + let mut directories_deleted = 0; 91 + 92 + if recursive && !is_empty { 93 + let descendants = tree.collect_descendants(&target.uri); 94 + 95 + for (uri, kind) in &descendants { 96 + let descendant_uri = atproto::parse_at_uri(uri)?; 97 + debug!("deleting descendant {}", uri); 98 + client 99 + .delete_record(&descendant_uri.collection, &descendant_uri.rkey) 100 + .await?; 101 + 102 + match kind { 103 + EntryKind::Document => documents_deleted += 1, 104 + EntryKind::Directory => directories_deleted += 1, 105 + } 106 + } 107 + } 108 + 109 + debug!("deleting directory {}", target.uri); 110 + client 111 + .delete_record(DIRECTORY_COLLECTION, &at_uri.rkey) 112 + .await?; 113 + directories_deleted += 1; 114 + 115 + if let Some(parent_uri) = &target.parent_uri { 116 + remove_entry(client, parent_uri, &target.uri, modified_at).await?; 117 + } 118 + 119 + Ok(RemoveResult { 120 + documents_deleted, 121 + directories_deleted, 122 + }) 123 + } 124 + 125 + #[cfg(test)] 126 + #[path = "remove_tests.rs"] 127 + mod tests;
+273
crates/opake-core/src/directories/remove_tests.rs
··· 1 + use super::*; 2 + use crate::client::HttpResponse; 3 + use crate::test_utils::MockTransport; 4 + 5 + use super::super::tests::{ 6 + dummy_directory_with_entries, get_record_response, list_records_response, mock_client, 7 + put_record_response, 8 + }; 9 + 10 + const ROOT_URI: &str = "at://did:plc:test/app.opake.cloud.directory/self"; 11 + const DIR_PHOTOS_URI: &str = "at://did:plc:test/app.opake.cloud.directory/photos"; 12 + const DIR_VACATION_URI: &str = "at://did:plc:test/app.opake.cloud.directory/vacation"; 13 + const DOC_BEACH_URI: &str = "at://did:plc:test/app.opake.cloud.document/beach"; 14 + const DOC_NOTES_URI: &str = "at://did:plc:test/app.opake.cloud.document/notes"; 15 + const DOC_SUNSET_URI: &str = "at://did:plc:test/app.opake.cloud.document/sunset"; 16 + 17 + fn delete_ok() -> HttpResponse { 18 + HttpResponse { 19 + status: 200, 20 + body: b"{}".to_vec(), 21 + } 22 + } 23 + 24 + fn doc_record_response(uri: &str, name: &str) -> HttpResponse { 25 + use crate::documents::tests::dummy_document; 26 + let doc = dummy_document(name, 100, vec![]); 27 + HttpResponse { 28 + status: 200, 29 + body: serde_json::to_vec(&serde_json::json!({ 30 + "uri": uri, 31 + "cid": "bafydocument", 32 + "value": doc, 33 + })) 34 + .unwrap(), 35 + } 36 + } 37 + 38 + /// Load a simple tree: / → [Photos → [beach.jpg], notes.txt] 39 + async fn setup_simple( 40 + mock: &MockTransport, 41 + ) -> (crate::client::XrpcClient<MockTransport>, DirectoryTree) { 42 + mock.enqueue(list_records_response( 43 + &[ 44 + ( 45 + "self", 46 + dummy_directory_with_entries( 47 + "/", 48 + vec![DIR_PHOTOS_URI.into(), DOC_NOTES_URI.into()], 49 + ), 50 + ), 51 + ( 52 + "photos", 53 + dummy_directory_with_entries("Photos", vec![DOC_BEACH_URI.into()]), 54 + ), 55 + ], 56 + None, 57 + )); 58 + 59 + let mut client = mock_client(mock.clone()); 60 + let tree = DirectoryTree::load(&mut client).await.unwrap(); 61 + (client, tree) 62 + } 63 + 64 + /// Load a nested tree: / → Photos → [Vacation → [sunset.jpg], beach.jpg] 65 + async fn setup_nested( 66 + mock: &MockTransport, 67 + ) -> (crate::client::XrpcClient<MockTransport>, DirectoryTree) { 68 + mock.enqueue(list_records_response( 69 + &[ 70 + ( 71 + "self", 72 + dummy_directory_with_entries("/", vec![DIR_PHOTOS_URI.into()]), 73 + ), 74 + ( 75 + "photos", 76 + dummy_directory_with_entries( 77 + "Photos", 78 + vec![DIR_VACATION_URI.into(), DOC_BEACH_URI.into()], 79 + ), 80 + ), 81 + ( 82 + "vacation", 83 + dummy_directory_with_entries("Vacation", vec![DOC_SUNSET_URI.into()]), 84 + ), 85 + ], 86 + None, 87 + )); 88 + 89 + let mut client = mock_client(mock.clone()); 90 + let tree = DirectoryTree::load(&mut client).await.unwrap(); 91 + (client, tree) 92 + } 93 + 94 + // -- document removal -- 95 + 96 + #[tokio::test] 97 + async fn remove_document_with_parent() { 98 + let mock = MockTransport::new(); 99 + let (mut client, tree) = setup_simple(&mock).await; 100 + 101 + // resolve "Photos/beach.jpg": getRecord for beach.jpg 102 + mock.enqueue(doc_record_response(DOC_BEACH_URI, "beach.jpg")); 103 + let resolved = tree.resolve(&mut client, "Photos/beach.jpg").await.unwrap(); 104 + 105 + // delete_record for the document 106 + mock.enqueue(delete_ok()); 107 + // get_record + put_record for remove_entry on parent 108 + let photos = dummy_directory_with_entries("Photos", vec![DOC_BEACH_URI.into()]); 109 + mock.enqueue(get_record_response(DIR_PHOTOS_URI, &photos)); 110 + mock.enqueue(put_record_response(DIR_PHOTOS_URI)); 111 + 112 + let result = remove(&mut client, &tree, &resolved, false, "2026-03-01T12:00:00Z") 113 + .await 114 + .unwrap(); 115 + 116 + assert_eq!(result.documents_deleted, 1); 117 + assert_eq!(result.directories_deleted, 0); 118 + } 119 + 120 + #[tokio::test] 121 + async fn remove_document_without_parent() { 122 + // Simulate a document resolved via the CLI fast path (no parent tracking). 123 + let mock = MockTransport::new(); 124 + mock.enqueue(list_records_response(&[], None)); 125 + 126 + let mut client = mock_client(mock.clone()); 127 + let tree = DirectoryTree::load(&mut client).await.unwrap(); 128 + 129 + let resolved = ResolvedPath { 130 + uri: "at://did:plc:test/app.opake.cloud.document/orphan".into(), 131 + kind: EntryKind::Document, 132 + name: "orphan.txt".into(), 133 + parent_uri: None, 134 + }; 135 + 136 + // delete_record only — no parent to update 137 + mock.enqueue(delete_ok()); 138 + 139 + let result = remove(&mut client, &tree, &resolved, false, "2026-03-01T12:00:00Z") 140 + .await 141 + .unwrap(); 142 + 143 + assert_eq!(result.documents_deleted, 1); 144 + assert_eq!(result.directories_deleted, 0); 145 + } 146 + 147 + // -- empty directory removal -- 148 + 149 + #[tokio::test] 150 + async fn remove_empty_directory() { 151 + let mock = MockTransport::new(); 152 + let root = dummy_directory_with_entries( 153 + "/", 154 + vec!["at://did:plc:test/app.opake.cloud.directory/empty".into()], 155 + ); 156 + mock.enqueue(list_records_response( 157 + &[ 158 + ("self", root.clone()), 159 + ("empty", dummy_directory_with_entries("Empty", vec![])), 160 + ], 161 + None, 162 + )); 163 + 164 + let mut client = mock_client(mock.clone()); 165 + let tree = DirectoryTree::load(&mut client).await.unwrap(); 166 + 167 + let resolved = tree.resolve(&mut client, "Empty").await.unwrap(); 168 + 169 + // delete_record for the directory 170 + mock.enqueue(delete_ok()); 171 + // get_record + put_record for remove_entry on root 172 + mock.enqueue(get_record_response(ROOT_URI, &root)); 173 + mock.enqueue(put_record_response(ROOT_URI)); 174 + 175 + let result = remove(&mut client, &tree, &resolved, false, "2026-03-01T12:00:00Z") 176 + .await 177 + .unwrap(); 178 + 179 + assert_eq!(result.documents_deleted, 0); 180 + assert_eq!(result.directories_deleted, 1); 181 + } 182 + 183 + // -- non-empty directory without -r -- 184 + 185 + #[tokio::test] 186 + async fn remove_nonempty_without_recursive_errors() { 187 + let mock = MockTransport::new(); 188 + let (mut client, tree) = setup_simple(&mock).await; 189 + 190 + // resolve "Photos": directory, found in memory. But find_child_any 191 + // also scans document children for ambiguity. 192 + mock.enqueue(doc_record_response(DOC_NOTES_URI, "notes.txt")); 193 + let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 194 + 195 + let err = remove(&mut client, &tree, &resolved, false, "2026-03-01T12:00:00Z") 196 + .await 197 + .unwrap_err(); 198 + 199 + let msg = err.to_string(); 200 + assert!(msg.contains("not empty"), "got: {msg}"); 201 + assert!(msg.contains("-r"), "should suggest -r, got: {msg}"); 202 + } 203 + 204 + // -- recursive directory removal -- 205 + 206 + #[tokio::test] 207 + async fn remove_recursive_flat() { 208 + let mock = MockTransport::new(); 209 + let (mut client, tree) = setup_simple(&mock).await; 210 + 211 + // resolve "Photos" 212 + mock.enqueue(doc_record_response(DOC_NOTES_URI, "notes.txt")); 213 + let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 214 + 215 + // delete beach.jpg (descendant document) 216 + mock.enqueue(delete_ok()); 217 + // delete Photos directory 218 + mock.enqueue(delete_ok()); 219 + // get_record + put_record for remove_entry on root 220 + let root = dummy_directory_with_entries("/", vec![DIR_PHOTOS_URI.into(), DOC_NOTES_URI.into()]); 221 + mock.enqueue(get_record_response(ROOT_URI, &root)); 222 + mock.enqueue(put_record_response(ROOT_URI)); 223 + 224 + let result = remove(&mut client, &tree, &resolved, true, "2026-03-01T12:00:00Z") 225 + .await 226 + .unwrap(); 227 + 228 + assert_eq!(result.documents_deleted, 1); 229 + assert_eq!(result.directories_deleted, 1); 230 + } 231 + 232 + #[tokio::test] 233 + async fn remove_recursive_nested() { 234 + let mock = MockTransport::new(); 235 + let (mut client, tree) = setup_nested(&mock).await; 236 + 237 + // resolve "Photos": directory, found in memory. 238 + // find_child_any scans root's document children — but root has none. 239 + let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 240 + 241 + // Post-order: sunset.jpg, Vacation, beach.jpg, then Photos itself 242 + mock.enqueue(delete_ok()); // sunset.jpg 243 + mock.enqueue(delete_ok()); // Vacation 244 + mock.enqueue(delete_ok()); // beach.jpg 245 + mock.enqueue(delete_ok()); // Photos 246 + // get_record + put_record for remove_entry on root 247 + let root = dummy_directory_with_entries("/", vec![DIR_PHOTOS_URI.into()]); 248 + mock.enqueue(get_record_response(ROOT_URI, &root)); 249 + mock.enqueue(put_record_response(ROOT_URI)); 250 + 251 + let result = remove(&mut client, &tree, &resolved, true, "2026-03-01T12:00:00Z") 252 + .await 253 + .unwrap(); 254 + 255 + assert_eq!(result.documents_deleted, 2); // sunset + beach 256 + assert_eq!(result.directories_deleted, 2); // Vacation + Photos 257 + } 258 + 259 + // -- root deletion guard -- 260 + 261 + #[tokio::test] 262 + async fn remove_root_rejected() { 263 + let mock = MockTransport::new(); 264 + let (mut client, tree) = setup_simple(&mock).await; 265 + 266 + let resolved = tree.resolve_at_uri(&mut client, ROOT_URI).await.unwrap(); 267 + 268 + let err = remove(&mut client, &tree, &resolved, true, "2026-03-01T12:00:00Z") 269 + .await 270 + .unwrap_err(); 271 + 272 + assert!(err.to_string().contains("root directory")); 273 + }
+413
crates/opake-core/src/directories/tree.rs
··· 1 + // In-memory snapshot of the directory hierarchy for path resolution. 2 + // 3 + // Loads only directory records (one paginated API call). Document names 4 + // are resolved on demand via individual getRecord calls against the 5 + // entries of the relevant directory. This avoids fetching the entire 6 + // document collection for every path-based operation. 7 + // 8 + // Designed for reuse across rm, mv, and any future command that needs 9 + // to resolve user-facing paths to AT-URIs. 10 + 11 + use std::collections::HashMap; 12 + 13 + use log::debug; 14 + 15 + use crate::atproto; 16 + use crate::client::{list_collection, Transport, XrpcClient}; 17 + use crate::documents::DOCUMENT_COLLECTION; 18 + use crate::error::Error; 19 + use crate::records::{self, Directory, Document}; 20 + 21 + use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_RKEY}; 22 + 23 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 24 + pub enum EntryKind { 25 + Document, 26 + Directory, 27 + } 28 + 29 + #[derive(Debug, Clone)] 30 + pub struct ResolvedPath { 31 + pub uri: String, 32 + pub kind: EntryKind, 33 + /// Name of the resolved entry. 34 + pub name: String, 35 + /// Parent directory URI. None if the target isn't tracked in any directory. 36 + pub parent_uri: Option<String>, 37 + } 38 + 39 + #[derive(Debug)] 40 + struct DirectoryInfo { 41 + name: String, 42 + entries: Vec<String>, 43 + } 44 + 45 + #[derive(Debug)] 46 + pub struct DirectoryTree { 47 + /// URI → (name, entries) for every directory record. 48 + directories: HashMap<String, DirectoryInfo>, 49 + /// The root directory URI, if it exists. 50 + root_uri: Option<String>, 51 + } 52 + 53 + /// Determine entry kind from the collection segment of an AT-URI. 54 + fn entry_kind_from_uri(uri: &str) -> Option<EntryKind> { 55 + let at_uri = atproto::parse_at_uri(uri).ok()?; 56 + match at_uri.collection.as_str() { 57 + c if c == DOCUMENT_COLLECTION => Some(EntryKind::Document), 58 + c if c == DIRECTORY_COLLECTION => Some(EntryKind::Directory), 59 + _ => None, 60 + } 61 + } 62 + 63 + /// Fetch a single document record and return its name. 64 + /// 65 + /// Returns None for 404s, unparseable records, and future schema versions 66 + /// (same tolerance as list_collection). 67 + async fn fetch_document_name( 68 + client: &mut XrpcClient<impl Transport>, 69 + uri: &str, 70 + ) -> Result<Option<String>, Error> { 71 + let at_uri = atproto::parse_at_uri(uri)?; 72 + let entry = match client 73 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 74 + .await 75 + { 76 + Ok(e) => e, 77 + Err(Error::NotFound(_)) => return Ok(None), 78 + Err(e) => return Err(e), 79 + }; 80 + 81 + let doc: Document = match serde_json::from_value(entry.value) { 82 + Ok(d) => d, 83 + Err(_) => return Ok(None), 84 + }; 85 + 86 + if records::check_version(doc.version).is_err() { 87 + return Ok(None); 88 + } 89 + 90 + Ok(Some(doc.name)) 91 + } 92 + 93 + impl DirectoryTree { 94 + /// Load the directory hierarchy from the PDS. 95 + /// 96 + /// Makes one paginated API call (all directories). Documents are NOT 97 + /// loaded — they're fetched on demand during resolution. The root is 98 + /// detected from the listing by its rkey ("self"). 99 + pub async fn load(client: &mut XrpcClient<impl Transport>) -> Result<Self, Error> { 100 + let dir_entries: Vec<(String, DirectoryInfo)> = 101 + list_collection(client, DIRECTORY_COLLECTION, |uri, dir: Directory| { 102 + ( 103 + uri.to_owned(), 104 + DirectoryInfo { 105 + name: dir.name, 106 + entries: dir.entries, 107 + }, 108 + ) 109 + }) 110 + .await?; 111 + 112 + let directories: HashMap<String, DirectoryInfo> = dir_entries.into_iter().collect(); 113 + 114 + // Find root by rkey — it's the singleton at rkey "self". 115 + let root_uri = directories 116 + .keys() 117 + .find(|uri| { 118 + atproto::parse_at_uri(uri) 119 + .map(|u| u.rkey == ROOT_DIRECTORY_RKEY) 120 + .unwrap_or(false) 121 + }) 122 + .cloned(); 123 + 124 + debug!( 125 + "loaded tree: {} directories, root={}", 126 + directories.len(), 127 + root_uri.as_deref().unwrap_or("none"), 128 + ); 129 + 130 + Ok(Self { 131 + directories, 132 + root_uri, 133 + }) 134 + } 135 + 136 + /// Resolve a user-provided reference to an AT-URI with metadata. 137 + /// 138 + /// Accepts three forms: 139 + /// - `at://` URI — directories resolved from memory, documents via getRecord 140 + /// - Path with `/` — walked segment by segment from root 141 + /// - Bare name — searched in root's direct children (directories in memory, 142 + /// documents via getRecord). Without a root, only directories are searched. 143 + pub async fn resolve( 144 + &self, 145 + client: &mut XrpcClient<impl Transport>, 146 + reference: &str, 147 + ) -> Result<ResolvedPath, Error> { 148 + if reference.starts_with("at://") { 149 + return self.resolve_at_uri(client, reference).await; 150 + } 151 + 152 + if reference.contains('/') { 153 + return self.resolve_path(client, reference).await; 154 + } 155 + 156 + self.resolve_bare_name(client, reference).await 157 + } 158 + 159 + /// Count descendant documents and directories under a directory URI. 160 + /// 161 + /// Infers entry kind from the collection segment in each child URI. 162 + /// No API calls — works entirely from the loaded directory data. 163 + pub fn count_descendants(&self, uri: &str) -> (usize, usize) { 164 + let mut documents = 0usize; 165 + let mut directories = 0usize; 166 + let mut stack = vec![uri.to_owned()]; 167 + 168 + while let Some(current) = stack.pop() { 169 + if let Some(dir) = self.directories.get(&current) { 170 + for entry_uri in &dir.entries { 171 + match entry_kind_from_uri(entry_uri) { 172 + Some(EntryKind::Document) => documents += 1, 173 + Some(EntryKind::Directory) => { 174 + directories += 1; 175 + if self.directories.contains_key(entry_uri.as_str()) { 176 + stack.push(entry_uri.clone()); 177 + } 178 + } 179 + None => {} 180 + } 181 + } 182 + } 183 + } 184 + 185 + (documents, directories) 186 + } 187 + 188 + /// Collect all descendant URIs in post-order (children before parents) 189 + /// for correct deletion ordering. 190 + /// 191 + /// No API calls — kind is inferred from the URI collection segment. 192 + pub fn collect_descendants(&self, uri: &str) -> Vec<(String, EntryKind)> { 193 + let mut result = Vec::new(); 194 + self.collect_descendants_recursive(uri, &mut result); 195 + result 196 + } 197 + 198 + fn collect_descendants_recursive(&self, uri: &str, result: &mut Vec<(String, EntryKind)>) { 199 + if let Some(dir) = self.directories.get(uri) { 200 + for entry_uri in &dir.entries { 201 + match entry_kind_from_uri(entry_uri) { 202 + Some(EntryKind::Document) => { 203 + result.push((entry_uri.clone(), EntryKind::Document)); 204 + } 205 + Some(EntryKind::Directory) => { 206 + if self.directories.contains_key(entry_uri.as_str()) { 207 + self.collect_descendants_recursive(entry_uri, result); 208 + } 209 + result.push((entry_uri.clone(), EntryKind::Directory)); 210 + } 211 + None => {} 212 + } 213 + } 214 + } 215 + } 216 + 217 + pub async fn resolve_at_uri( 218 + &self, 219 + client: &mut XrpcClient<impl Transport>, 220 + uri: &str, 221 + ) -> Result<ResolvedPath, Error> { 222 + // Directories are in memory. 223 + if let Some(info) = self.directories.get(uri) { 224 + return Ok(ResolvedPath { 225 + uri: uri.to_owned(), 226 + kind: EntryKind::Directory, 227 + name: info.name.clone(), 228 + parent_uri: self.find_parent(uri), 229 + }); 230 + } 231 + 232 + // Documents need a getRecord for the name. 233 + if let Some(name) = fetch_document_name(client, uri).await? { 234 + return Ok(ResolvedPath { 235 + uri: uri.to_owned(), 236 + kind: EntryKind::Document, 237 + name, 238 + parent_uri: self.find_parent(uri), 239 + }); 240 + } 241 + 242 + Err(Error::NotFound(format!("URI not found: {uri}"))) 243 + } 244 + 245 + async fn resolve_path( 246 + &self, 247 + client: &mut XrpcClient<impl Transport>, 248 + path: &str, 249 + ) -> Result<ResolvedPath, Error> { 250 + let root_uri = self 251 + .root_uri 252 + .as_ref() 253 + .ok_or_else(|| Error::NotFound("no root directory — run `opake mkdir` first".into()))?; 254 + 255 + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); 256 + if segments.is_empty() { 257 + return Err(Error::InvalidRecord("empty path".into())); 258 + } 259 + 260 + let mut current_uri = root_uri.clone(); 261 + 262 + // Walk all segments except the last one — these must be directories. 263 + for &segment in &segments[..segments.len() - 1] { 264 + current_uri = self.find_child_directory(&current_uri, segment)?; 265 + } 266 + 267 + // Last segment can be either a document or a directory. 268 + let last = segments[segments.len() - 1]; 269 + self.find_child_any(client, &current_uri, last).await 270 + } 271 + 272 + async fn resolve_bare_name( 273 + &self, 274 + client: &mut XrpcClient<impl Transport>, 275 + name: &str, 276 + ) -> Result<ResolvedPath, Error> { 277 + match &self.root_uri { 278 + Some(root_uri) => self.find_child_any(client, root_uri, name).await, 279 + None => { 280 + // No root — search directories only. Documents should be 281 + // resolved via documents::resolve_uri before reaching the tree. 282 + let mut matches = Vec::new(); 283 + 284 + for (uri, info) in &self.directories { 285 + if info.name == name { 286 + matches.push(ResolvedPath { 287 + uri: uri.clone(), 288 + kind: EntryKind::Directory, 289 + name: name.to_owned(), 290 + parent_uri: self.find_parent(uri), 291 + }); 292 + } 293 + } 294 + 295 + match matches.len() { 296 + 0 => Err(Error::NotFound(format!( 297 + "no document or directory named {name:?} — use `opake ls` to see your files" 298 + ))), 299 + 1 => Ok(matches.into_iter().next().unwrap()), 300 + n => { 301 + let uris: Vec<String> = matches.iter().map(|m| m.uri.clone()).collect(); 302 + Err(Error::AmbiguousName { 303 + name: name.to_owned(), 304 + count: n, 305 + uris, 306 + }) 307 + } 308 + } 309 + } 310 + } 311 + } 312 + 313 + /// Find a child directory by name within a parent directory (in memory). 314 + fn find_child_directory(&self, parent_uri: &str, name: &str) -> Result<String, Error> { 315 + let parent = self 316 + .directories 317 + .get(parent_uri) 318 + .ok_or_else(|| Error::NotFound(format!("directory not found: {parent_uri}")))?; 319 + 320 + for entry_uri in &parent.entries { 321 + if let Some(info) = self.directories.get(entry_uri.as_str()) { 322 + if info.name == name { 323 + return Ok(entry_uri.clone()); 324 + } 325 + } 326 + } 327 + 328 + Err(Error::NotFound(format!( 329 + "no directory named {name:?} in {}", 330 + parent.name, 331 + ))) 332 + } 333 + 334 + /// Find a child by name in a directory. 335 + /// 336 + /// Checks directory children in memory first, then fetches document 337 + /// children individually via getRecord. 338 + async fn find_child_any( 339 + &self, 340 + client: &mut XrpcClient<impl Transport>, 341 + parent_uri: &str, 342 + name: &str, 343 + ) -> Result<ResolvedPath, Error> { 344 + let parent = self 345 + .directories 346 + .get(parent_uri) 347 + .ok_or_else(|| Error::NotFound(format!("directory not found: {parent_uri}")))?; 348 + 349 + let mut matches = Vec::new(); 350 + 351 + // Directory children: resolved from memory. 352 + for entry_uri in &parent.entries { 353 + if let Some(info) = self.directories.get(entry_uri.as_str()) { 354 + if info.name == name { 355 + matches.push(ResolvedPath { 356 + uri: entry_uri.clone(), 357 + kind: EntryKind::Directory, 358 + name: name.to_owned(), 359 + parent_uri: Some(parent_uri.to_owned()), 360 + }); 361 + } 362 + } 363 + } 364 + 365 + // Document children: fetched individually. 366 + for entry_uri in &parent.entries { 367 + if entry_kind_from_uri(entry_uri) != Some(EntryKind::Document) { 368 + continue; 369 + } 370 + 371 + if let Some(doc_name) = fetch_document_name(client, entry_uri).await? { 372 + if doc_name == name { 373 + matches.push(ResolvedPath { 374 + uri: entry_uri.clone(), 375 + kind: EntryKind::Document, 376 + name: name.to_owned(), 377 + parent_uri: Some(parent_uri.to_owned()), 378 + }); 379 + } 380 + } 381 + } 382 + 383 + match matches.len() { 384 + 0 => Err(Error::NotFound(format!( 385 + "no document or directory named {name:?} in {}", 386 + parent.name, 387 + ))), 388 + 1 => Ok(matches.into_iter().next().unwrap()), 389 + n => { 390 + let uris: Vec<String> = matches.iter().map(|m| m.uri.clone()).collect(); 391 + Err(Error::AmbiguousName { 392 + name: name.to_owned(), 393 + count: n, 394 + uris, 395 + }) 396 + } 397 + } 398 + } 399 + 400 + /// Scan all directories to find which one contains the given URI as an entry. 401 + fn find_parent(&self, child_uri: &str) -> Option<String> { 402 + for (dir_uri, info) in &self.directories { 403 + if info.entries.iter().any(|e| e == child_uri) { 404 + return Some(dir_uri.clone()); 405 + } 406 + } 407 + None 408 + } 409 + } 410 + 411 + #[cfg(test)] 412 + #[path = "tree_tests.rs"] 413 + mod tests;
+403
crates/opake-core/src/directories/tree_tests.rs
··· 1 + use super::*; 2 + use crate::client::HttpResponse; 3 + use crate::test_utils::MockTransport; 4 + 5 + use super::super::tests::{dummy_directory_with_entries, list_records_response, mock_client}; 6 + 7 + const ROOT_URI: &str = "at://did:plc:test/app.opake.cloud.directory/self"; 8 + const DIR_PHOTOS_URI: &str = "at://did:plc:test/app.opake.cloud.directory/photos"; 9 + const DIR_VACATION_URI: &str = "at://did:plc:test/app.opake.cloud.directory/vacation"; 10 + const DOC_BEACH_URI: &str = "at://did:plc:test/app.opake.cloud.document/beach"; 11 + const DOC_NOTES_URI: &str = "at://did:plc:test/app.opake.cloud.document/notes"; 12 + const DOC_SUNSET_URI: &str = "at://did:plc:test/app.opake.cloud.document/sunset"; 13 + 14 + /// getRecord response for a document — minimal but parseable. 15 + fn doc_record_response(uri: &str, name: &str) -> HttpResponse { 16 + use crate::documents::tests::dummy_document; 17 + let doc = dummy_document(name, 100, vec![]); 18 + HttpResponse { 19 + status: 200, 20 + body: serde_json::to_vec(&serde_json::json!({ 21 + "uri": uri, 22 + "cid": "bafydocument", 23 + "value": doc, 24 + })) 25 + .unwrap(), 26 + } 27 + } 28 + 29 + /// Load a simple tree: / → [Photos → [beach.jpg], notes.txt] 30 + /// 31 + /// Only enqueues the directory listing. Document getRecord calls are 32 + /// enqueued by individual tests as needed for resolve. 33 + async fn load_simple_tree(mock: &MockTransport) -> DirectoryTree { 34 + mock.enqueue(list_records_response( 35 + &[ 36 + ( 37 + "self", 38 + dummy_directory_with_entries( 39 + "/", 40 + vec![DIR_PHOTOS_URI.into(), DOC_NOTES_URI.into()], 41 + ), 42 + ), 43 + ( 44 + "photos", 45 + dummy_directory_with_entries("Photos", vec![DOC_BEACH_URI.into()]), 46 + ), 47 + ], 48 + None, 49 + )); 50 + 51 + let mut client = mock_client(mock.clone()); 52 + DirectoryTree::load(&mut client).await.unwrap() 53 + } 54 + 55 + /// Load a nested tree: / → Photos → [Vacation → [sunset.jpg], beach.jpg] 56 + async fn load_nested_tree(mock: &MockTransport) -> DirectoryTree { 57 + mock.enqueue(list_records_response( 58 + &[ 59 + ( 60 + "self", 61 + dummy_directory_with_entries("/", vec![DIR_PHOTOS_URI.into()]), 62 + ), 63 + ( 64 + "photos", 65 + dummy_directory_with_entries( 66 + "Photos", 67 + vec![DIR_VACATION_URI.into(), DOC_BEACH_URI.into()], 68 + ), 69 + ), 70 + ( 71 + "vacation", 72 + dummy_directory_with_entries("Vacation", vec![DOC_SUNSET_URI.into()]), 73 + ), 74 + ], 75 + None, 76 + )); 77 + 78 + let mut client = mock_client(mock.clone()); 79 + DirectoryTree::load(&mut client).await.unwrap() 80 + } 81 + 82 + // -- load -- 83 + 84 + #[tokio::test] 85 + async fn load_with_no_root() { 86 + let mock = MockTransport::new(); 87 + mock.enqueue(list_records_response(&[], None)); 88 + 89 + let mut client = mock_client(mock); 90 + let tree = DirectoryTree::load(&mut client).await.unwrap(); 91 + assert!(tree.root_uri.is_none()); 92 + } 93 + 94 + #[tokio::test] 95 + async fn load_propagates_pds_error() { 96 + let mock = MockTransport::new(); 97 + mock.enqueue(HttpResponse { 98 + status: 500, 99 + body: br#"{"error":"InternalServerError","message":"boom"}"#.to_vec(), 100 + }); 101 + 102 + let mut client = mock_client(mock); 103 + let err = DirectoryTree::load(&mut client).await.unwrap_err(); 104 + assert!(matches!(err, Error::Xrpc { .. })); 105 + } 106 + 107 + #[tokio::test] 108 + async fn load_detects_root_from_listing() { 109 + let mock = MockTransport::new(); 110 + mock.enqueue(list_records_response( 111 + &[("self", dummy_directory_with_entries("/", vec![]))], 112 + None, 113 + )); 114 + 115 + let mut client = mock_client(mock); 116 + let tree = DirectoryTree::load(&mut client).await.unwrap(); 117 + assert_eq!(tree.root_uri.as_deref(), Some(ROOT_URI)); 118 + } 119 + 120 + // -- resolve: AT-URI -- 121 + 122 + #[tokio::test] 123 + async fn resolve_at_uri_directory() { 124 + let mock = MockTransport::new(); 125 + let tree = load_simple_tree(&mock).await; 126 + 127 + let mut client = mock_client(mock); 128 + let resolved = tree.resolve(&mut client, DIR_PHOTOS_URI).await.unwrap(); 129 + assert_eq!(resolved.kind, EntryKind::Directory); 130 + assert_eq!(resolved.name, "Photos"); 131 + assert_eq!(resolved.parent_uri.as_deref(), Some(ROOT_URI)); 132 + } 133 + 134 + #[tokio::test] 135 + async fn resolve_at_uri_document() { 136 + let mock = MockTransport::new(); 137 + let tree = load_simple_tree(&mock).await; 138 + 139 + // getRecord for the document to fetch its name 140 + mock.enqueue(doc_record_response(DOC_BEACH_URI, "beach.jpg")); 141 + 142 + let mut client = mock_client(mock); 143 + let resolved = tree.resolve(&mut client, DOC_BEACH_URI).await.unwrap(); 144 + assert_eq!(resolved.uri, DOC_BEACH_URI); 145 + assert_eq!(resolved.kind, EntryKind::Document); 146 + assert_eq!(resolved.name, "beach.jpg"); 147 + assert_eq!(resolved.parent_uri.as_deref(), Some(DIR_PHOTOS_URI)); 148 + } 149 + 150 + #[tokio::test] 151 + async fn resolve_at_uri_not_found() { 152 + let mock = MockTransport::new(); 153 + let tree = load_simple_tree(&mock).await; 154 + 155 + // getRecord 404 for unknown document 156 + mock.enqueue(HttpResponse { 157 + status: 404, 158 + body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 159 + }); 160 + 161 + let mut client = mock_client(mock); 162 + let err = tree 163 + .resolve( 164 + &mut client, 165 + "at://did:plc:test/app.opake.cloud.document/nope", 166 + ) 167 + .await 168 + .unwrap_err(); 169 + assert!(matches!(err, Error::NotFound(_))); 170 + } 171 + 172 + // -- resolve: path -- 173 + 174 + #[tokio::test] 175 + async fn resolve_path_document_in_subdirectory() { 176 + let mock = MockTransport::new(); 177 + let tree = load_simple_tree(&mock).await; 178 + 179 + // getRecord for beach.jpg (document child of Photos) 180 + mock.enqueue(doc_record_response(DOC_BEACH_URI, "beach.jpg")); 181 + 182 + let mut client = mock_client(mock); 183 + let resolved = tree.resolve(&mut client, "Photos/beach.jpg").await.unwrap(); 184 + assert_eq!(resolved.uri, DOC_BEACH_URI); 185 + assert_eq!(resolved.kind, EntryKind::Document); 186 + assert_eq!(resolved.parent_uri.as_deref(), Some(DIR_PHOTOS_URI)); 187 + } 188 + 189 + #[tokio::test] 190 + async fn resolve_path_nested() { 191 + let mock = MockTransport::new(); 192 + let tree = load_nested_tree(&mock).await; 193 + 194 + // getRecord for sunset.jpg (document child of Vacation) 195 + mock.enqueue(doc_record_response(DOC_SUNSET_URI, "sunset.jpg")); 196 + 197 + let mut client = mock_client(mock); 198 + let resolved = tree 199 + .resolve(&mut client, "Photos/Vacation/sunset.jpg") 200 + .await 201 + .unwrap(); 202 + assert_eq!(resolved.uri, DOC_SUNSET_URI); 203 + assert_eq!(resolved.kind, EntryKind::Document); 204 + } 205 + 206 + #[tokio::test] 207 + async fn resolve_path_directory_target() { 208 + let mock = MockTransport::new(); 209 + let tree = load_nested_tree(&mock).await; 210 + 211 + // Vacation found in memory, but find_child_any still scans document 212 + // children of Photos for ambiguity. 213 + mock.enqueue(doc_record_response(DOC_BEACH_URI, "beach.jpg")); 214 + 215 + let mut client = mock_client(mock); 216 + let resolved = tree.resolve(&mut client, "Photos/Vacation").await.unwrap(); 217 + assert_eq!(resolved.uri, DIR_VACATION_URI); 218 + assert_eq!(resolved.kind, EntryKind::Directory); 219 + } 220 + 221 + #[tokio::test] 222 + async fn resolve_path_not_found_segment() { 223 + let mock = MockTransport::new(); 224 + let tree = load_simple_tree(&mock).await; 225 + 226 + // getRecord for the one document child of Photos — no match 227 + mock.enqueue(doc_record_response(DOC_BEACH_URI, "beach.jpg")); 228 + 229 + let mut client = mock_client(mock); 230 + let err = tree 231 + .resolve(&mut client, "Photos/missing.txt") 232 + .await 233 + .unwrap_err(); 234 + assert!(matches!(err, Error::NotFound(_))); 235 + } 236 + 237 + #[tokio::test] 238 + async fn resolve_path_missing_intermediate_directory() { 239 + let mock = MockTransport::new(); 240 + let tree = load_simple_tree(&mock).await; 241 + 242 + let mut client = mock_client(mock); 243 + let err = tree 244 + .resolve(&mut client, "Nope/beach.jpg") 245 + .await 246 + .unwrap_err(); 247 + assert!(matches!(err, Error::NotFound(_))); 248 + } 249 + 250 + #[tokio::test] 251 + async fn resolve_path_no_root_errors() { 252 + let mock = MockTransport::new(); 253 + mock.enqueue(list_records_response(&[], None)); 254 + 255 + let mut client = mock_client(mock.clone()); 256 + let tree = DirectoryTree::load(&mut client).await.unwrap(); 257 + 258 + let mut client = mock_client(mock); 259 + let err = tree 260 + .resolve(&mut client, "Photos/beach.jpg") 261 + .await 262 + .unwrap_err(); 263 + assert!(matches!(err, Error::NotFound(_))); 264 + } 265 + 266 + // -- resolve: bare name -- 267 + 268 + #[tokio::test] 269 + async fn resolve_bare_name_document() { 270 + let mock = MockTransport::new(); 271 + let tree = load_simple_tree(&mock).await; 272 + 273 + // Root has 2 entries: DIR_PHOTOS_URI (directory, checked in memory) 274 + // and DOC_NOTES_URI (document, needs getRecord). 275 + mock.enqueue(doc_record_response(DOC_NOTES_URI, "notes.txt")); 276 + 277 + let mut client = mock_client(mock); 278 + let resolved = tree.resolve(&mut client, "notes.txt").await.unwrap(); 279 + assert_eq!(resolved.uri, DOC_NOTES_URI); 280 + assert_eq!(resolved.kind, EntryKind::Document); 281 + } 282 + 283 + #[tokio::test] 284 + async fn resolve_bare_name_directory() { 285 + let mock = MockTransport::new(); 286 + let tree = load_simple_tree(&mock).await; 287 + 288 + // Photos is a directory — found in memory, no document getRecords needed 289 + // because directory match is found first. 290 + // But find_child_any still scans document children for ambiguity. 291 + mock.enqueue(doc_record_response(DOC_NOTES_URI, "notes.txt")); 292 + 293 + let mut client = mock_client(mock); 294 + let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 295 + assert_eq!(resolved.uri, DIR_PHOTOS_URI); 296 + assert_eq!(resolved.kind, EntryKind::Directory); 297 + } 298 + 299 + #[tokio::test] 300 + async fn resolve_bare_name_not_found() { 301 + let mock = MockTransport::new(); 302 + let tree = load_simple_tree(&mock).await; 303 + 304 + // Scans root's document children (notes.txt) — no match. 305 + mock.enqueue(doc_record_response(DOC_NOTES_URI, "notes.txt")); 306 + 307 + let mut client = mock_client(mock); 308 + let err = tree.resolve(&mut client, "missing.txt").await.unwrap_err(); 309 + assert!(matches!(err, Error::NotFound(_))); 310 + } 311 + 312 + #[tokio::test] 313 + async fn resolve_bare_name_no_root_searches_directories() { 314 + let mock = MockTransport::new(); 315 + // No root, but a directory named "Photos" exists. 316 + mock.enqueue(list_records_response( 317 + &[("photos", dummy_directory_with_entries("Photos", vec![]))], 318 + None, 319 + )); 320 + 321 + let mut client = mock_client(mock.clone()); 322 + let tree = DirectoryTree::load(&mut client).await.unwrap(); 323 + assert!(tree.root_uri.is_none()); 324 + 325 + let mut client = mock_client(mock); 326 + let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 327 + assert_eq!( 328 + resolved.uri, 329 + "at://did:plc:test/app.opake.cloud.directory/photos" 330 + ); 331 + assert_eq!(resolved.kind, EntryKind::Directory); 332 + } 333 + 334 + // -- count_descendants -- 335 + 336 + #[tokio::test] 337 + async fn count_descendants_flat() { 338 + let mock = MockTransport::new(); 339 + let tree = load_simple_tree(&mock).await; 340 + let (docs, dirs) = tree.count_descendants(ROOT_URI); 341 + assert_eq!(docs, 2); // notes.txt (root) + beach.jpg (via Photos) 342 + assert_eq!(dirs, 1); // Photos 343 + } 344 + 345 + #[tokio::test] 346 + async fn count_descendants_nested() { 347 + let mock = MockTransport::new(); 348 + let tree = load_nested_tree(&mock).await; 349 + let (docs, dirs) = tree.count_descendants(DIR_PHOTOS_URI); 350 + assert_eq!(docs, 2); // beach.jpg + sunset.jpg (via Vacation) 351 + assert_eq!(dirs, 1); // Vacation 352 + } 353 + 354 + #[tokio::test] 355 + async fn count_descendants_empty_directory() { 356 + let mock = MockTransport::new(); 357 + let tree = load_simple_tree(&mock).await; 358 + let (docs, dirs) = tree.count_descendants(DIR_PHOTOS_URI); 359 + assert_eq!(docs, 1); // beach.jpg 360 + assert_eq!(dirs, 0); 361 + } 362 + 363 + // -- collect_descendants -- 364 + 365 + #[tokio::test] 366 + async fn collect_descendants_post_order() { 367 + let mock = MockTransport::new(); 368 + let tree = load_nested_tree(&mock).await; 369 + let descendants = tree.collect_descendants(DIR_PHOTOS_URI); 370 + 371 + let uris: Vec<&str> = descendants.iter().map(|(uri, _)| uri.as_str()).collect(); 372 + 373 + // sunset.jpg must come before Vacation (post-order) 374 + let sunset_pos = uris.iter().position(|u| *u == DOC_SUNSET_URI).unwrap(); 375 + let vacation_pos = uris.iter().position(|u| *u == DIR_VACATION_URI).unwrap(); 376 + assert!(sunset_pos < vacation_pos, "children must precede parents"); 377 + 378 + assert_eq!(descendants.len(), 3); // sunset, vacation, beach 379 + } 380 + 381 + #[tokio::test] 382 + async fn collect_descendants_empty() { 383 + let mock = MockTransport::new(); 384 + mock.enqueue(list_records_response( 385 + &[ 386 + ( 387 + "self", 388 + dummy_directory_with_entries( 389 + "/", 390 + vec!["at://did:plc:test/app.opake.cloud.directory/empty".into()], 391 + ), 392 + ), 393 + ("empty", dummy_directory_with_entries("Empty", vec![])), 394 + ], 395 + None, 396 + )); 397 + 398 + let mut client = mock_client(mock); 399 + let tree = DirectoryTree::load(&mut client).await.unwrap(); 400 + 401 + let descendants = tree.collect_descendants("at://did:plc:test/app.opake.cloud.directory/empty"); 402 + assert!(descendants.is_empty()); 403 + }
+1 -1
crates/opake-core/src/documents/mod.rs
··· 23 23 encrypt_and_upload, encrypt_and_upload_keyring, KeyringUploadParams, UploadParams, 24 24 }; 25 25 26 - const DOCUMENT_COLLECTION: &str = "app.opake.cloud.document"; 26 + pub const DOCUMENT_COLLECTION: &str = "app.opake.cloud.document"; 27 27 28 28 #[cfg(test)] 29 29 pub(crate) mod tests {
+11 -1
docs/ARCHITECTURE.md
··· 75 75 auth.rs login(), refresh_session() 76 76 blobs.rs upload_blob(), get_blob() 77 77 repo.rs create_record(), put_record(), get_record(), list_records(), delete_record() 78 + directories/ 79 + mod.rs Re-exports, collection constants, shared test fixtures 80 + create.rs create_directory() 81 + delete.rs delete_directory() — single empty directory 82 + entries.rs add_entry(), remove_entry() — fetch-modify-put on parent 83 + get_or_create_root.rs Root singleton (rkey "self") management 84 + list.rs list_directories() 85 + tree.rs DirectoryTree — in-memory snapshot for path resolution 86 + remove.rs remove() — path-aware deletion (recursive, with parent cleanup) 78 87 documents/ 79 88 mod.rs Re-exports, shared test fixtures 80 89 upload.rs encrypt_and_upload() ··· 110 119 upload.rs File → encrypt → upload (direct or --keyring) 111 120 download.rs Download + decrypt (direct, keyring, or --grant) 112 121 ls.rs List documents 113 - rm.rs Delete with confirmation prompt 122 + mkdir.rs Create directory 123 + rm.rs Path-aware delete (documents, directories, recursive) 114 124 resolve.rs Identity resolution display 115 125 share.rs Grant creation 116 126 revoke.rs Grant deletion
+1
docs/FLOWS.md
··· 6 6 |------|-------| 7 7 | [flows/authentication.md](flows/authentication.md) | Login, token refresh | 8 8 | [flows/documents.md](flows/documents.md) | Upload, download, list, delete | 9 + | [flows/directories.md](flows/directories.md) | Create, delete, recursive delete, path resolution | 9 10 | [flows/sharing.md](flows/sharing.md) | Resolve, share, revoke | 10 11 | [flows/crypto.md](flows/crypto.md) | Key wrapping, content encryption primitives | 11 12 | [flows/keyrings.md](flows/keyrings.md) | Create, list, add/remove member, keyring upload/download |
+1
docs/flows/README.md
··· 6 6 |------|-------| 7 7 | [authentication.md](authentication.md) | Login, token refresh | 8 8 | [documents.md](documents.md) | Upload, download, list, delete | 9 + | [directories.md](directories.md) | Create, delete, recursive delete, path resolution | 9 10 | [sharing.md](sharing.md) | Resolve, share, revoke | 10 11 | [crypto.md](crypto.md) | Key wrapping, content encryption primitives | 11 12 | [keyrings.md](keyrings.md) | Create, list, add/remove member, keyring upload/download |
+133
docs/flows/directories.md
··· 1 + # Directory Operations 2 + 3 + ## Create Directory 4 + 5 + Creates a directory record and registers it as a child of the root. 6 + 7 + ```mermaid 8 + sequenceDiagram 9 + participant User 10 + participant CLI 11 + participant PDS 12 + 13 + User->>CLI: opake mkdir Photos 14 + 15 + CLI->>PDS: com.atproto.repo.getRecord (directory/self) 16 + alt Root exists 17 + PDS-->>CLI: root directory record 18 + else Root not found (404) 19 + CLI->>PDS: com.atproto.repo.putRecord (directory/self, name="/") 20 + PDS-->>CLI: { uri, cid } 21 + end 22 + 23 + CLI->>PDS: com.atproto.repo.createRecord (directory, name="Photos") 24 + PDS-->>CLI: { uri, cid } 25 + 26 + CLI->>PDS: com.atproto.repo.getRecord (root) 27 + CLI->>CLI: Append new directory URI to entries 28 + CLI->>PDS: com.atproto.repo.putRecord (root with updated entries) 29 + PDS-->>CLI: { uri, cid } 30 + 31 + CLI->>User: Photos → at://did/.../directory/<tid> 32 + ``` 33 + 34 + Directories are children-on-parent: the parent's `entries` array holds the AT-URIs of its children. The root directory is a singleton at rkey "self". 35 + 36 + ## Delete (Non-Recursive) 37 + 38 + Deletes an empty directory. Refuses if the directory has entries. 39 + 40 + ```mermaid 41 + sequenceDiagram 42 + participant User 43 + participant CLI 44 + participant PDS 45 + 46 + User->>CLI: opake rm Photos 47 + 48 + Note over CLI: Fast path: try document resolution first 49 + CLI->>PDS: listRecords (document collection) 50 + PDS-->>CLI: no match → NotFound 51 + 52 + Note over CLI: Fall back to tree load (directories only) 53 + CLI->>PDS: listRecords (directory collection, paginated) 54 + PDS-->>CLI: all directories (includes root) 55 + 56 + CLI->>CLI: tree.resolve("Photos") → directory in memory 57 + CLI->>CLI: count_descendants → 0 docs, 0 dirs 58 + 59 + CLI->>User: delete Photos/? [y/N] 60 + User-->>CLI: y 61 + 62 + CLI->>PDS: com.atproto.repo.deleteRecord (directory) 63 + PDS-->>CLI: 200 OK 64 + 65 + CLI->>PDS: getRecord (root) → remove_entry → putRecord (root) 66 + PDS-->>CLI: 200 OK 67 + 68 + CLI->>User: deleted at://did/.../directory/<rkey> 69 + ``` 70 + 71 + ## Delete (Recursive) 72 + 73 + Deletes a directory and all its contents in post-order (children before parents). 74 + 75 + ```mermaid 76 + sequenceDiagram 77 + participant User 78 + participant CLI 79 + participant PDS 80 + 81 + User->>CLI: opake rm -r Photos 82 + 83 + Note over CLI: Path contains no / → try document resolution 84 + CLI->>PDS: listRecords (document collection) 85 + PDS-->>CLI: no match → NotFound 86 + 87 + Note over CLI: Fall back to tree load (directories only) 88 + CLI->>PDS: listRecords (directory collection, paginated) 89 + PDS-->>CLI: all directories (includes root) 90 + 91 + CLI->>CLI: tree.resolve("Photos") → directory in memory 92 + CLI->>CLI: count_descendants → 2 docs, 1 subdir (from entry URIs) 93 + CLI->>User: delete Photos/? (2 documents, 1 subdirectories) [y/N] 94 + User-->>CLI: y 95 + 96 + CLI->>CLI: collect_descendants (post-order, from entry URIs) 97 + Note over CLI: Children deleted before parents<br/>No getRecord needed — URIs known from directory entries 98 + 99 + loop Each descendant (post-order) 100 + CLI->>PDS: com.atproto.repo.deleteRecord 101 + PDS-->>CLI: 200 OK 102 + end 103 + 104 + CLI->>PDS: com.atproto.repo.deleteRecord (Photos) 105 + PDS-->>CLI: 200 OK 106 + 107 + CLI->>PDS: getRecord (root) → remove_entry → putRecord (root) 108 + PDS-->>CLI: 200 OK 109 + 110 + CLI->>User: deleted at://... (2 documents, 2 directories) 111 + ``` 112 + 113 + Post-order deletion means leaf documents are removed first, then empty subdirectories, then the target directory itself. The parent's entry list is only updated once (for the target directory — descendant directories are deleted wholesale without updating their parents' entries, since the parents are also being deleted). 114 + 115 + ## Path Resolution 116 + 117 + The `DirectoryTree` resolves user-provided references to AT-URIs. Three input forms are supported: 118 + 119 + | Input | Strategy | API cost | 120 + |-------|----------|----------| 121 + | `at://did/.../document/rkey` | Passthrough | 0 calls | 122 + | `beach.jpg` (bare name) | `documents::resolve_uri` (fast path) | 1 paginated call | 123 + | `Photos` (bare name, no document match) | Tree load + in-memory search | 1 paginated + 0 calls | 124 + | `Photos/beach.jpg` | Tree load + walk + getRecord per doc child | 1 paginated + N getRecord | 125 + | `Photos/Vacation/sunset.jpg` | Tree load + walk + getRecord per doc child | 1 paginated + N getRecord | 126 + 127 + Bare names search only root's direct children (matching filesystem semantics). Use paths for nested items. 128 + 129 + The tree is built from a single paginated `listRecords` call (directories only). Directory segments are walked in memory. When the final segment targets a document, each document child URI in the parent directory is fetched individually via `getRecord` to match by name. This avoids loading the entire document collection — only the children of the relevant directory are fetched. 130 + 131 + For recursive deletion (`rm -r`), descendant counts and URIs are determined entirely from directory entry arrays — document names aren't needed, so no `getRecord` calls are made for counting or collecting. 132 + 133 + Future optimization: a local URI → name cache (#155) would eliminate repeated `getRecord` calls for the same directory's children.
+11 -4
docs/flows/documents.md
··· 127 127 128 128 ## Delete 129 129 130 - Deletes a document record. The blob becomes orphaned and is eventually garbage-collected by the PDS. 130 + Deletes a document record. The blob becomes orphaned and is eventually garbage-collected by the PDS. If the document is tracked in a directory, the parent's entry list is updated. 131 + 132 + For path-based deletion (`Photos/beach.jpg`), recursive directory deletion, and directory-related flows, see [directories.md](directories.md). 131 133 132 134 ```mermaid 133 135 sequenceDiagram ··· 137 139 138 140 User->>CLI: opake rm photo.jpg 139 141 140 - CLI->>CLI: Resolve filename → AT-URI 141 - CLI->>User: Delete photo.jpg? [y/N] 142 + Note over CLI: Bare name → fast path (document-only resolution) 143 + CLI->>PDS: listRecords (document collection, paginated) 144 + PDS-->>CLI: match found → AT-URI 145 + 146 + CLI->>User: delete photo.jpg? [y/N] 142 147 User-->>CLI: y 143 148 144 149 CLI->>PDS: com.atproto.repo.deleteRecord (collection, rkey) 145 150 PDS-->>CLI: 200 OK 146 151 147 - CLI->>User: Deleted 152 + CLI->>User: deleted at://did/.../document/<rkey> 148 153 ``` 154 + 155 + The fast path resolves bare document names with a single paginated `listRecords` call, the same cost as the pre-directory implementation. AT-URIs skip resolution entirely. Only path references (`dir/file`) and directory targets trigger a full tree load — see [directories.md](directories.md#path-resolution) for details.