···11+use jacquard_common::types::blob::BlobRef;
22+use jacquard_common::IntoStatic;
33+use std::collections::HashMap;
44+55+use crate::place_wisp::fs::{Directory, EntryNode};
66+77+/// Extract blob information from a directory tree
88+/// Returns a map of file paths to their blob refs and CIDs
99+///
1010+/// This mirrors the TypeScript implementation in src/lib/wisp-utils.ts lines 275-302
1111+pub fn extract_blob_map(
1212+ directory: &Directory,
1313+) -> HashMap<String, (BlobRef<'static>, String)> {
1414+ extract_blob_map_recursive(directory, String::new())
1515+}
1616+1717+fn extract_blob_map_recursive(
1818+ directory: &Directory,
1919+ current_path: String,
2020+) -> HashMap<String, (BlobRef<'static>, String)> {
2121+ let mut blob_map = HashMap::new();
2222+2323+ for entry in &directory.entries {
2424+ let full_path = if current_path.is_empty() {
2525+ entry.name.to_string()
2626+ } else {
2727+ format!("{}/{}", current_path, entry.name)
2828+ };
2929+3030+ match &entry.node {
3131+ EntryNode::File(file_node) => {
3232+ // Extract CID from blob ref
3333+ // BlobRef is an enum with Blob variant, which has a ref field (CidLink)
3434+ let blob_ref = &file_node.blob;
3535+ let cid_string = blob_ref.blob().r#ref.to_string();
3636+3737+ // Store both normalized and full paths
3838+ // Normalize by removing base folder prefix (e.g., "cobblemon/index.html" -> "index.html")
3939+ let normalized_path = normalize_path(&full_path);
4040+4141+ blob_map.insert(
4242+ normalized_path.clone(),
4343+ (blob_ref.clone().into_static(), cid_string.clone())
4444+ );
4545+4646+ // Also store the full path for matching
4747+ if normalized_path != full_path {
4848+ blob_map.insert(
4949+ full_path,
5050+ (blob_ref.clone().into_static(), cid_string)
5151+ );
5252+ }
5353+ }
5454+ EntryNode::Directory(subdir) => {
5555+ let sub_map = extract_blob_map_recursive(subdir, full_path);
5656+ blob_map.extend(sub_map);
5757+ }
5858+ EntryNode::Unknown(_) => {
5959+ // Skip unknown node types
6060+ }
6161+ }
6262+ }
6363+6464+ blob_map
6565+}
6666+6767+/// Normalize file path by removing base folder prefix
6868+/// Example: "cobblemon/index.html" -> "index.html"
6969+///
7070+/// Mirrors TypeScript implementation at src/routes/wisp.ts line 291
7171+pub fn normalize_path(path: &str) -> String {
7272+ // Remove base folder prefix (everything before first /)
7373+ if let Some(idx) = path.find('/') {
7474+ path[idx + 1..].to_string()
7575+ } else {
7676+ path.to_string()
7777+ }
7878+}
7979+8080+#[cfg(test)]
8181+mod tests {
8282+ use super::*;
8383+8484+ #[test]
8585+ fn test_normalize_path() {
8686+ assert_eq!(normalize_path("index.html"), "index.html");
8787+ assert_eq!(normalize_path("cobblemon/index.html"), "index.html");
8888+ assert_eq!(normalize_path("folder/subfolder/file.txt"), "subfolder/file.txt");
8989+ assert_eq!(normalize_path("a/b/c/d.txt"), "b/c/d.txt");
9090+ }
9191+}
9292+
+66
cli/src/cid.rs
···11+use jacquard_common::types::cid::IpldCid;
22+use sha2::{Digest, Sha256};
33+44+/// Compute CID (Content Identifier) for blob content
55+/// Uses the same algorithm as AT Protocol: CIDv1 with raw codec (0x55) and SHA-256
66+///
77+/// CRITICAL: This must be called on BASE64-ENCODED GZIPPED content, not just gzipped content
88+///
99+/// Based on @atproto/common/src/ipld.ts sha256RawToCid implementation
1010+pub fn compute_cid(content: &[u8]) -> String {
1111+ // Use node crypto to compute sha256 hash (same as AT Protocol)
1212+ let hash = Sha256::digest(content);
1313+1414+ // Create multihash (code 0x12 = sha2-256)
1515+ let multihash = multihash::Multihash::wrap(0x12, &hash)
1616+ .expect("SHA-256 hash should always fit in multihash");
1717+1818+ // Create CIDv1 with raw codec (0x55)
1919+ let cid = IpldCid::new_v1(0x55, multihash);
2020+2121+ // Convert to base32 string representation
2222+ cid.to_string_of_base(multibase::Base::Base32Lower)
2323+ .unwrap_or_else(|_| cid.to_string())
2424+}
2525+2626+#[cfg(test)]
2727+mod tests {
2828+ use super::*;
2929+ use base64::Engine;
3030+3131+ #[test]
3232+ fn test_compute_cid() {
3333+ // Test with a simple string: "hello"
3434+ let content = b"hello";
3535+ let cid = compute_cid(content);
3636+3737+ // CID should start with 'baf' for raw codec base32
3838+ assert!(cid.starts_with("baf"));
3939+ }
4040+4141+ #[test]
4242+ fn test_compute_cid_base64_encoded() {
4343+ // Simulate the actual use case: gzipped then base64 encoded
4444+ use flate2::write::GzEncoder;
4545+ use flate2::Compression;
4646+ use std::io::Write;
4747+4848+ let original = b"hello world";
4949+5050+ // Gzip compress
5151+ let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
5252+ encoder.write_all(original).unwrap();
5353+ let gzipped = encoder.finish().unwrap();
5454+5555+ // Base64 encode the gzipped data
5656+ let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
5757+5858+ // Compute CID on the base64 bytes
5959+ let cid = compute_cid(&base64_bytes);
6060+6161+ // Should be a valid CID
6262+ assert!(cid.starts_with("baf"));
6363+ assert!(cid.len() > 10);
6464+ }
6565+}
6666+
+121-38
cli/src/main.rs
···11mod builder_types;
22mod place_wisp;
33+mod cid;
44+mod blob_map;
3546use clap::Parser;
57use jacquard::CowStr;
66-use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession};
88+use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession, AgentSession};
79use jacquard::oauth::client::OAuthClient;
810use jacquard::oauth::loopback::LoopbackConfig;
911use jacquard::prelude::IdentityResolver;
···1113use jacquard_common::types::blob::MimeType;
1214use miette::IntoDiagnostic;
1315use std::path::{Path, PathBuf};
1616+use std::collections::HashMap;
1417use flate2::Compression;
1518use flate2::write::GzEncoder;
1619use std::io::Write;
···107110108111 println!("Deploying site '{}'...", site_name);
109112110110- // Build directory tree
111111- let root_dir = build_directory(agent, &path).await?;
113113+ // Try to fetch existing manifest for incremental updates
114114+ let existing_blob_map: HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)> = {
115115+ use jacquard_common::types::string::AtUri;
116116+117117+ // Get the DID for this session
118118+ let session_info = agent.session_info().await;
119119+ if let Some((did, _)) = session_info {
120120+ // Construct the AT URI for the record
121121+ let uri_string = format!("at://{}/place.wisp.fs/{}", did, site_name);
122122+ if let Ok(uri) = AtUri::new(&uri_string) {
123123+ match agent.get_record::<Fs>(&uri).await {
124124+ Ok(response) => {
125125+ match response.into_output() {
126126+ Ok(record_output) => {
127127+ let existing_manifest = record_output.value;
128128+ let blob_map = blob_map::extract_blob_map(&existing_manifest.root);
129129+ println!("Found existing manifest with {} files, checking for changes...", blob_map.len());
130130+ blob_map
131131+ }
132132+ Err(_) => {
133133+ println!("No existing manifest found, uploading all files...");
134134+ HashMap::new()
135135+ }
136136+ }
137137+ }
138138+ Err(_) => {
139139+ // Record doesn't exist yet - this is a new site
140140+ println!("No existing manifest found, uploading all files...");
141141+ HashMap::new()
142142+ }
143143+ }
144144+ } else {
145145+ println!("No existing manifest found (invalid URI), uploading all files...");
146146+ HashMap::new()
147147+ }
148148+ } else {
149149+ println!("No existing manifest found (could not get DID), uploading all files...");
150150+ HashMap::new()
151151+ }
152152+ };
112153113113- // Count total files
114114- let file_count = count_files(&root_dir);
154154+ // Build directory tree
155155+ let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map).await?;
156156+ let uploaded_count = total_files - reused_count;
115157116158 // Create the Fs record
117159 let fs_record = Fs::new()
118160 .site(CowStr::from(site_name.clone()))
119161 .root(root_dir)
120120- .file_count(file_count as i64)
162162+ .file_count(total_files as i64)
121163 .created_at(Datetime::now())
122164 .build();
123165···132174 .and_then(|s| s.split('/').next())
133175 .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?;
134176135135- println!("Deployed site '{}': {}", site_name, output.uri);
136136- println!("Available at: https://sites.wisp.place/{}/{}", did, site_name);
177177+ println!("\n✓ Deployed site '{}': {}", site_name, output.uri);
178178+ println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count);
179179+ println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name);
137180138181 Ok(())
139182}
···142185fn build_directory<'a>(
143186 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>,
144187 dir_path: &'a Path,
145145-) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>>
188188+ existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
189189+) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>>
146190{
147191 Box::pin(async move {
148192 // Collect all directory entries first
···177221 }
178222179223 // Process files concurrently with a limit of 5
180180- let file_entries: Vec<Entry> = stream::iter(file_tasks)
224224+ let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks)
181225 .map(|(name, path)| async move {
182182- let file_node = process_file(agent, &path).await?;
183183- Ok::<_, miette::Report>(Entry::new()
226226+ let (file_node, reused) = process_file(agent, &path, &name, existing_blobs).await?;
227227+ let entry = Entry::new()
184228 .name(CowStr::from(name))
185229 .node(EntryNode::File(Box::new(file_node)))
186186- .build())
230230+ .build();
231231+ Ok::<_, miette::Report>((entry, reused))
187232 })
188233 .buffer_unordered(5)
189234 .collect::<Vec<_>>()
190235 .await
191236 .into_iter()
192237 .collect::<miette::Result<Vec<_>>>()?;
238238+239239+ let mut file_entries = Vec::new();
240240+ let mut reused_count = 0;
241241+ let mut total_files = 0;
242242+243243+ for (entry, reused) in file_results {
244244+ file_entries.push(entry);
245245+ total_files += 1;
246246+ if reused {
247247+ reused_count += 1;
248248+ }
249249+ }
193250194251 // Process directories recursively (sequentially to avoid too much nesting)
195252 let mut dir_entries = Vec::new();
196253 for (name, path) in dir_tasks {
197197- let subdir = build_directory(agent, &path).await?;
254254+ let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs).await?;
198255 dir_entries.push(Entry::new()
199256 .name(CowStr::from(name))
200257 .node(EntryNode::Directory(Box::new(subdir)))
201258 .build());
259259+ total_files += sub_total;
260260+ reused_count += sub_reused;
202261 }
203262204263 // Combine file and directory entries
205264 let mut entries = file_entries;
206265 entries.extend(dir_entries);
207266208208- Ok(Directory::new()
267267+ let directory = Directory::new()
209268 .r#type(CowStr::from("directory"))
210269 .entries(entries)
211211- .build())
270270+ .build();
271271+272272+ Ok((directory, total_files, reused_count))
212273 })
213274}
214275215215-/// Process a single file: gzip -> base64 -> upload blob
276276+/// Process a single file: gzip -> base64 -> upload blob (or reuse existing)
277277+/// Returns (File, reused: bool)
216278async fn process_file(
217279 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
218280 file_path: &Path,
219219-) -> miette::Result<File<'static>>
281281+ file_name: &str,
282282+ existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
283283+) -> miette::Result<(File<'static>, bool)>
220284{
221285 // Read file
222286 let file_data = std::fs::read(file_path).into_diagnostic()?;
···234298 // Base64 encode the gzipped data
235299 let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
236300237237- // Upload blob as octet-stream
301301+ // Compute CID for this file (CRITICAL: on base64-encoded gzipped content)
302302+ let file_cid = cid::compute_cid(&base64_bytes);
303303+304304+ // Normalize the file path for comparison
305305+ let normalized_path = blob_map::normalize_path(file_name);
306306+307307+ // Check if we have an existing blob with the same CID
308308+ let existing_blob = existing_blobs.get(&normalized_path)
309309+ .or_else(|| existing_blobs.get(file_name));
310310+311311+ if let Some((existing_blob_ref, existing_cid)) = existing_blob {
312312+ if existing_cid == &file_cid {
313313+ // CIDs match - reuse existing blob
314314+ println!(" ✓ Reusing blob for {} (CID: {})", file_name, file_cid);
315315+ return Ok((
316316+ File::new()
317317+ .r#type(CowStr::from("file"))
318318+ .blob(existing_blob_ref.clone())
319319+ .encoding(CowStr::from("gzip"))
320320+ .mime_type(CowStr::from(original_mime))
321321+ .base64(true)
322322+ .build(),
323323+ true
324324+ ));
325325+ }
326326+ }
327327+328328+ // File is new or changed - upload it
329329+ println!(" ↑ Uploading {} ({} bytes, CID: {})", file_name, base64_bytes.len(), file_cid);
238330 let blob = agent.upload_blob(
239331 base64_bytes,
240332 MimeType::new_static("application/octet-stream"),
241333 ).await?;
242334243243- Ok(File::new()
244244- .r#type(CowStr::from("file"))
245245- .blob(blob)
246246- .encoding(CowStr::from("gzip"))
247247- .mime_type(CowStr::from(original_mime))
248248- .base64(true)
249249- .build())
335335+ Ok((
336336+ File::new()
337337+ .r#type(CowStr::from("file"))
338338+ .blob(blob)
339339+ .encoding(CowStr::from("gzip"))
340340+ .mime_type(CowStr::from(original_mime))
341341+ .base64(true)
342342+ .build(),
343343+ false
344344+ ))
250345}
251346252252-/// Count total files in a directory tree
253253-fn count_files(dir: &Directory) -> usize {
254254- let mut count = 0;
255255- for entry in &dir.entries {
256256- match &entry.node {
257257- EntryNode::File(_) => count += 1,
258258- EntryNode::Directory(subdir) => count += count_files(subdir),
259259- _ => {} // Unknown variants
260260- }
261261- }
262262- count
263263-}