this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add filename resolution for download and rm commands

Both commands now accept a filename in addition to a full AT URI.
The resolver lists documents and matches by name, erroring on zero
or ambiguous matches with actionable messages.

Closes #41, closes #42

+138 -8
+2
CHANGELOG.md
··· 7 7 ## [Unreleased] 8 8 9 9 ### Added 10 + - Add filename resolution for rm command (#42) 11 + - Add filename resolution for download command (#41) 10 12 - Add MockTransport test infrastructure with FIFO response queue 11 13 - Add download command tests with full crypto roundtrip verification 12 14 - Update login command to read password from stdin (#23)
+5 -3
crates/opake-cli/src/commands/download.rs
··· 12 12 #[derive(Args)] 13 13 /// Download and decrypt a file 14 14 pub struct DownloadCommand { 15 - /// AT URI of the document record 16 - uri: String, 15 + /// AT URI or filename of the document 16 + reference: String, 17 17 18 18 /// Output path (defaults to the original filename) 19 19 #[arg(short, long)] ··· 45 45 let id = identity::load_identity().context("run `opake login` first")?; 46 46 let private_key = id.private_key_bytes()?; 47 47 48 + let uri = documents::resolve_uri(&client, &self.reference).await?; 49 + 48 50 let (name, plaintext) = 49 - documents::download_and_decrypt(&client, &id.did, &private_key, &self.uri).await?; 51 + documents::download_and_decrypt(&client, &id.did, &private_key, &uri).await?; 50 52 51 53 let output_path = resolve_output_path(self.output, &name); 52 54 write_output(&output_path, &plaintext)?;
+6 -5
crates/opake-cli/src/commands/rm.rs
··· 8 8 #[derive(Args)] 9 9 /// Delete a document 10 10 pub struct RmCommand { 11 - /// AT URI of the document record 12 - uri: String, 11 + /// AT URI or filename of the document 12 + reference: String, 13 13 14 14 /// Skip confirmation prompt 15 15 #[arg(short, long)] ··· 19 19 impl Execute for RmCommand { 20 20 async fn execute(self) -> Result<()> { 21 21 let client = session::load_client()?; 22 + let uri = documents::resolve_uri(&client, &self.reference).await?; 22 23 23 24 if !self.yes { 24 - eprint!("delete {}? [y/N] ", self.uri); 25 + eprint!("delete {}? [y/N] ", uri); 25 26 let mut answer = String::new(); 26 27 std::io::stdin() 27 28 .read_line(&mut answer) ··· 32 33 } 33 34 } 34 35 35 - documents::delete_document(&client, &self.uri).await?; 36 - println!("deleted {}", self.uri); 36 + documents::delete_document(&client, &uri).await?; 37 + println!("deleted {}", uri); 37 38 38 39 Ok(()) 39 40 }
+2
crates/opake-core/src/documents/mod.rs
··· 8 8 mod delete; 9 9 mod download; 10 10 mod list; 11 + mod resolve; 11 12 mod upload; 12 13 13 14 pub use delete::delete_document; 14 15 pub use download::download_and_decrypt; 15 16 pub use list::{list_documents, DocumentEntry}; 17 + pub use resolve::resolve_uri; 16 18 pub use upload::{encrypt_and_upload, UploadParams}; 17 19 18 20 const DOCUMENT_COLLECTION: &str = "app.opake.cloud.document";
+116
crates/opake-core/src/documents/resolve.rs
··· 1 + use crate::client::{Transport, XrpcClient}; 2 + use crate::error::Error; 3 + 4 + use super::list::{list_documents, DocumentEntry}; 5 + 6 + /// Resolve a user-provided reference to a document's AT URI. 7 + /// 8 + /// If `reference` already looks like an `at://` URI, it's returned as-is. 9 + /// Otherwise it's treated as a filename: we list all documents and find the 10 + /// matching entry. Exactly one match is required — zero or multiple matches 11 + /// are errors. 12 + pub async fn resolve_uri( 13 + client: &XrpcClient<impl Transport>, 14 + reference: &str, 15 + ) -> Result<String, Error> { 16 + if reference.starts_with("at://") { 17 + return Ok(reference.to_string()); 18 + } 19 + 20 + let entries = list_documents(client).await?; 21 + let matches: Vec<&DocumentEntry> = entries.iter().filter(|e| e.name == reference).collect(); 22 + 23 + match matches.len() { 24 + 0 => Err(Error::NotFound(format!( 25 + "no document named {:?} — use `opake ls` to see your documents", 26 + reference 27 + ))), 28 + 1 => Ok(matches[0].uri.clone()), 29 + n => { 30 + let uris: Vec<String> = matches.iter().map(|e| e.uri.clone()).collect(); 31 + Err(Error::AmbiguousName { 32 + name: reference.to_string(), 33 + count: n, 34 + uris, 35 + }) 36 + } 37 + } 38 + } 39 + 40 + #[cfg(test)] 41 + mod tests { 42 + use super::*; 43 + use crate::test_utils::MockTransport; 44 + 45 + use super::super::tests::{dummy_document, list_records_response, mock_client}; 46 + 47 + #[tokio::test] 48 + async fn passthrough_at_uri() { 49 + let mock = MockTransport::new(); 50 + let client = mock_client(mock); 51 + 52 + let uri = resolve_uri(&client, "at://did:plc:test/app.opake.cloud.document/abc") 53 + .await 54 + .unwrap(); 55 + assert_eq!(uri, "at://did:plc:test/app.opake.cloud.document/abc"); 56 + } 57 + 58 + #[tokio::test] 59 + async fn resolves_unique_filename() { 60 + let mock = MockTransport::new(); 61 + mock.enqueue(list_records_response( 62 + &[ 63 + ("a1", dummy_document("notes.txt", 100, vec![])), 64 + ("a2", dummy_document("photo.jpg", 200, vec![])), 65 + ], 66 + None, 67 + )); 68 + 69 + let client = mock_client(mock); 70 + let uri = resolve_uri(&client, "photo.jpg").await.unwrap(); 71 + assert!(uri.contains("a2")); 72 + } 73 + 74 + #[tokio::test] 75 + async fn no_match_returns_not_found() { 76 + let mock = MockTransport::new(); 77 + mock.enqueue(list_records_response( 78 + &[("a1", dummy_document("notes.txt", 100, vec![]))], 79 + None, 80 + )); 81 + 82 + let client = mock_client(mock); 83 + let err = resolve_uri(&client, "missing.pdf").await.unwrap_err(); 84 + let msg = err.to_string(); 85 + assert!(msg.contains("no document named"), "got: {msg}"); 86 + assert!(msg.contains("opake ls"), "should suggest ls, got: {msg}"); 87 + } 88 + 89 + #[tokio::test] 90 + async fn ambiguous_name_returns_error() { 91 + let mock = MockTransport::new(); 92 + mock.enqueue(list_records_response( 93 + &[ 94 + ("a1", dummy_document("report.pdf", 100, vec![])), 95 + ("a2", dummy_document("report.pdf", 200, vec![])), 96 + ], 97 + None, 98 + )); 99 + 100 + let client = mock_client(mock); 101 + let err = resolve_uri(&client, "report.pdf").await.unwrap_err(); 102 + let msg = err.to_string(); 103 + assert!(msg.contains("report.pdf"), "got: {msg}"); 104 + assert!(msg.contains("2"), "should mention count, got: {msg}"); 105 + } 106 + 107 + #[tokio::test] 108 + async fn empty_collection_returns_not_found() { 109 + let mock = MockTransport::new(); 110 + mock.enqueue(list_records_response(&[], None)); 111 + 112 + let client = mock_client(mock); 113 + let err = resolve_uri(&client, "anything.txt").await.unwrap_err(); 114 + assert!(err.to_string().contains("no document named")); 115 + } 116 + }
+7
crates/opake-core/src/error.rs
··· 20 20 #[error("record not found: {0}")] 21 21 NotFound(String), 22 22 23 + #[error("{count} documents named {name:?} — specify an AT URI instead: {}", uris.join(", "))] 24 + AmbiguousName { 25 + name: String, 26 + count: usize, 27 + uris: Vec<String>, 28 + }, 29 + 23 30 #[error("invalid record: {0}")] 24 31 InvalidRecord(String), 25 32