Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

add splitting to cli

Changed files
+2696 -177
cli
+42 -51
cli/Cargo.lock
··· 175 176 [[package]] 177 name = "axum" 178 - version = "0.7.9" 179 source = "registry+https://github.com/rust-lang/crates.io-index" 180 - checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" 181 dependencies = [ 182 - "async-trait", 183 "axum-core", 184 "bytes", 185 "futures-util", 186 "http", 187 "http-body", ··· 194 "mime", 195 "percent-encoding", 196 "pin-project-lite", 197 - "rustversion", 198 - "serde", 199 "serde_json", 200 "serde_path_to_error", 201 "serde_urlencoded", 202 "sync_wrapper", 203 "tokio", 204 - "tower 0.5.2", 205 "tower-layer", 206 "tower-service", 207 "tracing", ··· 209 210 [[package]] 211 name = "axum-core" 212 - version = "0.4.5" 213 source = "registry+https://github.com/rust-lang/crates.io-index" 214 - checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" 215 dependencies = [ 216 - "async-trait", 217 "bytes", 218 - "futures-util", 219 "http", 220 "http-body", 221 "http-body-util", 222 "mime", 223 "pin-project-lite", 224 - "rustversion", 225 "sync_wrapper", 226 "tower-layer", 227 "tower-service", ··· 1792 "miette", 1793 "multibase", 1794 "multihash", 1795 - "n0-future", 1796 "ouroboros", 1797 "p256", 1798 "rand 0.9.2", ··· 2146 2147 [[package]] 2148 name = "matchit" 2149 - version = "0.7.3" 2150 source = "registry+https://github.com/rust-lang/crates.io-index" 2151 - checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" 2152 2153 [[package]] 2154 name = "memchr" ··· 2289 version = "0.1.3" 2290 source = "registry+https://github.com/rust-lang/crates.io-index" 2291 checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794" 2292 dependencies = [ 2293 "cfg_aliases", 2294 "derive_more", ··· 2991 "tokio", 2992 "tokio-rustls", 2993 "tokio-util", 2994 - "tower 0.5.2", 2995 - "tower-http 0.6.6", 2996 "tower-service", 2997 "url", 2998 "wasm-bindgen", ··· 3973 3974 [[package]] 3975 name = "tower" 3976 - version = "0.4.13" 3977 - source = "registry+https://github.com/rust-lang/crates.io-index" 3978 - checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" 3979 - dependencies = [ 3980 - "tower-layer", 3981 - "tower-service", 3982 - "tracing", 3983 - ] 3984 - 3985 - [[package]] 3986 - name = "tower" 3987 version = "0.5.2" 3988 source = "registry+https://github.com/rust-lang/crates.io-index" 3989 checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" ··· 4000 4001 [[package]] 4002 name = "tower-http" 4003 - version = "0.5.2" 4004 source = "registry+https://github.com/rust-lang/crates.io-index" 4005 - checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" 4006 dependencies = [ 4007 "async-compression", 4008 "bitflags", ··· 4014 "http-body-util", 4015 "http-range-header", 4016 "httpdate", 4017 "mime", 4018 "mime_guess", 4019 "percent-encoding", 4020 "pin-project-lite", 4021 "tokio", 4022 "tokio-util", 4023 "tower-layer", 4024 "tower-service", 4025 "tracing", 4026 - ] 4027 - 4028 - [[package]] 4029 - name = "tower-http" 4030 - version = "0.6.6" 4031 - source = "registry+https://github.com/rust-lang/crates.io-index" 4032 - checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" 4033 - dependencies = [ 4034 - "bitflags", 4035 - "bytes", 4036 - "futures-util", 4037 - "http", 4038 - "http-body", 4039 - "iri-string", 4040 - "pin-project-lite", 4041 - "tower 0.5.2", 4042 - "tower-layer", 4043 - "tower-service", 4044 ] 4045 4046 [[package]] ··· 4913 4914 [[package]] 4915 name = "wisp-cli" 4916 - version = "0.2.0" 4917 dependencies = [ 4918 "axum", 4919 "base64 0.22.1", ··· 4933 "mime_guess", 4934 "multibase", 4935 "multihash", 4936 - "n0-future", 4937 "reqwest", 4938 "rustversion", 4939 "serde", ··· 4941 "sha2", 4942 "shellexpand", 4943 "tokio", 4944 - "tower 0.4.13", 4945 - "tower-http 0.5.2", 4946 "url", 4947 "walkdir", 4948 ]
··· 175 176 [[package]] 177 name = "axum" 178 + version = "0.8.7" 179 source = "registry+https://github.com/rust-lang/crates.io-index" 180 + checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" 181 dependencies = [ 182 "axum-core", 183 "bytes", 184 + "form_urlencoded", 185 "futures-util", 186 "http", 187 "http-body", ··· 194 "mime", 195 "percent-encoding", 196 "pin-project-lite", 197 + "serde_core", 198 "serde_json", 199 "serde_path_to_error", 200 "serde_urlencoded", 201 "sync_wrapper", 202 "tokio", 203 + "tower", 204 "tower-layer", 205 "tower-service", 206 "tracing", ··· 208 209 [[package]] 210 name = "axum-core" 211 + version = "0.5.5" 212 source = "registry+https://github.com/rust-lang/crates.io-index" 213 + checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" 214 dependencies = [ 215 "bytes", 216 + "futures-core", 217 "http", 218 "http-body", 219 "http-body-util", 220 "mime", 221 "pin-project-lite", 222 "sync_wrapper", 223 "tower-layer", 224 "tower-service", ··· 1789 "miette", 1790 "multibase", 1791 "multihash", 1792 + "n0-future 0.1.3", 1793 "ouroboros", 1794 "p256", 1795 "rand 0.9.2", ··· 2143 2144 [[package]] 2145 name = "matchit" 2146 + version = "0.8.4" 2147 source = "registry+https://github.com/rust-lang/crates.io-index" 2148 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 2149 2150 [[package]] 2151 name = "memchr" ··· 2286 version = "0.1.3" 2287 source = "registry+https://github.com/rust-lang/crates.io-index" 2288 checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794" 2289 + dependencies = [ 2290 + "cfg_aliases", 2291 + "derive_more", 2292 + "futures-buffered", 2293 + "futures-lite", 2294 + "futures-util", 2295 + "js-sys", 2296 + "pin-project", 2297 + "send_wrapper", 2298 + "tokio", 2299 + "tokio-util", 2300 + "wasm-bindgen", 2301 + "wasm-bindgen-futures", 2302 + "web-time", 2303 + ] 2304 + 2305 + [[package]] 2306 + name = "n0-future" 2307 + version = "0.3.1" 2308 + source = "registry+https://github.com/rust-lang/crates.io-index" 2309 + checksum = "8c0709ac8235ce13b82bc4d180ee3c42364b90c1a8a628c3422d991d75a728b5" 2310 dependencies = [ 2311 "cfg_aliases", 2312 "derive_more", ··· 3009 "tokio", 3010 "tokio-rustls", 3011 "tokio-util", 3012 + "tower", 3013 + "tower-http", 3014 "tower-service", 3015 "url", 3016 "wasm-bindgen", ··· 3991 3992 [[package]] 3993 name = "tower" 3994 version = "0.5.2" 3995 source = "registry+https://github.com/rust-lang/crates.io-index" 3996 checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" ··· 4007 4008 [[package]] 4009 name = "tower-http" 4010 + version = "0.6.6" 4011 source = "registry+https://github.com/rust-lang/crates.io-index" 4012 + checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" 4013 dependencies = [ 4014 "async-compression", 4015 "bitflags", ··· 4021 "http-body-util", 4022 "http-range-header", 4023 "httpdate", 4024 + "iri-string", 4025 "mime", 4026 "mime_guess", 4027 "percent-encoding", 4028 "pin-project-lite", 4029 "tokio", 4030 "tokio-util", 4031 + "tower", 4032 "tower-layer", 4033 "tower-service", 4034 "tracing", 4035 ] 4036 4037 [[package]] ··· 4904 4905 [[package]] 4906 name = "wisp-cli" 4907 + version = "0.3.0" 4908 dependencies = [ 4909 "axum", 4910 "base64 0.22.1", ··· 4924 "mime_guess", 4925 "multibase", 4926 "multihash", 4927 + "n0-future 0.3.1", 4928 "reqwest", 4929 "rustversion", 4930 "serde", ··· 4932 "sha2", 4933 "shellexpand", 4934 "tokio", 4935 + "tower", 4936 + "tower-http", 4937 "url", 4938 "walkdir", 4939 ]
+5 -5
cli/Cargo.toml
··· 1 [package] 2 name = "wisp-cli" 3 - version = "0.2.0" 4 edition = "2024" 5 6 [features] ··· 33 multihash = "0.19.3" 34 multibase = "0.9" 35 sha2 = "0.10" 36 - axum = "0.7" 37 - tower-http = { version = "0.5", features = ["fs", "compression-gzip"] } 38 - tower = "0.4" 39 - n0-future = "0.1" 40 chrono = "0.4" 41 url = "2.5"
··· 1 [package] 2 name = "wisp-cli" 3 + version = "0.3.0" 4 edition = "2024" 5 6 [features] ··· 33 multihash = "0.19.3" 34 multibase = "0.9" 35 sha2 = "0.10" 36 + axum = "0.8.7" 37 + tower-http = { version = "0.6.6", features = ["fs", "compression-gzip"] } 38 + tower = "0.5.2" 39 + n0-future = "0.3.1" 40 chrono = "0.4" 41 url = "2.5"
-51
cli/lexicons/place/wisp/fs.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "place.wisp.fs", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "Virtual filesystem manifest for a Wisp site", 8 - "record": { 9 - "type": "object", 10 - "required": ["site", "root", "createdAt"], 11 - "properties": { 12 - "site": { "type": "string" }, 13 - "root": { "type": "ref", "ref": "#directory" }, 14 - "fileCount": { "type": "integer", "minimum": 0, "maximum": 1000 }, 15 - "createdAt": { "type": "string", "format": "datetime" } 16 - } 17 - } 18 - }, 19 - "file": { 20 - "type": "object", 21 - "required": ["type", "blob"], 22 - "properties": { 23 - "type": { "type": "string", "const": "file" }, 24 - "blob": { "type": "blob", "accept": ["*/*"], "maxSize": 1000000, "description": "Content blob ref" }, 25 - "encoding": { "type": "string", "enum": ["gzip"], "description": "Content encoding (e.g., gzip for compressed files)" }, 26 - "mimeType": { "type": "string", "description": "Original MIME type before compression" }, 27 - "base64": { "type": "boolean", "description": "True if blob content is base64-encoded (used to bypass PDS content sniffing)" } 28 - } 29 - }, 30 - "directory": { 31 - "type": "object", 32 - "required": ["type", "entries"], 33 - "properties": { 34 - "type": { "type": "string", "const": "directory" }, 35 - "entries": { 36 - "type": "array", 37 - "maxLength": 500, 38 - "items": { "type": "ref", "ref": "#entry" } 39 - } 40 - } 41 - }, 42 - "entry": { 43 - "type": "object", 44 - "required": ["name", "node"], 45 - "properties": { 46 - "name": { "type": "string", "maxLength": 255 }, 47 - "node": { "type": "union", "refs": ["#file", "#directory"] } 48 - } 49 - } 50 - } 51 - }
···
+5 -1
cli/src/blob_map.rs
··· 33 // BlobRef is an enum with Blob variant, which has a ref field (CidLink) 34 let blob_ref = &file_node.blob; 35 let cid_string = blob_ref.blob().r#ref.to_string(); 36 - 37 // Store with full path (mirrors TypeScript implementation) 38 blob_map.insert( 39 full_path, ··· 43 EntryNode::Directory(subdir) => { 44 let sub_map = extract_blob_map_recursive(subdir, full_path); 45 blob_map.extend(sub_map); 46 } 47 EntryNode::Unknown(_) => { 48 // Skip unknown node types
··· 33 // BlobRef is an enum with Blob variant, which has a ref field (CidLink) 34 let blob_ref = &file_node.blob; 35 let cid_string = blob_ref.blob().r#ref.to_string(); 36 + 37 // Store with full path (mirrors TypeScript implementation) 38 blob_map.insert( 39 full_path, ··· 43 EntryNode::Directory(subdir) => { 44 let sub_map = extract_blob_map_recursive(subdir, full_path); 45 blob_map.extend(sub_map); 46 + } 47 + EntryNode::Subfs(_) => { 48 + // Subfs nodes don't contain blobs directly - they reference other records 49 + // Skip them in blob map extraction 50 } 51 EntryNode::Unknown(_) => { 52 // Skip unknown node types
+9
cli/src/lib.rs
···
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // This file was automatically generated from Lexicon schemas. 4 + // Any manual changes will be overwritten on the next regeneration. 5 + 6 + pub mod builder_types; 7 + 8 + #[cfg(feature = "place_wisp")] 9 + pub mod place_wisp;
+195 -12
cli/src/main.rs
··· 6 mod download; 7 mod pull; 8 mod serve; 9 10 use clap::{Parser, Subcommand}; 11 use jacquard::CowStr; ··· 204 println!("Deploying site '{}'...", site_name); 205 206 // Try to fetch existing manifest for incremental updates 207 - let existing_blob_map: HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)> = { 208 use jacquard_common::types::string::AtUri; 209 - 210 // Get the DID for this session 211 let session_info = agent.session_info().await; 212 if let Some((did, _)) = session_info { ··· 218 match response.into_output() { 219 Ok(record_output) => { 220 let existing_manifest = record_output.value; 221 - let blob_map = blob_map::extract_blob_map(&existing_manifest.root); 222 - println!("Found existing manifest with {} files, checking for changes...", blob_map.len()); 223 - blob_map 224 } 225 Err(_) => { 226 println!("No existing manifest found, uploading all files..."); 227 - HashMap::new() 228 } 229 } 230 } 231 Err(_) => { 232 // Record doesn't exist yet - this is a new site 233 println!("No existing manifest found, uploading all files..."); 234 - HashMap::new() 235 } 236 } 237 } else { 238 println!("No existing manifest found (invalid URI), uploading all files..."); 239 - HashMap::new() 240 } 241 } else { 242 println!("No existing manifest found (could not get DID), uploading all files..."); 243 - HashMap::new() 244 } 245 }; 246 ··· 248 let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?; 249 let uploaded_count = total_files - reused_count; 250 251 - // Create the Fs record 252 let fs_record = Fs::new() 253 .site(CowStr::from(site_name.clone())) 254 - .root(root_dir) 255 - .file_count(total_files as i64) 256 .created_at(Datetime::now()) 257 .build(); 258 ··· 270 println!("\n✓ Deployed site '{}': {}", site_name, output.uri); 271 println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count); 272 println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name); 273 274 Ok(()) 275 } ··· 448 )) 449 } 450
··· 6 mod download; 7 mod pull; 8 mod serve; 9 + mod subfs_utils; 10 11 use clap::{Parser, Subcommand}; 12 use jacquard::CowStr; ··· 205 println!("Deploying site '{}'...", site_name); 206 207 // Try to fetch existing manifest for incremental updates 208 + let (existing_blob_map, old_subfs_uris): (HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, Vec<(String, String)>) = { 209 use jacquard_common::types::string::AtUri; 210 + 211 // Get the DID for this session 212 let session_info = agent.session_info().await; 213 if let Some((did, _)) = session_info { ··· 219 match response.into_output() { 220 Ok(record_output) => { 221 let existing_manifest = record_output.value; 222 + let mut blob_map = blob_map::extract_blob_map(&existing_manifest.root); 223 + println!("Found existing manifest with {} files in main record", blob_map.len()); 224 + 225 + // Extract subfs URIs from main record 226 + let subfs_uris = subfs_utils::extract_subfs_uris(&existing_manifest.root, String::new()); 227 + 228 + if !subfs_uris.is_empty() { 229 + println!("Found {} subfs records, fetching for blob reuse...", subfs_uris.len()); 230 + 231 + // Merge blob maps from all subfs records 232 + match subfs_utils::merge_subfs_blob_maps(agent, subfs_uris.clone(), &mut blob_map).await { 233 + Ok(merged_count) => { 234 + println!("Total blob map: {} files (main + {} from subfs)", blob_map.len(), merged_count); 235 + } 236 + Err(e) => { 237 + eprintln!("⚠️ Failed to merge some subfs blob maps: {}", e); 238 + } 239 + } 240 + 241 + (blob_map, subfs_uris) 242 + } else { 243 + (blob_map, Vec::new()) 244 + } 245 } 246 Err(_) => { 247 println!("No existing manifest found, uploading all files..."); 248 + (HashMap::new(), Vec::new()) 249 } 250 } 251 } 252 Err(_) => { 253 // Record doesn't exist yet - this is a new site 254 println!("No existing manifest found, uploading all files..."); 255 + (HashMap::new(), Vec::new()) 256 } 257 } 258 } else { 259 println!("No existing manifest found (invalid URI), uploading all files..."); 260 + (HashMap::new(), Vec::new()) 261 } 262 } else { 263 println!("No existing manifest found (could not get DID), uploading all files..."); 264 + (HashMap::new(), Vec::new()) 265 } 266 }; 267 ··· 269 let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?; 270 let uploaded_count = total_files - reused_count; 271 272 + // Check if we need to split into subfs records 273 + const MAX_MANIFEST_SIZE: usize = 140 * 1024; // 140KB (PDS limit is 150KB) 274 + const FILE_COUNT_THRESHOLD: usize = 250; // Start splitting at this many files 275 + const TARGET_FILE_COUNT: usize = 200; // Keep main manifest under this 276 + 277 + let mut working_directory = root_dir; 278 + let mut current_file_count = total_files; 279 + let mut new_subfs_uris: Vec<(String, String)> = Vec::new(); 280 + 281 + // Estimate initial manifest size 282 + let mut manifest_size = subfs_utils::estimate_directory_size(&working_directory); 283 + 284 + if total_files >= FILE_COUNT_THRESHOLD || manifest_size > MAX_MANIFEST_SIZE { 285 + println!("\n⚠️ Large site detected ({} files, {:.1}KB manifest), splitting into subfs records...", 286 + total_files, manifest_size as f64 / 1024.0); 287 + 288 + let mut attempts = 0; 289 + const MAX_SPLIT_ATTEMPTS: usize = 50; 290 + 291 + while (manifest_size > MAX_MANIFEST_SIZE || current_file_count > TARGET_FILE_COUNT) && attempts < MAX_SPLIT_ATTEMPTS { 292 + attempts += 1; 293 + 294 + // Find large directories to split 295 + let directories = subfs_utils::find_large_directories(&working_directory, String::new()); 296 + 297 + if let Some(largest_dir) = directories.first() { 298 + println!(" Split #{}: {} ({} files, {:.1}KB)", 299 + attempts, largest_dir.path, largest_dir.file_count, largest_dir.size as f64 / 1024.0); 300 + 301 + // Create a subfs record for this directory 302 + use jacquard_common::types::string::Tid; 303 + let subfs_tid = Tid::now_0(); 304 + let subfs_rkey = subfs_tid.to_string(); 305 + 306 + let subfs_manifest = crate::place_wisp::subfs::SubfsRecord::new() 307 + .root(convert_fs_dir_to_subfs_dir(largest_dir.directory.clone())) 308 + .file_count(Some(largest_dir.file_count as i64)) 309 + .created_at(Datetime::now()) 310 + .build(); 311 + 312 + // Upload subfs record 313 + let subfs_output = agent.put_record( 314 + RecordKey::from(Rkey::new(&subfs_rkey).into_diagnostic()?), 315 + subfs_manifest 316 + ).await.into_diagnostic()?; 317 + 318 + let subfs_uri = subfs_output.uri.to_string(); 319 + println!(" ✅ Created subfs: {}", subfs_uri); 320 + 321 + // Replace directory with subfs node (flat: false to preserve structure) 322 + working_directory = subfs_utils::replace_directory_with_subfs( 323 + working_directory, 324 + &largest_dir.path, 325 + &subfs_uri, 326 + false // Preserve directory structure 327 + )?; 328 + 329 + new_subfs_uris.push((subfs_uri, largest_dir.path.clone())); 330 + current_file_count -= largest_dir.file_count; 331 + 332 + // Recalculate manifest size 333 + manifest_size = subfs_utils::estimate_directory_size(&working_directory); 334 + println!(" → Manifest now {:.1}KB with {} files ({} subfs total)", 335 + manifest_size as f64 / 1024.0, current_file_count, new_subfs_uris.len()); 336 + 337 + if manifest_size <= MAX_MANIFEST_SIZE && current_file_count <= TARGET_FILE_COUNT { 338 + println!("✅ Manifest now fits within limits"); 339 + break; 340 + } 341 + } else { 342 + println!(" No more subdirectories to split - stopping"); 343 + break; 344 + } 345 + } 346 + 347 + if attempts >= MAX_SPLIT_ATTEMPTS { 348 + return Err(miette::miette!( 349 + "Exceeded maximum split attempts ({}). Manifest still too large: {:.1}KB with {} files", 350 + MAX_SPLIT_ATTEMPTS, 351 + manifest_size as f64 / 1024.0, 352 + current_file_count 353 + )); 354 + } 355 + 356 + println!("✅ Split complete: {} subfs records, {} files in main manifest, {:.1}KB", 357 + new_subfs_uris.len(), current_file_count, manifest_size as f64 / 1024.0); 358 + } else { 359 + println!("Manifest created ({} files, {:.1}KB) - no splitting needed", 360 + total_files, manifest_size as f64 / 1024.0); 361 + } 362 + 363 + // Create the final Fs record 364 let fs_record = Fs::new() 365 .site(CowStr::from(site_name.clone())) 366 + .root(working_directory) 367 + .file_count(current_file_count as i64) 368 .created_at(Datetime::now()) 369 .build(); 370 ··· 382 println!("\n✓ Deployed site '{}': {}", site_name, output.uri); 383 println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count); 384 println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name); 385 + 386 + // Clean up old subfs records 387 + if !old_subfs_uris.is_empty() { 388 + println!("\nCleaning up {} old subfs records...", old_subfs_uris.len()); 389 + 390 + let mut deleted_count = 0; 391 + let mut failed_count = 0; 392 + 393 + for (uri, _path) in old_subfs_uris { 394 + match subfs_utils::delete_subfs_record(agent, &uri).await { 395 + Ok(_) => { 396 + deleted_count += 1; 397 + println!(" 🗑️ Deleted old subfs: {}", uri); 398 + } 399 + Err(e) => { 400 + failed_count += 1; 401 + eprintln!(" ⚠️ Failed to delete {}: {}", uri, e); 402 + } 403 + } 404 + } 405 + 406 + if failed_count > 0 { 407 + eprintln!("⚠️ Cleanup completed with {} deleted, {} failed", deleted_count, failed_count); 408 + } else { 409 + println!("✅ Cleanup complete: {} old subfs records deleted", deleted_count); 410 + } 411 + } 412 413 Ok(()) 414 } ··· 587 )) 588 } 589 590 + /// Convert fs::Directory to subfs::Directory 591 + /// They have the same structure, but different types 592 + fn convert_fs_dir_to_subfs_dir(fs_dir: place_wisp::fs::Directory<'static>) -> place_wisp::subfs::Directory<'static> { 593 + use place_wisp::subfs::{Directory as SubfsDirectory, Entry as SubfsEntry, EntryNode as SubfsEntryNode, File as SubfsFile}; 594 + 595 + let subfs_entries: Vec<SubfsEntry> = fs_dir.entries.into_iter().map(|entry| { 596 + let node = match entry.node { 597 + place_wisp::fs::EntryNode::File(file) => { 598 + SubfsEntryNode::File(Box::new(SubfsFile::new() 599 + .r#type(file.r#type) 600 + .blob(file.blob) 601 + .encoding(file.encoding) 602 + .mime_type(file.mime_type) 603 + .base64(file.base64) 604 + .build())) 605 + } 606 + place_wisp::fs::EntryNode::Directory(dir) => { 607 + SubfsEntryNode::Directory(Box::new(convert_fs_dir_to_subfs_dir(*dir))) 608 + } 609 + place_wisp::fs::EntryNode::Subfs(subfs) => { 610 + // Nested subfs in the directory we're converting 611 + // Note: subfs::Subfs doesn't have the 'flat' field - that's only in fs::Subfs 612 + SubfsEntryNode::Subfs(Box::new(place_wisp::subfs::Subfs::new() 613 + .r#type(subfs.r#type) 614 + .subject(subfs.subject) 615 + .build())) 616 + } 617 + place_wisp::fs::EntryNode::Unknown(unknown) => { 618 + SubfsEntryNode::Unknown(unknown) 619 + } 620 + }; 621 + 622 + SubfsEntry::new() 623 + .name(entry.name) 624 + .node(node) 625 + .build() 626 + }).collect(); 627 + 628 + SubfsDirectory::new() 629 + .r#type(fs_dir.r#type) 630 + .entries(subfs_entries) 631 + .build() 632 + } 633 +
+2 -1
cli/src/place_wisp.rs
··· 3 // This file was automatically generated from Lexicon schemas. 4 // Any manual changes will be overwritten on the next regeneration. 5 6 - pub mod fs;
··· 3 // This file was automatically generated from Lexicon schemas. 4 // Any manual changes will be overwritten on the next regeneration. 5 6 + pub mod fs; 7 + pub mod subfs;
+261 -1
cli/src/place_wisp/fs.rs
··· 251 description: None, 252 refs: vec![ 253 ::jacquard_common::CowStr::new_static("#file"), 254 - ::jacquard_common::CowStr::new_static("#directory") 255 ], 256 closed: None, 257 }), ··· 428 }), 429 }), 430 ); 431 map 432 }, 433 } ··· 638 File(Box<crate::place_wisp::fs::File<'a>>), 639 #[serde(rename = "place.wisp.fs#directory")] 640 Directory(Box<crate::place_wisp::fs::Directory<'a>>), 641 } 642 643 impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Entry<'a> { ··· 1225 }); 1226 } 1227 } 1228 Ok(()) 1229 } 1230 }
··· 251 description: None, 252 refs: vec![ 253 ::jacquard_common::CowStr::new_static("#file"), 254 + ::jacquard_common::CowStr::new_static("#directory"), 255 + ::jacquard_common::CowStr::new_static("#subfs") 256 ], 257 closed: None, 258 }), ··· 429 }), 430 }), 431 ); 432 + map.insert( 433 + ::jacquard_common::smol_str::SmolStr::new_static("subfs"), 434 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 435 + description: None, 436 + required: Some( 437 + vec![ 438 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 439 + ::jacquard_common::smol_str::SmolStr::new_static("subject") 440 + ], 441 + ), 442 + nullable: None, 443 + properties: { 444 + #[allow(unused_mut)] 445 + let mut map = ::std::collections::BTreeMap::new(); 446 + map.insert( 447 + ::jacquard_common::smol_str::SmolStr::new_static("flat"), 448 + ::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean { 449 + description: None, 450 + default: None, 451 + r#const: None, 452 + }), 453 + ); 454 + map.insert( 455 + ::jacquard_common::smol_str::SmolStr::new_static("subject"), 456 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 457 + description: Some( 458 + ::jacquard_common::CowStr::new_static( 459 + "AT-URI pointing to a place.wisp.subfs record containing this subtree.", 460 + ), 461 + ), 462 + format: Some( 463 + ::jacquard_lexicon::lexicon::LexStringFormat::AtUri, 464 + ), 465 + default: None, 466 + min_length: None, 467 + max_length: None, 468 + min_graphemes: None, 469 + max_graphemes: None, 470 + r#enum: None, 471 + r#const: None, 472 + known_values: None, 473 + }), 474 + ); 475 + map.insert( 476 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 477 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 478 + description: None, 479 + format: None, 480 + default: None, 481 + min_length: None, 482 + max_length: None, 483 + min_graphemes: None, 484 + max_graphemes: None, 485 + r#enum: None, 486 + r#const: None, 487 + known_values: None, 488 + }), 489 + ); 490 + map 491 + }, 492 + }), 493 + ); 494 map 495 }, 496 } ··· 701 File(Box<crate::place_wisp::fs::File<'a>>), 702 #[serde(rename = "place.wisp.fs#directory")] 703 Directory(Box<crate::place_wisp::fs::Directory<'a>>), 704 + #[serde(rename = "place.wisp.fs#subfs")] 705 + Subfs(Box<crate::place_wisp::fs::Subfs<'a>>), 706 } 707 708 impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Entry<'a> { ··· 1290 }); 1291 } 1292 } 1293 + Ok(()) 1294 + } 1295 + } 1296 + 1297 + #[jacquard_derive::lexicon] 1298 + #[derive( 1299 + serde::Serialize, 1300 + serde::Deserialize, 1301 + Debug, 1302 + Clone, 1303 + PartialEq, 1304 + Eq, 1305 + jacquard_derive::IntoStatic 1306 + )] 1307 + #[serde(rename_all = "camelCase")] 1308 + pub struct Subfs<'a> { 1309 + /// If true, the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false (default), the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure. 1310 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 1311 + pub flat: Option<bool>, 1312 + /// AT-URI pointing to a place.wisp.subfs record containing this subtree. 1313 + #[serde(borrow)] 1314 + pub subject: jacquard_common::types::string::AtUri<'a>, 1315 + #[serde(borrow)] 1316 + pub r#type: jacquard_common::CowStr<'a>, 1317 + } 1318 + 1319 + pub mod subfs_state { 1320 + 1321 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 1322 + #[allow(unused)] 1323 + use ::core::marker::PhantomData; 1324 + mod sealed { 1325 + pub trait Sealed {} 1326 + } 1327 + /// State trait tracking which required fields have been set 1328 + pub trait State: sealed::Sealed { 1329 + type Type; 1330 + type Subject; 1331 + } 1332 + /// Empty state - all required fields are unset 1333 + pub struct Empty(()); 1334 + impl sealed::Sealed for Empty {} 1335 + impl State for Empty { 1336 + type Type = Unset; 1337 + type Subject = Unset; 1338 + } 1339 + ///State transition - sets the `type` field to Set 1340 + pub struct SetType<S: State = Empty>(PhantomData<fn() -> S>); 1341 + impl<S: State> sealed::Sealed for SetType<S> {} 1342 + impl<S: State> State for SetType<S> { 1343 + type Type = Set<members::r#type>; 1344 + type Subject = S::Subject; 1345 + } 1346 + ///State transition - sets the `subject` field to Set 1347 + pub struct SetSubject<S: State = Empty>(PhantomData<fn() -> S>); 1348 + impl<S: State> sealed::Sealed for SetSubject<S> {} 1349 + impl<S: State> State for SetSubject<S> { 1350 + type Type = S::Type; 1351 + type Subject = Set<members::subject>; 1352 + } 1353 + /// Marker types for field names 1354 + #[allow(non_camel_case_types)] 1355 + pub mod members { 1356 + ///Marker type for the `type` field 1357 + pub struct r#type(()); 1358 + ///Marker type for the `subject` field 1359 + pub struct subject(()); 1360 + } 1361 + } 1362 + 1363 + /// Builder for constructing an instance of this type 1364 + pub struct SubfsBuilder<'a, S: subfs_state::State> { 1365 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 1366 + __unsafe_private_named: ( 1367 + ::core::option::Option<bool>, 1368 + ::core::option::Option<jacquard_common::types::string::AtUri<'a>>, 1369 + ::core::option::Option<jacquard_common::CowStr<'a>>, 1370 + ), 1371 + _phantom: ::core::marker::PhantomData<&'a ()>, 1372 + } 1373 + 1374 + impl<'a> Subfs<'a> { 1375 + /// Create a new builder for this type 1376 + pub fn new() -> SubfsBuilder<'a, subfs_state::Empty> { 1377 + SubfsBuilder::new() 1378 + } 1379 + } 1380 + 1381 + impl<'a> SubfsBuilder<'a, subfs_state::Empty> { 1382 + /// Create a new builder with all fields unset 1383 + pub fn new() -> Self { 1384 + SubfsBuilder { 1385 + _phantom_state: ::core::marker::PhantomData, 1386 + __unsafe_private_named: (None, None, None), 1387 + _phantom: ::core::marker::PhantomData, 1388 + } 1389 + } 1390 + } 1391 + 1392 + impl<'a, S: subfs_state::State> SubfsBuilder<'a, S> { 1393 + /// Set the `flat` field (optional) 1394 + pub fn flat(mut self, value: impl Into<Option<bool>>) -> Self { 1395 + self.__unsafe_private_named.0 = value.into(); 1396 + self 1397 + } 1398 + /// Set the `flat` field to an Option value (optional) 1399 + pub fn maybe_flat(mut self, value: Option<bool>) -> Self { 1400 + self.__unsafe_private_named.0 = value; 1401 + self 1402 + } 1403 + } 1404 + 1405 + impl<'a, S> SubfsBuilder<'a, S> 1406 + where 1407 + S: subfs_state::State, 1408 + S::Subject: subfs_state::IsUnset, 1409 + { 1410 + /// Set the `subject` field (required) 1411 + pub fn subject( 1412 + mut self, 1413 + value: impl Into<jacquard_common::types::string::AtUri<'a>>, 1414 + ) -> SubfsBuilder<'a, subfs_state::SetSubject<S>> { 1415 + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); 1416 + SubfsBuilder { 1417 + _phantom_state: ::core::marker::PhantomData, 1418 + __unsafe_private_named: self.__unsafe_private_named, 1419 + _phantom: ::core::marker::PhantomData, 1420 + } 1421 + } 1422 + } 1423 + 1424 + impl<'a, S> SubfsBuilder<'a, S> 1425 + where 1426 + S: subfs_state::State, 1427 + S::Type: subfs_state::IsUnset, 1428 + { 1429 + /// Set the `type` field (required) 1430 + pub fn r#type( 1431 + mut self, 1432 + value: impl Into<jacquard_common::CowStr<'a>>, 1433 + ) -> SubfsBuilder<'a, subfs_state::SetType<S>> { 1434 + self.__unsafe_private_named.2 = ::core::option::Option::Some(value.into()); 1435 + SubfsBuilder { 1436 + _phantom_state: ::core::marker::PhantomData, 1437 + __unsafe_private_named: self.__unsafe_private_named, 1438 + _phantom: ::core::marker::PhantomData, 1439 + } 1440 + } 1441 + } 1442 + 1443 + impl<'a, S> SubfsBuilder<'a, S> 1444 + where 1445 + S: subfs_state::State, 1446 + S::Type: subfs_state::IsSet, 1447 + S::Subject: subfs_state::IsSet, 1448 + { 1449 + /// Build the final struct 1450 + pub fn build(self) -> Subfs<'a> { 1451 + Subfs { 1452 + flat: self.__unsafe_private_named.0, 1453 + subject: self.__unsafe_private_named.1.unwrap(), 1454 + r#type: self.__unsafe_private_named.2.unwrap(), 1455 + extra_data: Default::default(), 1456 + } 1457 + } 1458 + /// Build the final struct with custom extra_data 1459 + pub fn build_with_data( 1460 + self, 1461 + extra_data: std::collections::BTreeMap< 1462 + jacquard_common::smol_str::SmolStr, 1463 + jacquard_common::types::value::Data<'a>, 1464 + >, 1465 + ) -> Subfs<'a> { 1466 + Subfs { 1467 + flat: self.__unsafe_private_named.0, 1468 + subject: self.__unsafe_private_named.1.unwrap(), 1469 + r#type: self.__unsafe_private_named.2.unwrap(), 1470 + extra_data: Some(extra_data), 1471 + } 1472 + } 1473 + } 1474 + 1475 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Subfs<'a> { 1476 + fn nsid() -> &'static str { 1477 + "place.wisp.fs" 1478 + } 1479 + fn def_name() -> &'static str { 1480 + "subfs" 1481 + } 1482 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 1483 + lexicon_doc_place_wisp_fs() 1484 + } 1485 + fn validate( 1486 + &self, 1487 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 1488 Ok(()) 1489 } 1490 }
+1408
cli/src/place_wisp/subfs.rs
···
··· 1 + // @generated by jacquard-lexicon. DO NOT EDIT. 2 + // 3 + // Lexicon: place.wisp.subfs 4 + // 5 + // This file was automatically generated from Lexicon schemas. 6 + // Any manual changes will be overwritten on the next regeneration. 7 + 8 + #[jacquard_derive::lexicon] 9 + #[derive( 10 + serde::Serialize, 11 + serde::Deserialize, 12 + Debug, 13 + Clone, 14 + PartialEq, 15 + Eq, 16 + jacquard_derive::IntoStatic 17 + )] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct Directory<'a> { 20 + #[serde(borrow)] 21 + pub entries: Vec<crate::place_wisp::subfs::Entry<'a>>, 22 + #[serde(borrow)] 23 + pub r#type: jacquard_common::CowStr<'a>, 24 + } 25 + 26 + pub mod directory_state { 27 + 28 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 29 + #[allow(unused)] 30 + use ::core::marker::PhantomData; 31 + mod sealed { 32 + pub trait Sealed {} 33 + } 34 + /// State trait tracking which required fields have been set 35 + pub trait State: sealed::Sealed { 36 + type Type; 37 + type Entries; 38 + } 39 + /// Empty state - all required fields are unset 40 + pub struct Empty(()); 41 + impl sealed::Sealed for Empty {} 42 + impl State for Empty { 43 + type Type = Unset; 44 + type Entries = Unset; 45 + } 46 + ///State transition - sets the `type` field to Set 47 + pub struct SetType<S: State = Empty>(PhantomData<fn() -> S>); 48 + impl<S: State> sealed::Sealed for SetType<S> {} 49 + impl<S: State> State for SetType<S> { 50 + type Type = Set<members::r#type>; 51 + type Entries = S::Entries; 52 + } 53 + ///State transition - sets the `entries` field to Set 54 + pub struct SetEntries<S: State = Empty>(PhantomData<fn() -> S>); 55 + impl<S: State> sealed::Sealed for SetEntries<S> {} 56 + impl<S: State> State for SetEntries<S> { 57 + type Type = S::Type; 58 + type Entries = Set<members::entries>; 59 + } 60 + /// Marker types for field names 61 + #[allow(non_camel_case_types)] 62 + pub mod members { 63 + ///Marker type for the `type` field 64 + pub struct r#type(()); 65 + ///Marker type for the `entries` field 66 + pub struct entries(()); 67 + } 68 + } 69 + 70 + /// Builder for constructing an instance of this type 71 + pub struct DirectoryBuilder<'a, S: directory_state::State> { 72 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 73 + __unsafe_private_named: ( 74 + ::core::option::Option<Vec<crate::place_wisp::subfs::Entry<'a>>>, 75 + ::core::option::Option<jacquard_common::CowStr<'a>>, 76 + ), 77 + _phantom: ::core::marker::PhantomData<&'a ()>, 78 + } 79 + 80 + impl<'a> Directory<'a> { 81 + /// Create a new builder for this type 82 + pub fn new() -> DirectoryBuilder<'a, directory_state::Empty> { 83 + DirectoryBuilder::new() 84 + } 85 + } 86 + 87 + impl<'a> DirectoryBuilder<'a, directory_state::Empty> { 88 + /// Create a new builder with all fields unset 89 + pub fn new() -> Self { 90 + DirectoryBuilder { 91 + _phantom_state: ::core::marker::PhantomData, 92 + __unsafe_private_named: (None, None), 93 + _phantom: ::core::marker::PhantomData, 94 + } 95 + } 96 + } 97 + 98 + impl<'a, S> DirectoryBuilder<'a, S> 99 + where 100 + S: directory_state::State, 101 + S::Entries: directory_state::IsUnset, 102 + { 103 + /// Set the `entries` field (required) 104 + pub fn entries( 105 + mut self, 106 + value: impl Into<Vec<crate::place_wisp::subfs::Entry<'a>>>, 107 + ) -> DirectoryBuilder<'a, directory_state::SetEntries<S>> { 108 + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); 109 + DirectoryBuilder { 110 + _phantom_state: ::core::marker::PhantomData, 111 + __unsafe_private_named: self.__unsafe_private_named, 112 + _phantom: ::core::marker::PhantomData, 113 + } 114 + } 115 + } 116 + 117 + impl<'a, S> DirectoryBuilder<'a, S> 118 + where 119 + S: directory_state::State, 120 + S::Type: directory_state::IsUnset, 121 + { 122 + /// Set the `type` field (required) 123 + pub fn r#type( 124 + mut self, 125 + value: impl Into<jacquard_common::CowStr<'a>>, 126 + ) -> DirectoryBuilder<'a, directory_state::SetType<S>> { 127 + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); 128 + DirectoryBuilder { 129 + _phantom_state: ::core::marker::PhantomData, 130 + __unsafe_private_named: self.__unsafe_private_named, 131 + _phantom: ::core::marker::PhantomData, 132 + } 133 + } 134 + } 135 + 136 + impl<'a, S> DirectoryBuilder<'a, S> 137 + where 138 + S: directory_state::State, 139 + S::Type: directory_state::IsSet, 140 + S::Entries: directory_state::IsSet, 141 + { 142 + /// Build the final struct 143 + pub fn build(self) -> Directory<'a> { 144 + Directory { 145 + entries: self.__unsafe_private_named.0.unwrap(), 146 + r#type: self.__unsafe_private_named.1.unwrap(), 147 + extra_data: Default::default(), 148 + } 149 + } 150 + /// Build the final struct with custom extra_data 151 + pub fn build_with_data( 152 + self, 153 + extra_data: std::collections::BTreeMap< 154 + jacquard_common::smol_str::SmolStr, 155 + jacquard_common::types::value::Data<'a>, 156 + >, 157 + ) -> Directory<'a> { 158 + Directory { 159 + entries: self.__unsafe_private_named.0.unwrap(), 160 + r#type: self.__unsafe_private_named.1.unwrap(), 161 + extra_data: Some(extra_data), 162 + } 163 + } 164 + } 165 + 166 + fn lexicon_doc_place_wisp_subfs() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 167 + ::jacquard_lexicon::lexicon::LexiconDoc { 168 + lexicon: ::jacquard_lexicon::lexicon::Lexicon::Lexicon1, 169 + id: ::jacquard_common::CowStr::new_static("place.wisp.subfs"), 170 + revision: None, 171 + description: None, 172 + defs: { 173 + let mut map = ::std::collections::BTreeMap::new(); 174 + map.insert( 175 + ::jacquard_common::smol_str::SmolStr::new_static("directory"), 176 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 177 + description: None, 178 + required: Some( 179 + vec![ 180 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 181 + ::jacquard_common::smol_str::SmolStr::new_static("entries") 182 + ], 183 + ), 184 + nullable: None, 185 + properties: { 186 + #[allow(unused_mut)] 187 + let mut map = ::std::collections::BTreeMap::new(); 188 + map.insert( 189 + ::jacquard_common::smol_str::SmolStr::new_static("entries"), 190 + ::jacquard_lexicon::lexicon::LexObjectProperty::Array(::jacquard_lexicon::lexicon::LexArray { 191 + description: None, 192 + items: ::jacquard_lexicon::lexicon::LexArrayItem::Ref(::jacquard_lexicon::lexicon::LexRef { 193 + description: None, 194 + r#ref: ::jacquard_common::CowStr::new_static("#entry"), 195 + }), 196 + min_length: None, 197 + max_length: Some(500usize), 198 + }), 199 + ); 200 + map.insert( 201 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 202 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 203 + description: None, 204 + format: None, 205 + default: None, 206 + min_length: None, 207 + max_length: None, 208 + min_graphemes: None, 209 + max_graphemes: None, 210 + r#enum: None, 211 + r#const: None, 212 + known_values: None, 213 + }), 214 + ); 215 + map 216 + }, 217 + }), 218 + ); 219 + map.insert( 220 + ::jacquard_common::smol_str::SmolStr::new_static("entry"), 221 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 222 + description: None, 223 + required: Some( 224 + vec![ 225 + ::jacquard_common::smol_str::SmolStr::new_static("name"), 226 + ::jacquard_common::smol_str::SmolStr::new_static("node") 227 + ], 228 + ), 229 + nullable: None, 230 + properties: { 231 + #[allow(unused_mut)] 232 + let mut map = ::std::collections::BTreeMap::new(); 233 + map.insert( 234 + ::jacquard_common::smol_str::SmolStr::new_static("name"), 235 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 236 + description: None, 237 + format: None, 238 + default: None, 239 + min_length: None, 240 + max_length: Some(255usize), 241 + min_graphemes: None, 242 + max_graphemes: None, 243 + r#enum: None, 244 + r#const: None, 245 + known_values: None, 246 + }), 247 + ); 248 + map.insert( 249 + ::jacquard_common::smol_str::SmolStr::new_static("node"), 250 + ::jacquard_lexicon::lexicon::LexObjectProperty::Union(::jacquard_lexicon::lexicon::LexRefUnion { 251 + description: None, 252 + refs: vec![ 253 + ::jacquard_common::CowStr::new_static("#file"), 254 + ::jacquard_common::CowStr::new_static("#directory"), 255 + ::jacquard_common::CowStr::new_static("#subfs") 256 + ], 257 + closed: None, 258 + }), 259 + ); 260 + map 261 + }, 262 + }), 263 + ); 264 + map.insert( 265 + ::jacquard_common::smol_str::SmolStr::new_static("file"), 266 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 267 + description: None, 268 + required: Some( 269 + vec![ 270 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 271 + ::jacquard_common::smol_str::SmolStr::new_static("blob") 272 + ], 273 + ), 274 + nullable: None, 275 + properties: { 276 + #[allow(unused_mut)] 277 + let mut map = ::std::collections::BTreeMap::new(); 278 + map.insert( 279 + ::jacquard_common::smol_str::SmolStr::new_static("base64"), 280 + ::jacquard_lexicon::lexicon::LexObjectProperty::Boolean(::jacquard_lexicon::lexicon::LexBoolean { 281 + description: None, 282 + default: None, 283 + r#const: None, 284 + }), 285 + ); 286 + map.insert( 287 + ::jacquard_common::smol_str::SmolStr::new_static("blob"), 288 + ::jacquard_lexicon::lexicon::LexObjectProperty::Blob(::jacquard_lexicon::lexicon::LexBlob { 289 + description: None, 290 + accept: None, 291 + max_size: None, 292 + }), 293 + ); 294 + map.insert( 295 + ::jacquard_common::smol_str::SmolStr::new_static("encoding"), 296 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 297 + description: Some( 298 + ::jacquard_common::CowStr::new_static( 299 + "Content encoding (e.g., gzip for compressed files)", 300 + ), 301 + ), 302 + format: None, 303 + default: None, 304 + min_length: None, 305 + max_length: None, 306 + min_graphemes: None, 307 + max_graphemes: None, 308 + r#enum: None, 309 + r#const: None, 310 + known_values: None, 311 + }), 312 + ); 313 + map.insert( 314 + ::jacquard_common::smol_str::SmolStr::new_static("mimeType"), 315 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 316 + description: Some( 317 + ::jacquard_common::CowStr::new_static( 318 + "Original MIME type before compression", 319 + ), 320 + ), 321 + format: None, 322 + default: None, 323 + min_length: None, 324 + max_length: None, 325 + min_graphemes: None, 326 + max_graphemes: None, 327 + r#enum: None, 328 + r#const: None, 329 + known_values: None, 330 + }), 331 + ); 332 + map.insert( 333 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 334 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 335 + description: None, 336 + format: None, 337 + default: None, 338 + min_length: None, 339 + max_length: None, 340 + min_graphemes: None, 341 + max_graphemes: None, 342 + r#enum: None, 343 + r#const: None, 344 + known_values: None, 345 + }), 346 + ); 347 + map 348 + }, 349 + }), 350 + ); 351 + map.insert( 352 + ::jacquard_common::smol_str::SmolStr::new_static("main"), 353 + ::jacquard_lexicon::lexicon::LexUserType::Record(::jacquard_lexicon::lexicon::LexRecord { 354 + description: Some( 355 + ::jacquard_common::CowStr::new_static( 356 + "Virtual filesystem subtree referenced by place.wisp.fs records. When a subfs entry is expanded, its root entries are merged (flattened) into the parent directory, allowing large directories to be split across multiple records while maintaining a flat structure.", 357 + ), 358 + ), 359 + key: None, 360 + record: ::jacquard_lexicon::lexicon::LexRecordRecord::Object(::jacquard_lexicon::lexicon::LexObject { 361 + description: None, 362 + required: Some( 363 + vec![ 364 + ::jacquard_common::smol_str::SmolStr::new_static("root"), 365 + ::jacquard_common::smol_str::SmolStr::new_static("createdAt") 366 + ], 367 + ), 368 + nullable: None, 369 + properties: { 370 + #[allow(unused_mut)] 371 + let mut map = ::std::collections::BTreeMap::new(); 372 + map.insert( 373 + ::jacquard_common::smol_str::SmolStr::new_static( 374 + "createdAt", 375 + ), 376 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 377 + description: None, 378 + format: Some( 379 + ::jacquard_lexicon::lexicon::LexStringFormat::Datetime, 380 + ), 381 + default: None, 382 + min_length: None, 383 + max_length: None, 384 + min_graphemes: None, 385 + max_graphemes: None, 386 + r#enum: None, 387 + r#const: None, 388 + known_values: None, 389 + }), 390 + ); 391 + map.insert( 392 + ::jacquard_common::smol_str::SmolStr::new_static( 393 + "fileCount", 394 + ), 395 + ::jacquard_lexicon::lexicon::LexObjectProperty::Integer(::jacquard_lexicon::lexicon::LexInteger { 396 + description: None, 397 + default: None, 398 + minimum: Some(0i64), 399 + maximum: Some(1000i64), 400 + r#enum: None, 401 + r#const: None, 402 + }), 403 + ); 404 + map.insert( 405 + ::jacquard_common::smol_str::SmolStr::new_static("root"), 406 + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { 407 + description: None, 408 + r#ref: ::jacquard_common::CowStr::new_static("#directory"), 409 + }), 410 + ); 411 + map 412 + }, 413 + }), 414 + }), 415 + ); 416 + map.insert( 417 + ::jacquard_common::smol_str::SmolStr::new_static("subfs"), 418 + ::jacquard_lexicon::lexicon::LexUserType::Object(::jacquard_lexicon::lexicon::LexObject { 419 + description: None, 420 + required: Some( 421 + vec![ 422 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 423 + ::jacquard_common::smol_str::SmolStr::new_static("subject") 424 + ], 425 + ), 426 + nullable: None, 427 + properties: { 428 + #[allow(unused_mut)] 429 + let mut map = ::std::collections::BTreeMap::new(); 430 + map.insert( 431 + ::jacquard_common::smol_str::SmolStr::new_static("subject"), 432 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 433 + description: Some( 434 + ::jacquard_common::CowStr::new_static( 435 + "AT-URI pointing to another place.wisp.subfs record for nested subtrees. When expanded, the referenced record's root entries are merged (flattened) into the parent directory, allowing recursive splitting of large directory structures.", 436 + ), 437 + ), 438 + format: Some( 439 + ::jacquard_lexicon::lexicon::LexStringFormat::AtUri, 440 + ), 441 + default: None, 442 + min_length: None, 443 + max_length: None, 444 + min_graphemes: None, 445 + max_graphemes: None, 446 + r#enum: None, 447 + r#const: None, 448 + known_values: None, 449 + }), 450 + ); 451 + map.insert( 452 + ::jacquard_common::smol_str::SmolStr::new_static("type"), 453 + ::jacquard_lexicon::lexicon::LexObjectProperty::String(::jacquard_lexicon::lexicon::LexString { 454 + description: None, 455 + format: None, 456 + default: None, 457 + min_length: None, 458 + max_length: None, 459 + min_graphemes: None, 460 + max_graphemes: None, 461 + r#enum: None, 462 + r#const: None, 463 + known_values: None, 464 + }), 465 + ); 466 + map 467 + }, 468 + }), 469 + ); 470 + map 471 + }, 472 + } 473 + } 474 + 475 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Directory<'a> { 476 + fn nsid() -> &'static str { 477 + "place.wisp.subfs" 478 + } 479 + fn def_name() -> &'static str { 480 + "directory" 481 + } 482 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 483 + lexicon_doc_place_wisp_subfs() 484 + } 485 + fn validate( 486 + &self, 487 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 488 + { 489 + let value = &self.entries; 490 + #[allow(unused_comparisons)] 491 + if value.len() > 500usize { 492 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 493 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 494 + "entries", 495 + ), 496 + max: 500usize, 497 + actual: value.len(), 498 + }); 499 + } 500 + } 501 + Ok(()) 502 + } 503 + } 504 + 505 + #[jacquard_derive::lexicon] 506 + #[derive( 507 + serde::Serialize, 508 + serde::Deserialize, 509 + Debug, 510 + Clone, 511 + PartialEq, 512 + Eq, 513 + jacquard_derive::IntoStatic 514 + )] 515 + #[serde(rename_all = "camelCase")] 516 + pub struct Entry<'a> { 517 + #[serde(borrow)] 518 + pub name: jacquard_common::CowStr<'a>, 519 + #[serde(borrow)] 520 + pub node: EntryNode<'a>, 521 + } 522 + 523 + pub mod entry_state { 524 + 525 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 526 + #[allow(unused)] 527 + use ::core::marker::PhantomData; 528 + mod sealed { 529 + pub trait Sealed {} 530 + } 531 + /// State trait tracking which required fields have been set 532 + pub trait State: sealed::Sealed { 533 + type Name; 534 + type Node; 535 + } 536 + /// Empty state - all required fields are unset 537 + pub struct Empty(()); 538 + impl sealed::Sealed for Empty {} 539 + impl State for Empty { 540 + type Name = Unset; 541 + type Node = Unset; 542 + } 543 + ///State transition - sets the `name` field to Set 544 + pub struct SetName<S: State = Empty>(PhantomData<fn() -> S>); 545 + impl<S: State> sealed::Sealed for SetName<S> {} 546 + impl<S: State> State for SetName<S> { 547 + type Name = Set<members::name>; 548 + type Node = S::Node; 549 + } 550 + ///State transition - sets the `node` field to Set 551 + pub struct SetNode<S: State = Empty>(PhantomData<fn() -> S>); 552 + impl<S: State> sealed::Sealed for SetNode<S> {} 553 + impl<S: State> State for SetNode<S> { 554 + type Name = S::Name; 555 + type Node = Set<members::node>; 556 + } 557 + /// Marker types for field names 558 + #[allow(non_camel_case_types)] 559 + pub mod members { 560 + ///Marker type for the `name` field 561 + pub struct name(()); 562 + ///Marker type for the `node` field 563 + pub struct node(()); 564 + } 565 + } 566 + 567 + /// Builder for constructing an instance of this type 568 + pub struct EntryBuilder<'a, S: entry_state::State> { 569 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 570 + __unsafe_private_named: ( 571 + ::core::option::Option<jacquard_common::CowStr<'a>>, 572 + ::core::option::Option<EntryNode<'a>>, 573 + ), 574 + _phantom: ::core::marker::PhantomData<&'a ()>, 575 + } 576 + 577 + impl<'a> Entry<'a> { 578 + /// Create a new builder for this type 579 + pub fn new() -> EntryBuilder<'a, entry_state::Empty> { 580 + EntryBuilder::new() 581 + } 582 + } 583 + 584 + impl<'a> EntryBuilder<'a, entry_state::Empty> { 585 + /// Create a new builder with all fields unset 586 + pub fn new() -> Self { 587 + EntryBuilder { 588 + _phantom_state: ::core::marker::PhantomData, 589 + __unsafe_private_named: (None, None), 590 + _phantom: ::core::marker::PhantomData, 591 + } 592 + } 593 + } 594 + 595 + impl<'a, S> EntryBuilder<'a, S> 596 + where 597 + S: entry_state::State, 598 + S::Name: entry_state::IsUnset, 599 + { 600 + /// Set the `name` field (required) 601 + pub fn name( 602 + mut self, 603 + value: impl Into<jacquard_common::CowStr<'a>>, 604 + ) -> EntryBuilder<'a, entry_state::SetName<S>> { 605 + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); 606 + EntryBuilder { 607 + _phantom_state: ::core::marker::PhantomData, 608 + __unsafe_private_named: self.__unsafe_private_named, 609 + _phantom: ::core::marker::PhantomData, 610 + } 611 + } 612 + } 613 + 614 + impl<'a, S> EntryBuilder<'a, S> 615 + where 616 + S: entry_state::State, 617 + S::Node: entry_state::IsUnset, 618 + { 619 + /// Set the `node` field (required) 620 + pub fn node( 621 + mut self, 622 + value: impl Into<EntryNode<'a>>, 623 + ) -> EntryBuilder<'a, entry_state::SetNode<S>> { 624 + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); 625 + EntryBuilder { 626 + _phantom_state: ::core::marker::PhantomData, 627 + __unsafe_private_named: self.__unsafe_private_named, 628 + _phantom: ::core::marker::PhantomData, 629 + } 630 + } 631 + } 632 + 633 + impl<'a, S> EntryBuilder<'a, S> 634 + where 635 + S: entry_state::State, 636 + S::Name: entry_state::IsSet, 637 + S::Node: entry_state::IsSet, 638 + { 639 + /// Build the final struct 640 + pub fn build(self) -> Entry<'a> { 641 + Entry { 642 + name: self.__unsafe_private_named.0.unwrap(), 643 + node: self.__unsafe_private_named.1.unwrap(), 644 + extra_data: Default::default(), 645 + } 646 + } 647 + /// Build the final struct with custom extra_data 648 + pub fn build_with_data( 649 + self, 650 + extra_data: std::collections::BTreeMap< 651 + jacquard_common::smol_str::SmolStr, 652 + jacquard_common::types::value::Data<'a>, 653 + >, 654 + ) -> Entry<'a> { 655 + Entry { 656 + name: self.__unsafe_private_named.0.unwrap(), 657 + node: self.__unsafe_private_named.1.unwrap(), 658 + extra_data: Some(extra_data), 659 + } 660 + } 661 + } 662 + 663 + #[jacquard_derive::open_union] 664 + #[derive( 665 + serde::Serialize, 666 + serde::Deserialize, 667 + Debug, 668 + Clone, 669 + PartialEq, 670 + Eq, 671 + jacquard_derive::IntoStatic 672 + )] 673 + #[serde(tag = "$type")] 674 + #[serde(bound(deserialize = "'de: 'a"))] 675 + pub enum EntryNode<'a> { 676 + #[serde(rename = "place.wisp.subfs#file")] 677 + File(Box<crate::place_wisp::subfs::File<'a>>), 678 + #[serde(rename = "place.wisp.subfs#directory")] 679 + Directory(Box<crate::place_wisp::subfs::Directory<'a>>), 680 + #[serde(rename = "place.wisp.subfs#subfs")] 681 + Subfs(Box<crate::place_wisp::subfs::Subfs<'a>>), 682 + } 683 + 684 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Entry<'a> { 685 + fn nsid() -> &'static str { 686 + "place.wisp.subfs" 687 + } 688 + fn def_name() -> &'static str { 689 + "entry" 690 + } 691 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 692 + lexicon_doc_place_wisp_subfs() 693 + } 694 + fn validate( 695 + &self, 696 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 697 + { 698 + let value = &self.name; 699 + #[allow(unused_comparisons)] 700 + if <str>::len(value.as_ref()) > 255usize { 701 + return Err(::jacquard_lexicon::validation::ConstraintError::MaxLength { 702 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 703 + "name", 704 + ), 705 + max: 255usize, 706 + actual: <str>::len(value.as_ref()), 707 + }); 708 + } 709 + } 710 + Ok(()) 711 + } 712 + } 713 + 714 + #[jacquard_derive::lexicon] 715 + #[derive( 716 + serde::Serialize, 717 + serde::Deserialize, 718 + Debug, 719 + Clone, 720 + PartialEq, 721 + Eq, 722 + jacquard_derive::IntoStatic 723 + )] 724 + #[serde(rename_all = "camelCase")] 725 + pub struct File<'a> { 726 + /// True if blob content is base64-encoded (used to bypass PDS content sniffing) 727 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 728 + pub base64: Option<bool>, 729 + /// Content blob ref 730 + #[serde(borrow)] 731 + pub blob: jacquard_common::types::blob::BlobRef<'a>, 732 + /// Content encoding (e.g., gzip for compressed files) 733 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 734 + #[serde(borrow)] 735 + pub encoding: Option<jacquard_common::CowStr<'a>>, 736 + /// Original MIME type before compression 737 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 738 + #[serde(borrow)] 739 + pub mime_type: Option<jacquard_common::CowStr<'a>>, 740 + #[serde(borrow)] 741 + pub r#type: jacquard_common::CowStr<'a>, 742 + } 743 + 744 + pub mod file_state { 745 + 746 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 747 + #[allow(unused)] 748 + use ::core::marker::PhantomData; 749 + mod sealed { 750 + pub trait Sealed {} 751 + } 752 + /// State trait tracking which required fields have been set 753 + pub trait State: sealed::Sealed { 754 + type Type; 755 + type Blob; 756 + } 757 + /// Empty state - all required fields are unset 758 + pub struct Empty(()); 759 + impl sealed::Sealed for Empty {} 760 + impl State for Empty { 761 + type Type = Unset; 762 + type Blob = Unset; 763 + } 764 + ///State transition - sets the `type` field to Set 765 + pub struct SetType<S: State = Empty>(PhantomData<fn() -> S>); 766 + impl<S: State> sealed::Sealed for SetType<S> {} 767 + impl<S: State> State for SetType<S> { 768 + type Type = Set<members::r#type>; 769 + type Blob = S::Blob; 770 + } 771 + ///State transition - sets the `blob` field to Set 772 + pub struct SetBlob<S: State = Empty>(PhantomData<fn() -> S>); 773 + impl<S: State> sealed::Sealed for SetBlob<S> {} 774 + impl<S: State> State for SetBlob<S> { 775 + type Type = S::Type; 776 + type Blob = Set<members::blob>; 777 + } 778 + /// Marker types for field names 779 + #[allow(non_camel_case_types)] 780 + pub mod members { 781 + ///Marker type for the `type` field 782 + pub struct r#type(()); 783 + ///Marker type for the `blob` field 784 + pub struct blob(()); 785 + } 786 + } 787 + 788 + /// Builder for constructing an instance of this type 789 + pub struct FileBuilder<'a, S: file_state::State> { 790 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 791 + __unsafe_private_named: ( 792 + ::core::option::Option<bool>, 793 + ::core::option::Option<jacquard_common::types::blob::BlobRef<'a>>, 794 + ::core::option::Option<jacquard_common::CowStr<'a>>, 795 + ::core::option::Option<jacquard_common::CowStr<'a>>, 796 + ::core::option::Option<jacquard_common::CowStr<'a>>, 797 + ), 798 + _phantom: ::core::marker::PhantomData<&'a ()>, 799 + } 800 + 801 + impl<'a> File<'a> { 802 + /// Create a new builder for this type 803 + pub fn new() -> FileBuilder<'a, file_state::Empty> { 804 + FileBuilder::new() 805 + } 806 + } 807 + 808 + impl<'a> FileBuilder<'a, file_state::Empty> { 809 + /// Create a new builder with all fields unset 810 + pub fn new() -> Self { 811 + FileBuilder { 812 + _phantom_state: ::core::marker::PhantomData, 813 + __unsafe_private_named: (None, None, None, None, None), 814 + _phantom: ::core::marker::PhantomData, 815 + } 816 + } 817 + } 818 + 819 + impl<'a, S: file_state::State> FileBuilder<'a, S> { 820 + /// Set the `base64` field (optional) 821 + pub fn base64(mut self, value: impl Into<Option<bool>>) -> Self { 822 + self.__unsafe_private_named.0 = value.into(); 823 + self 824 + } 825 + /// Set the `base64` field to an Option value (optional) 826 + pub fn maybe_base64(mut self, value: Option<bool>) -> Self { 827 + self.__unsafe_private_named.0 = value; 828 + self 829 + } 830 + } 831 + 832 + impl<'a, S> FileBuilder<'a, S> 833 + where 834 + S: file_state::State, 835 + S::Blob: file_state::IsUnset, 836 + { 837 + /// Set the `blob` field (required) 838 + pub fn blob( 839 + mut self, 840 + value: impl Into<jacquard_common::types::blob::BlobRef<'a>>, 841 + ) -> FileBuilder<'a, file_state::SetBlob<S>> { 842 + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); 843 + FileBuilder { 844 + _phantom_state: ::core::marker::PhantomData, 845 + __unsafe_private_named: self.__unsafe_private_named, 846 + _phantom: ::core::marker::PhantomData, 847 + } 848 + } 849 + } 850 + 851 + impl<'a, S: file_state::State> FileBuilder<'a, S> { 852 + /// Set the `encoding` field (optional) 853 + pub fn encoding( 854 + mut self, 855 + value: impl Into<Option<jacquard_common::CowStr<'a>>>, 856 + ) -> Self { 857 + self.__unsafe_private_named.2 = value.into(); 858 + self 859 + } 860 + /// Set the `encoding` field to an Option value (optional) 861 + pub fn maybe_encoding(mut self, value: Option<jacquard_common::CowStr<'a>>) -> Self { 862 + self.__unsafe_private_named.2 = value; 863 + self 864 + } 865 + } 866 + 867 + impl<'a, S: file_state::State> FileBuilder<'a, S> { 868 + /// Set the `mimeType` field (optional) 869 + pub fn mime_type( 870 + mut self, 871 + value: impl Into<Option<jacquard_common::CowStr<'a>>>, 872 + ) -> Self { 873 + self.__unsafe_private_named.3 = value.into(); 874 + self 875 + } 876 + /// Set the `mimeType` field to an Option value (optional) 877 + pub fn maybe_mime_type( 878 + mut self, 879 + value: Option<jacquard_common::CowStr<'a>>, 880 + ) -> Self { 881 + self.__unsafe_private_named.3 = value; 882 + self 883 + } 884 + } 885 + 886 + impl<'a, S> FileBuilder<'a, S> 887 + where 888 + S: file_state::State, 889 + S::Type: file_state::IsUnset, 890 + { 891 + /// Set the `type` field (required) 892 + pub fn r#type( 893 + mut self, 894 + value: impl Into<jacquard_common::CowStr<'a>>, 895 + ) -> FileBuilder<'a, file_state::SetType<S>> { 896 + self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); 897 + FileBuilder { 898 + _phantom_state: ::core::marker::PhantomData, 899 + __unsafe_private_named: self.__unsafe_private_named, 900 + _phantom: ::core::marker::PhantomData, 901 + } 902 + } 903 + } 904 + 905 + impl<'a, S> FileBuilder<'a, S> 906 + where 907 + S: file_state::State, 908 + S::Type: file_state::IsSet, 909 + S::Blob: file_state::IsSet, 910 + { 911 + /// Build the final struct 912 + pub fn build(self) -> File<'a> { 913 + File { 914 + base64: self.__unsafe_private_named.0, 915 + blob: self.__unsafe_private_named.1.unwrap(), 916 + encoding: self.__unsafe_private_named.2, 917 + mime_type: self.__unsafe_private_named.3, 918 + r#type: self.__unsafe_private_named.4.unwrap(), 919 + extra_data: Default::default(), 920 + } 921 + } 922 + /// Build the final struct with custom extra_data 923 + pub fn build_with_data( 924 + self, 925 + extra_data: std::collections::BTreeMap< 926 + jacquard_common::smol_str::SmolStr, 927 + jacquard_common::types::value::Data<'a>, 928 + >, 929 + ) -> File<'a> { 930 + File { 931 + base64: self.__unsafe_private_named.0, 932 + blob: self.__unsafe_private_named.1.unwrap(), 933 + encoding: self.__unsafe_private_named.2, 934 + mime_type: self.__unsafe_private_named.3, 935 + r#type: self.__unsafe_private_named.4.unwrap(), 936 + extra_data: Some(extra_data), 937 + } 938 + } 939 + } 940 + 941 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for File<'a> { 942 + fn nsid() -> &'static str { 943 + "place.wisp.subfs" 944 + } 945 + fn def_name() -> &'static str { 946 + "file" 947 + } 948 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 949 + lexicon_doc_place_wisp_subfs() 950 + } 951 + fn validate( 952 + &self, 953 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 954 + Ok(()) 955 + } 956 + } 957 + 958 + /// Virtual filesystem subtree referenced by place.wisp.fs records. When a subfs entry is expanded, its root entries are merged (flattened) into the parent directory, allowing large directories to be split across multiple records while maintaining a flat structure. 959 + #[jacquard_derive::lexicon] 960 + #[derive( 961 + serde::Serialize, 962 + serde::Deserialize, 963 + Debug, 964 + Clone, 965 + PartialEq, 966 + Eq, 967 + jacquard_derive::IntoStatic 968 + )] 969 + #[serde(rename_all = "camelCase")] 970 + pub struct SubfsRecord<'a> { 971 + pub created_at: jacquard_common::types::string::Datetime, 972 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 973 + pub file_count: Option<i64>, 974 + #[serde(borrow)] 975 + pub root: crate::place_wisp::subfs::Directory<'a>, 976 + } 977 + 978 + pub mod subfs_record_state { 979 + 980 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 981 + #[allow(unused)] 982 + use ::core::marker::PhantomData; 983 + mod sealed { 984 + pub trait Sealed {} 985 + } 986 + /// State trait tracking which required fields have been set 987 + pub trait State: sealed::Sealed { 988 + type Root; 989 + type CreatedAt; 990 + } 991 + /// Empty state - all required fields are unset 992 + pub struct Empty(()); 993 + impl sealed::Sealed for Empty {} 994 + impl State for Empty { 995 + type Root = Unset; 996 + type CreatedAt = Unset; 997 + } 998 + ///State transition - sets the `root` field to Set 999 + pub struct SetRoot<S: State = Empty>(PhantomData<fn() -> S>); 1000 + impl<S: State> sealed::Sealed for SetRoot<S> {} 1001 + impl<S: State> State for SetRoot<S> { 1002 + type Root = Set<members::root>; 1003 + type CreatedAt = S::CreatedAt; 1004 + } 1005 + ///State transition - sets the `created_at` field to Set 1006 + pub struct SetCreatedAt<S: State = Empty>(PhantomData<fn() -> S>); 1007 + impl<S: State> sealed::Sealed for SetCreatedAt<S> {} 1008 + impl<S: State> State for SetCreatedAt<S> { 1009 + type Root = S::Root; 1010 + type CreatedAt = Set<members::created_at>; 1011 + } 1012 + /// Marker types for field names 1013 + #[allow(non_camel_case_types)] 1014 + pub mod members { 1015 + ///Marker type for the `root` field 1016 + pub struct root(()); 1017 + ///Marker type for the `created_at` field 1018 + pub struct created_at(()); 1019 + } 1020 + } 1021 + 1022 + /// Builder for constructing an instance of this type 1023 + pub struct SubfsRecordBuilder<'a, S: subfs_record_state::State> { 1024 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 1025 + __unsafe_private_named: ( 1026 + ::core::option::Option<jacquard_common::types::string::Datetime>, 1027 + ::core::option::Option<i64>, 1028 + ::core::option::Option<crate::place_wisp::subfs::Directory<'a>>, 1029 + ), 1030 + _phantom: ::core::marker::PhantomData<&'a ()>, 1031 + } 1032 + 1033 + impl<'a> SubfsRecord<'a> { 1034 + /// Create a new builder for this type 1035 + pub fn new() -> SubfsRecordBuilder<'a, subfs_record_state::Empty> { 1036 + SubfsRecordBuilder::new() 1037 + } 1038 + } 1039 + 1040 + impl<'a> SubfsRecordBuilder<'a, subfs_record_state::Empty> { 1041 + /// Create a new builder with all fields unset 1042 + pub fn new() -> Self { 1043 + SubfsRecordBuilder { 1044 + _phantom_state: ::core::marker::PhantomData, 1045 + __unsafe_private_named: (None, None, None), 1046 + _phantom: ::core::marker::PhantomData, 1047 + } 1048 + } 1049 + } 1050 + 1051 + impl<'a, S> SubfsRecordBuilder<'a, S> 1052 + where 1053 + S: subfs_record_state::State, 1054 + S::CreatedAt: subfs_record_state::IsUnset, 1055 + { 1056 + /// Set the `createdAt` field (required) 1057 + pub fn created_at( 1058 + mut self, 1059 + value: impl Into<jacquard_common::types::string::Datetime>, 1060 + ) -> SubfsRecordBuilder<'a, subfs_record_state::SetCreatedAt<S>> { 1061 + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); 1062 + SubfsRecordBuilder { 1063 + _phantom_state: ::core::marker::PhantomData, 1064 + __unsafe_private_named: self.__unsafe_private_named, 1065 + _phantom: ::core::marker::PhantomData, 1066 + } 1067 + } 1068 + } 1069 + 1070 + impl<'a, S: subfs_record_state::State> SubfsRecordBuilder<'a, S> { 1071 + /// Set the `fileCount` field (optional) 1072 + pub fn file_count(mut self, value: impl Into<Option<i64>>) -> Self { 1073 + self.__unsafe_private_named.1 = value.into(); 1074 + self 1075 + } 1076 + /// Set the `fileCount` field to an Option value (optional) 1077 + pub fn maybe_file_count(mut self, value: Option<i64>) -> Self { 1078 + self.__unsafe_private_named.1 = value; 1079 + self 1080 + } 1081 + } 1082 + 1083 + impl<'a, S> SubfsRecordBuilder<'a, S> 1084 + where 1085 + S: subfs_record_state::State, 1086 + S::Root: subfs_record_state::IsUnset, 1087 + { 1088 + /// Set the `root` field (required) 1089 + pub fn root( 1090 + mut self, 1091 + value: impl Into<crate::place_wisp::subfs::Directory<'a>>, 1092 + ) -> SubfsRecordBuilder<'a, subfs_record_state::SetRoot<S>> { 1093 + self.__unsafe_private_named.2 = ::core::option::Option::Some(value.into()); 1094 + SubfsRecordBuilder { 1095 + _phantom_state: ::core::marker::PhantomData, 1096 + __unsafe_private_named: self.__unsafe_private_named, 1097 + _phantom: ::core::marker::PhantomData, 1098 + } 1099 + } 1100 + } 1101 + 1102 + impl<'a, S> SubfsRecordBuilder<'a, S> 1103 + where 1104 + S: subfs_record_state::State, 1105 + S::Root: subfs_record_state::IsSet, 1106 + S::CreatedAt: subfs_record_state::IsSet, 1107 + { 1108 + /// Build the final struct 1109 + pub fn build(self) -> SubfsRecord<'a> { 1110 + SubfsRecord { 1111 + created_at: self.__unsafe_private_named.0.unwrap(), 1112 + file_count: self.__unsafe_private_named.1, 1113 + root: self.__unsafe_private_named.2.unwrap(), 1114 + extra_data: Default::default(), 1115 + } 1116 + } 1117 + /// Build the final struct with custom extra_data 1118 + pub fn build_with_data( 1119 + self, 1120 + extra_data: std::collections::BTreeMap< 1121 + jacquard_common::smol_str::SmolStr, 1122 + jacquard_common::types::value::Data<'a>, 1123 + >, 1124 + ) -> SubfsRecord<'a> { 1125 + SubfsRecord { 1126 + created_at: self.__unsafe_private_named.0.unwrap(), 1127 + file_count: self.__unsafe_private_named.1, 1128 + root: self.__unsafe_private_named.2.unwrap(), 1129 + extra_data: Some(extra_data), 1130 + } 1131 + } 1132 + } 1133 + 1134 + impl<'a> SubfsRecord<'a> { 1135 + pub fn uri( 1136 + uri: impl Into<jacquard_common::CowStr<'a>>, 1137 + ) -> Result< 1138 + jacquard_common::types::uri::RecordUri<'a, SubfsRecordRecord>, 1139 + jacquard_common::types::uri::UriError, 1140 + > { 1141 + jacquard_common::types::uri::RecordUri::try_from_uri( 1142 + jacquard_common::types::string::AtUri::new_cow(uri.into())?, 1143 + ) 1144 + } 1145 + } 1146 + 1147 + /// Typed wrapper for GetRecord response with this collection's record type. 1148 + #[derive( 1149 + serde::Serialize, 1150 + serde::Deserialize, 1151 + Debug, 1152 + Clone, 1153 + PartialEq, 1154 + Eq, 1155 + jacquard_derive::IntoStatic 1156 + )] 1157 + #[serde(rename_all = "camelCase")] 1158 + pub struct SubfsRecordGetRecordOutput<'a> { 1159 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 1160 + #[serde(borrow)] 1161 + pub cid: std::option::Option<jacquard_common::types::string::Cid<'a>>, 1162 + #[serde(borrow)] 1163 + pub uri: jacquard_common::types::string::AtUri<'a>, 1164 + #[serde(borrow)] 1165 + pub value: SubfsRecord<'a>, 1166 + } 1167 + 1168 + impl From<SubfsRecordGetRecordOutput<'_>> for SubfsRecord<'_> { 1169 + fn from(output: SubfsRecordGetRecordOutput<'_>) -> Self { 1170 + use jacquard_common::IntoStatic; 1171 + output.value.into_static() 1172 + } 1173 + } 1174 + 1175 + impl jacquard_common::types::collection::Collection for SubfsRecord<'_> { 1176 + const NSID: &'static str = "place.wisp.subfs"; 1177 + type Record = SubfsRecordRecord; 1178 + } 1179 + 1180 + /// Marker type for deserializing records from this collection. 1181 + #[derive(Debug, serde::Serialize, serde::Deserialize)] 1182 + pub struct SubfsRecordRecord; 1183 + impl jacquard_common::xrpc::XrpcResp for SubfsRecordRecord { 1184 + const NSID: &'static str = "place.wisp.subfs"; 1185 + const ENCODING: &'static str = "application/json"; 1186 + type Output<'de> = SubfsRecordGetRecordOutput<'de>; 1187 + type Err<'de> = jacquard_common::types::collection::RecordError<'de>; 1188 + } 1189 + 1190 + impl jacquard_common::types::collection::Collection for SubfsRecordRecord { 1191 + const NSID: &'static str = "place.wisp.subfs"; 1192 + type Record = SubfsRecordRecord; 1193 + } 1194 + 1195 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for SubfsRecord<'a> { 1196 + fn nsid() -> &'static str { 1197 + "place.wisp.subfs" 1198 + } 1199 + fn def_name() -> &'static str { 1200 + "main" 1201 + } 1202 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 1203 + lexicon_doc_place_wisp_subfs() 1204 + } 1205 + fn validate( 1206 + &self, 1207 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 1208 + if let Some(ref value) = self.file_count { 1209 + if *value > 1000i64 { 1210 + return Err(::jacquard_lexicon::validation::ConstraintError::Maximum { 1211 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 1212 + "file_count", 1213 + ), 1214 + max: 1000i64, 1215 + actual: *value, 1216 + }); 1217 + } 1218 + } 1219 + if let Some(ref value) = self.file_count { 1220 + if *value < 0i64 { 1221 + return Err(::jacquard_lexicon::validation::ConstraintError::Minimum { 1222 + path: ::jacquard_lexicon::validation::ValidationPath::from_field( 1223 + "file_count", 1224 + ), 1225 + min: 0i64, 1226 + actual: *value, 1227 + }); 1228 + } 1229 + } 1230 + Ok(()) 1231 + } 1232 + } 1233 + 1234 + #[jacquard_derive::lexicon] 1235 + #[derive( 1236 + serde::Serialize, 1237 + serde::Deserialize, 1238 + Debug, 1239 + Clone, 1240 + PartialEq, 1241 + Eq, 1242 + jacquard_derive::IntoStatic 1243 + )] 1244 + #[serde(rename_all = "camelCase")] 1245 + pub struct Subfs<'a> { 1246 + /// AT-URI pointing to another place.wisp.subfs record for nested subtrees. When expanded, the referenced record's root entries are merged (flattened) into the parent directory, allowing recursive splitting of large directory structures. 1247 + #[serde(borrow)] 1248 + pub subject: jacquard_common::types::string::AtUri<'a>, 1249 + #[serde(borrow)] 1250 + pub r#type: jacquard_common::CowStr<'a>, 1251 + } 1252 + 1253 + pub mod subfs_state { 1254 + 1255 + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; 1256 + #[allow(unused)] 1257 + use ::core::marker::PhantomData; 1258 + mod sealed { 1259 + pub trait Sealed {} 1260 + } 1261 + /// State trait tracking which required fields have been set 1262 + pub trait State: sealed::Sealed { 1263 + type Type; 1264 + type Subject; 1265 + } 1266 + /// Empty state - all required fields are unset 1267 + pub struct Empty(()); 1268 + impl sealed::Sealed for Empty {} 1269 + impl State for Empty { 1270 + type Type = Unset; 1271 + type Subject = Unset; 1272 + } 1273 + ///State transition - sets the `type` field to Set 1274 + pub struct SetType<S: State = Empty>(PhantomData<fn() -> S>); 1275 + impl<S: State> sealed::Sealed for SetType<S> {} 1276 + impl<S: State> State for SetType<S> { 1277 + type Type = Set<members::r#type>; 1278 + type Subject = S::Subject; 1279 + } 1280 + ///State transition - sets the `subject` field to Set 1281 + pub struct SetSubject<S: State = Empty>(PhantomData<fn() -> S>); 1282 + impl<S: State> sealed::Sealed for SetSubject<S> {} 1283 + impl<S: State> State for SetSubject<S> { 1284 + type Type = S::Type; 1285 + type Subject = Set<members::subject>; 1286 + } 1287 + /// Marker types for field names 1288 + #[allow(non_camel_case_types)] 1289 + pub mod members { 1290 + ///Marker type for the `type` field 1291 + pub struct r#type(()); 1292 + ///Marker type for the `subject` field 1293 + pub struct subject(()); 1294 + } 1295 + } 1296 + 1297 + /// Builder for constructing an instance of this type 1298 + pub struct SubfsBuilder<'a, S: subfs_state::State> { 1299 + _phantom_state: ::core::marker::PhantomData<fn() -> S>, 1300 + __unsafe_private_named: ( 1301 + ::core::option::Option<jacquard_common::types::string::AtUri<'a>>, 1302 + ::core::option::Option<jacquard_common::CowStr<'a>>, 1303 + ), 1304 + _phantom: ::core::marker::PhantomData<&'a ()>, 1305 + } 1306 + 1307 + impl<'a> Subfs<'a> { 1308 + /// Create a new builder for this type 1309 + pub fn new() -> SubfsBuilder<'a, subfs_state::Empty> { 1310 + SubfsBuilder::new() 1311 + } 1312 + } 1313 + 1314 + impl<'a> SubfsBuilder<'a, subfs_state::Empty> { 1315 + /// Create a new builder with all fields unset 1316 + pub fn new() -> Self { 1317 + SubfsBuilder { 1318 + _phantom_state: ::core::marker::PhantomData, 1319 + __unsafe_private_named: (None, None), 1320 + _phantom: ::core::marker::PhantomData, 1321 + } 1322 + } 1323 + } 1324 + 1325 + impl<'a, S> SubfsBuilder<'a, S> 1326 + where 1327 + S: subfs_state::State, 1328 + S::Subject: subfs_state::IsUnset, 1329 + { 1330 + /// Set the `subject` field (required) 1331 + pub fn subject( 1332 + mut self, 1333 + value: impl Into<jacquard_common::types::string::AtUri<'a>>, 1334 + ) -> SubfsBuilder<'a, subfs_state::SetSubject<S>> { 1335 + self.__unsafe_private_named.0 = ::core::option::Option::Some(value.into()); 1336 + SubfsBuilder { 1337 + _phantom_state: ::core::marker::PhantomData, 1338 + __unsafe_private_named: self.__unsafe_private_named, 1339 + _phantom: ::core::marker::PhantomData, 1340 + } 1341 + } 1342 + } 1343 + 1344 + impl<'a, S> SubfsBuilder<'a, S> 1345 + where 1346 + S: subfs_state::State, 1347 + S::Type: subfs_state::IsUnset, 1348 + { 1349 + /// Set the `type` field (required) 1350 + pub fn r#type( 1351 + mut self, 1352 + value: impl Into<jacquard_common::CowStr<'a>>, 1353 + ) -> SubfsBuilder<'a, subfs_state::SetType<S>> { 1354 + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); 1355 + SubfsBuilder { 1356 + _phantom_state: ::core::marker::PhantomData, 1357 + __unsafe_private_named: self.__unsafe_private_named, 1358 + _phantom: ::core::marker::PhantomData, 1359 + } 1360 + } 1361 + } 1362 + 1363 + impl<'a, S> SubfsBuilder<'a, S> 1364 + where 1365 + S: subfs_state::State, 1366 + S::Type: subfs_state::IsSet, 1367 + S::Subject: subfs_state::IsSet, 1368 + { 1369 + /// Build the final struct 1370 + pub fn build(self) -> Subfs<'a> { 1371 + Subfs { 1372 + subject: self.__unsafe_private_named.0.unwrap(), 1373 + r#type: self.__unsafe_private_named.1.unwrap(), 1374 + extra_data: Default::default(), 1375 + } 1376 + } 1377 + /// Build the final struct with custom extra_data 1378 + pub fn build_with_data( 1379 + self, 1380 + extra_data: std::collections::BTreeMap< 1381 + jacquard_common::smol_str::SmolStr, 1382 + jacquard_common::types::value::Data<'a>, 1383 + >, 1384 + ) -> Subfs<'a> { 1385 + Subfs { 1386 + subject: self.__unsafe_private_named.0.unwrap(), 1387 + r#type: self.__unsafe_private_named.1.unwrap(), 1388 + extra_data: Some(extra_data), 1389 + } 1390 + } 1391 + } 1392 + 1393 + impl<'a> ::jacquard_lexicon::schema::LexiconSchema for Subfs<'a> { 1394 + fn nsid() -> &'static str { 1395 + "place.wisp.subfs" 1396 + } 1397 + fn def_name() -> &'static str { 1398 + "subfs" 1399 + } 1400 + fn lexicon_doc() -> ::jacquard_lexicon::lexicon::LexiconDoc<'static> { 1401 + lexicon_doc_place_wisp_subfs() 1402 + } 1403 + fn validate( 1404 + &self, 1405 + ) -> ::std::result::Result<(), ::jacquard_lexicon::validation::ConstraintError> { 1406 + Ok(()) 1407 + } 1408 + }
+433 -55
cli/src/pull.rs
··· 2 use crate::download; 3 use crate::metadata::SiteMetadata; 4 use crate::place_wisp::fs::*; 5 use jacquard::CowStr; 6 use jacquard::prelude::IdentityResolver; 7 use jacquard_common::types::string::Did; ··· 66 let fs_record: Fs = from_data(&record_output.value).into_diagnostic()?; 67 68 let file_count = fs_record.file_count.map(|c| c.to_string()).unwrap_or_else(|| "?".to_string()); 69 - println!("Found site '{}' with {} files", fs_record.site, file_count); 70 71 // Load existing metadata for incremental updates 72 let existing_metadata = SiteMetadata::load(&output_dir)?; ··· 75 .map(|m| m.file_cids.clone()) 76 .unwrap_or_default(); 77 78 - // Extract blob map from the new manifest 79 - let new_blob_map = blob_map::extract_blob_map(&fs_record.root); 80 let new_file_cids: HashMap<String, String> = new_blob_map 81 .iter() 82 .map(|(path, (_blob_ref, cid))| (path.clone(), cid.clone())) ··· 96 } 97 } 98 99 - // Check if we need to update (but only if output directory actually exists with files) 100 if let Some(metadata) = &existing_metadata { 101 if metadata.record_cid == record_cid { 102 - // Verify that the output directory actually exists and has content 103 - let has_content = output_dir.exists() && 104 - output_dir.read_dir() 105 - .map(|mut entries| entries.any(|e| { 106 - if let Ok(entry) = e { 107 - !entry.file_name().to_string_lossy().starts_with(".wisp-metadata") 108 - } else { 109 - false 110 } 111 - })) 112 - .unwrap_or(false); 113 - 114 - if has_content { 115 println!("Site is already up to date!"); 116 return Ok(()); 117 } 118 } 119 } ··· 133 let mut downloaded = 0; 134 let mut reused = 0; 135 136 - // Download files recursively 137 let download_result = download_directory( 138 - &fs_record.root, 139 &temp_dir, 140 &pds_url, 141 did.as_str(), ··· 218 Ok(()) 219 } 220 221 - /// Recursively download a directory 222 fn download_directory<'a>( 223 dir: &'a Directory<'_>, 224 output_dir: &'a Path, ··· 232 reused: &'a mut usize, 233 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send + 'a>> { 234 Box::pin(async move { 235 for entry in &dir.entries { 236 let entry_name = entry.name.as_str(); 237 let current_path = if path_prefix.is_empty() { ··· 245 let output_path = output_dir.join(entry_name); 246 247 // Check if file CID matches existing 248 - if let Some((_blob_ref, new_cid)) = new_blob_map.get(&current_path) { 249 if let Some(existing_cid) = existing_file_cids.get(&current_path) { 250 if existing_cid == new_cid { 251 - // File unchanged, copy from existing directory 252 let existing_path = existing_output_dir.join(&current_path); 253 if existing_path.exists() { 254 - std::fs::copy(&existing_path, &output_path).into_diagnostic()?; 255 - *reused += 1; 256 - println!(" ✓ Reused {}", current_path); 257 - continue; 258 } 259 } 260 } 261 - } 262 - 263 - // File is new or changed, download it 264 - println!(" ↓ Downloading {}", current_path); 265 - let data = download::download_and_decompress_blob( 266 - pds_url, 267 - &file.blob, 268 - did, 269 - file.base64.unwrap_or(false), 270 - file.encoding.as_ref().map(|e| e.as_str() == "gzip").unwrap_or(false), 271 - ) 272 - .await?; 273 274 - std::fs::write(&output_path, data).into_diagnostic()?; 275 - *downloaded += 1; 276 } 277 EntryNode::Directory(subdir) => { 278 let subdir_path = output_dir.join(entry_name); 279 - std::fs::create_dir_all(&subdir_path).into_diagnostic()?; 280 - 281 - download_directory( 282 - subdir, 283 - &subdir_path, 284 - pds_url, 285 - did, 286 - new_blob_map, 287 - existing_file_cids, 288 - existing_output_dir, 289 - current_path, 290 - downloaded, 291 - reused, 292 - ) 293 - .await?; 294 } 295 EntryNode::Unknown(_) => { 296 - // Skip unknown node types 297 println!(" ⚠ Skipping unknown node type for {}", current_path); 298 } 299 } 300 } 301 302 Ok(()) 303 }) 304 } 305
··· 2 use crate::download; 3 use crate::metadata::SiteMetadata; 4 use crate::place_wisp::fs::*; 5 + use crate::subfs_utils; 6 use jacquard::CowStr; 7 use jacquard::prelude::IdentityResolver; 8 use jacquard_common::types::string::Did; ··· 67 let fs_record: Fs = from_data(&record_output.value).into_diagnostic()?; 68 69 let file_count = fs_record.file_count.map(|c| c.to_string()).unwrap_or_else(|| "?".to_string()); 70 + println!("Found site '{}' with {} files (in main record)", fs_record.site, file_count); 71 + 72 + // Check for and expand subfs nodes 73 + let expanded_root = expand_subfs_in_pull(&fs_record.root, &pds_url, did.as_str()).await?; 74 + let total_file_count = subfs_utils::count_files_in_directory(&expanded_root); 75 + 76 + if total_file_count as i64 != fs_record.file_count.unwrap_or(0) { 77 + println!("Total files after expanding subfs: {}", total_file_count); 78 + } 79 80 // Load existing metadata for incremental updates 81 let existing_metadata = SiteMetadata::load(&output_dir)?; ··· 84 .map(|m| m.file_cids.clone()) 85 .unwrap_or_default(); 86 87 + // Extract blob map from the expanded manifest 88 + let new_blob_map = blob_map::extract_blob_map(&expanded_root); 89 let new_file_cids: HashMap<String, String> = new_blob_map 90 .iter() 91 .map(|(path, (_blob_ref, cid))| (path.clone(), cid.clone())) ··· 105 } 106 } 107 108 + // Check if we need to update (verify files actually exist, not just metadata) 109 if let Some(metadata) = &existing_metadata { 110 if metadata.record_cid == record_cid { 111 + // Verify that the output directory actually exists and has the expected files 112 + let has_all_files = output_dir.exists() && { 113 + // Count actual files on disk (excluding metadata) 114 + let mut actual_file_count = 0; 115 + if let Ok(entries) = std::fs::read_dir(&output_dir) { 116 + for entry in entries.flatten() { 117 + let name = entry.file_name(); 118 + if !name.to_string_lossy().starts_with(".wisp-metadata") { 119 + if entry.path().is_file() { 120 + actual_file_count += 1; 121 + } 122 } 123 + } 124 + } 125 + 126 + // Compare with expected file count from metadata 127 + let expected_count = metadata.file_cids.len(); 128 + actual_file_count > 0 && actual_file_count >= expected_count 129 + }; 130 + 131 + if has_all_files { 132 println!("Site is already up to date!"); 133 return Ok(()); 134 + } else { 135 + println!("Site metadata exists but files are missing, re-downloading..."); 136 } 137 } 138 } ··· 152 let mut downloaded = 0; 153 let mut reused = 0; 154 155 + // Download files recursively (using expanded root) 156 let download_result = download_directory( 157 + &expanded_root, 158 &temp_dir, 159 &pds_url, 160 did.as_str(), ··· 237 Ok(()) 238 } 239 240 + /// Recursively download a directory with concurrent downloads 241 fn download_directory<'a>( 242 dir: &'a Directory<'_>, 243 output_dir: &'a Path, ··· 251 reused: &'a mut usize, 252 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send + 'a>> { 253 Box::pin(async move { 254 + use futures::stream::{self, StreamExt}; 255 + 256 + // Collect download tasks and directory tasks separately 257 + struct DownloadTask { 258 + path: String, 259 + output_path: PathBuf, 260 + blob: jacquard_common::types::blob::BlobRef<'static>, 261 + base64: bool, 262 + gzip: bool, 263 + } 264 + 265 + struct CopyTask { 266 + path: String, 267 + from: PathBuf, 268 + to: PathBuf, 269 + } 270 + 271 + let mut download_tasks = Vec::new(); 272 + let mut copy_tasks = Vec::new(); 273 + let mut dir_tasks = Vec::new(); 274 + 275 for entry in &dir.entries { 276 let entry_name = entry.name.as_str(); 277 let current_path = if path_prefix.is_empty() { ··· 285 let output_path = output_dir.join(entry_name); 286 287 // Check if file CID matches existing 288 + let should_copy = if let Some((_blob_ref, new_cid)) = new_blob_map.get(&current_path) { 289 if let Some(existing_cid) = existing_file_cids.get(&current_path) { 290 if existing_cid == new_cid { 291 let existing_path = existing_output_dir.join(&current_path); 292 if existing_path.exists() { 293 + copy_tasks.push(CopyTask { 294 + path: current_path.clone(), 295 + from: existing_path, 296 + to: output_path.clone(), 297 + }); 298 + true 299 + } else { 300 + false 301 } 302 + } else { 303 + false 304 } 305 + } else { 306 + false 307 } 308 + } else { 309 + false 310 + }; 311 312 + if !should_copy { 313 + use jacquard_common::IntoStatic; 314 + // File needs to be downloaded 315 + download_tasks.push(DownloadTask { 316 + path: current_path, 317 + output_path, 318 + blob: file.blob.clone().into_static(), 319 + base64: file.base64.unwrap_or(false), 320 + gzip: file.encoding.as_ref().map(|e| e.as_str() == "gzip").unwrap_or(false), 321 + }); 322 + } 323 } 324 EntryNode::Directory(subdir) => { 325 let subdir_path = output_dir.join(entry_name); 326 + dir_tasks.push((subdir.as_ref().clone(), subdir_path, current_path)); 327 + } 328 + EntryNode::Subfs(_) => { 329 + println!(" ⚠ Skipping subfs node at {} (should have been expanded)", current_path); 330 } 331 EntryNode::Unknown(_) => { 332 println!(" ⚠ Skipping unknown node type for {}", current_path); 333 } 334 } 335 } 336 337 + // Execute copy tasks (fast, do them all) 338 + for task in copy_tasks { 339 + std::fs::copy(&task.from, &task.to).into_diagnostic()?; 340 + *reused += 1; 341 + println!(" ✓ Reused {}", task.path); 342 + } 343 + 344 + // Execute download tasks with concurrency limit (20 concurrent downloads) 345 + const DOWNLOAD_CONCURRENCY: usize = 20; 346 + 347 + let pds_url_clone = pds_url.clone(); 348 + let did_str = did.to_string(); 349 + 350 + let download_results: Vec<miette::Result<(String, PathBuf, Vec<u8>)>> = stream::iter(download_tasks) 351 + .map(|task| { 352 + let pds = pds_url_clone.clone(); 353 + let did_copy = did_str.clone(); 354 + 355 + async move { 356 + println!(" ↓ Downloading {}", task.path); 357 + let data = download::download_and_decompress_blob( 358 + &pds, 359 + &task.blob, 360 + &did_copy, 361 + task.base64, 362 + task.gzip, 363 + ) 364 + .await?; 365 + 366 + Ok::<_, miette::Report>((task.path, task.output_path, data)) 367 + } 368 + }) 369 + .buffer_unordered(DOWNLOAD_CONCURRENCY) 370 + .collect() 371 + .await; 372 + 373 + // Write downloaded files to disk 374 + for result in download_results { 375 + let (path, output_path, data) = result?; 376 + std::fs::write(&output_path, data).into_diagnostic()?; 377 + *downloaded += 1; 378 + println!(" ✓ Downloaded {}", path); 379 + } 380 + 381 + // Recursively process directories 382 + for (subdir, subdir_path, current_path) in dir_tasks { 383 + std::fs::create_dir_all(&subdir_path).into_diagnostic()?; 384 + 385 + download_directory( 386 + &subdir, 387 + &subdir_path, 388 + pds_url, 389 + did, 390 + new_blob_map, 391 + existing_file_cids, 392 + existing_output_dir, 393 + current_path, 394 + downloaded, 395 + reused, 396 + ) 397 + .await?; 398 + } 399 + 400 Ok(()) 401 }) 402 } 403 404 + /// Expand subfs nodes in a directory tree by fetching and merging subfs records (RECURSIVELY) 405 + async fn expand_subfs_in_pull<'a>( 406 + directory: &Directory<'a>, 407 + pds_url: &Url, 408 + _did: &str, 409 + ) -> miette::Result<Directory<'static>> { 410 + use crate::place_wisp::subfs::SubfsRecord; 411 + use jacquard_common::types::value::from_data; 412 + use jacquard_common::IntoStatic; 413 + 414 + // Recursively fetch ALL subfs records (including nested ones) 415 + let mut all_subfs_map: HashMap<String, crate::place_wisp::subfs::Directory> = HashMap::new(); 416 + let mut to_fetch = subfs_utils::extract_subfs_uris(directory, String::new()); 417 + 418 + if to_fetch.is_empty() { 419 + return Ok((*directory).clone().into_static()); 420 + } 421 + 422 + println!("Found {} subfs records, fetching recursively...", to_fetch.len()); 423 + let client = reqwest::Client::new(); 424 + 425 + // Keep fetching until we've resolved all subfs (including nested ones) 426 + let mut iteration = 0; 427 + const MAX_ITERATIONS: usize = 10; // Prevent infinite loops 428 + 429 + while !to_fetch.is_empty() && iteration < MAX_ITERATIONS { 430 + iteration += 1; 431 + println!(" Iteration {}: fetching {} subfs records...", iteration, to_fetch.len()); 432 + 433 + let mut fetch_tasks = Vec::new(); 434 + 435 + for (uri, path) in to_fetch.clone() { 436 + let client = client.clone(); 437 + let pds_url = pds_url.clone(); 438 + 439 + fetch_tasks.push(async move { 440 + let parts: Vec<&str> = uri.trim_start_matches("at://").split('/').collect(); 441 + if parts.len() < 3 { 442 + return Err(miette::miette!("Invalid subfs URI: {}", uri)); 443 + } 444 + 445 + let _did = parts[0]; 446 + let collection = parts[1]; 447 + let rkey = parts[2]; 448 + 449 + if collection != "place.wisp.subfs" { 450 + return Err(miette::miette!("Expected place.wisp.subfs collection, got: {}", collection)); 451 + } 452 + 453 + use jacquard::api::com_atproto::repo::get_record::GetRecord; 454 + use jacquard_common::types::string::Rkey as RkeyType; 455 + use jacquard_common::types::ident::AtIdentifier; 456 + use jacquard_common::types::string::{RecordKey, Did as DidType}; 457 + 458 + let rkey_parsed = RkeyType::new(rkey).into_diagnostic()?; 459 + let did_parsed = DidType::new(_did).into_diagnostic()?; 460 + 461 + let request = GetRecord::new() 462 + .repo(AtIdentifier::Did(did_parsed)) 463 + .collection(CowStr::from("place.wisp.subfs")) 464 + .rkey(RecordKey::from(rkey_parsed)) 465 + .build(); 466 + 467 + let response = client 468 + .xrpc(pds_url) 469 + .send(&request) 470 + .await 471 + .into_diagnostic()?; 472 + 473 + let record_output = response.into_output().into_diagnostic()?; 474 + let subfs_record: SubfsRecord = from_data(&record_output.value).into_diagnostic()?; 475 + let subfs_record_static = subfs_record.into_static(); 476 + 477 + Ok::<_, miette::Report>((path, subfs_record_static)) 478 + }); 479 + } 480 + 481 + let results: Vec<_> = futures::future::join_all(fetch_tasks).await; 482 + 483 + // Process results and find nested subfs 484 + let mut newly_fetched = Vec::new(); 485 + for result in results { 486 + match result { 487 + Ok((path, record)) => { 488 + println!(" ✓ Fetched subfs at {}", path); 489 + 490 + // Check for nested subfs in this record 491 + let nested_subfs = extract_subfs_from_subfs_dir(&record.root, path.clone()); 492 + newly_fetched.extend(nested_subfs); 493 + 494 + all_subfs_map.insert(path, record.root); 495 + } 496 + Err(e) => { 497 + eprintln!(" ⚠️ Failed to fetch subfs: {}", e); 498 + } 499 + } 500 + } 501 + 502 + // Update to_fetch with only the NEW subfs we haven't fetched yet 503 + to_fetch = newly_fetched 504 + .into_iter() 505 + .filter(|(uri, _)| !all_subfs_map.iter().any(|(k, _)| k == uri)) 506 + .collect(); 507 + } 508 + 509 + if iteration >= MAX_ITERATIONS { 510 + return Err(miette::miette!("Max iterations reached while fetching nested subfs")); 511 + } 512 + 513 + println!(" Total subfs records fetched: {}", all_subfs_map.len()); 514 + 515 + // Now replace all subfs nodes with their content 516 + Ok(replace_subfs_with_content(directory.clone(), &all_subfs_map, String::new())) 517 + } 518 + 519 + /// Extract subfs URIs from a subfs::Directory 520 + fn extract_subfs_from_subfs_dir( 521 + directory: &crate::place_wisp::subfs::Directory, 522 + current_path: String, 523 + ) -> Vec<(String, String)> { 524 + let mut uris = Vec::new(); 525 + 526 + for entry in &directory.entries { 527 + let full_path = if current_path.is_empty() { 528 + entry.name.to_string() 529 + } else { 530 + format!("{}/{}", current_path, entry.name) 531 + }; 532 + 533 + match &entry.node { 534 + crate::place_wisp::subfs::EntryNode::Subfs(subfs_node) => { 535 + uris.push((subfs_node.subject.to_string(), full_path.clone())); 536 + } 537 + crate::place_wisp::subfs::EntryNode::Directory(subdir) => { 538 + let nested = extract_subfs_from_subfs_dir(subdir, full_path); 539 + uris.extend(nested); 540 + } 541 + _ => {} 542 + } 543 + } 544 + 545 + uris 546 + } 547 + 548 + /// Recursively replace subfs nodes with their actual content 549 + fn replace_subfs_with_content( 550 + directory: Directory, 551 + subfs_map: &HashMap<String, crate::place_wisp::subfs::Directory>, 552 + current_path: String, 553 + ) -> Directory<'static> { 554 + use jacquard_common::IntoStatic; 555 + 556 + let new_entries: Vec<Entry<'static>> = directory 557 + .entries 558 + .into_iter() 559 + .flat_map(|entry| { 560 + let full_path = if current_path.is_empty() { 561 + entry.name.to_string() 562 + } else { 563 + format!("{}/{}", current_path, entry.name) 564 + }; 565 + 566 + match entry.node { 567 + EntryNode::Subfs(subfs_node) => { 568 + // Check if we have this subfs record 569 + if let Some(subfs_dir) = subfs_map.get(&full_path) { 570 + let flat = subfs_node.flat.unwrap_or(true); // Default to flat merge 571 + 572 + if flat { 573 + // Flat merge: hoist subfs entries into parent 574 + println!(" Merging subfs {} (flat)", full_path); 575 + let converted_entries: Vec<Entry<'static>> = subfs_dir 576 + .entries 577 + .iter() 578 + .map(|subfs_entry| convert_subfs_entry_to_fs(subfs_entry.clone().into_static())) 579 + .collect(); 580 + 581 + converted_entries 582 + } else { 583 + // Nested: create a directory with the subfs name 584 + println!(" Merging subfs {} (nested)", full_path); 585 + let converted_entries: Vec<Entry<'static>> = subfs_dir 586 + .entries 587 + .iter() 588 + .map(|subfs_entry| convert_subfs_entry_to_fs(subfs_entry.clone().into_static())) 589 + .collect(); 590 + 591 + vec![Entry::new() 592 + .name(entry.name.into_static()) 593 + .node(EntryNode::Directory(Box::new( 594 + Directory::new() 595 + .r#type(CowStr::from("directory")) 596 + .entries(converted_entries) 597 + .build() 598 + ))) 599 + .build()] 600 + } 601 + } else { 602 + // Subfs not found, skip with warning 603 + eprintln!(" ⚠️ Subfs not found: {}", full_path); 604 + vec![] 605 + } 606 + } 607 + EntryNode::Directory(dir) => { 608 + // Recursively process subdirectories 609 + vec![Entry::new() 610 + .name(entry.name.into_static()) 611 + .node(EntryNode::Directory(Box::new( 612 + replace_subfs_with_content(*dir, subfs_map, full_path) 613 + ))) 614 + .build()] 615 + } 616 + EntryNode::File(_) => { 617 + vec![entry.into_static()] 618 + } 619 + EntryNode::Unknown(_) => { 620 + vec![entry.into_static()] 621 + } 622 + } 623 + }) 624 + .collect(); 625 + 626 + Directory::new() 627 + .r#type(CowStr::from("directory")) 628 + .entries(new_entries) 629 + .build() 630 + } 631 + 632 + /// Convert a subfs entry to a fs entry (they have the same structure but different types) 633 + fn convert_subfs_entry_to_fs(subfs_entry: crate::place_wisp::subfs::Entry<'static>) -> Entry<'static> { 634 + use jacquard_common::IntoStatic; 635 + 636 + let node = match subfs_entry.node { 637 + crate::place_wisp::subfs::EntryNode::File(file) => { 638 + EntryNode::File(Box::new( 639 + File::new() 640 + .r#type(file.r#type.into_static()) 641 + .blob(file.blob.into_static()) 642 + .encoding(file.encoding.map(|e| e.into_static())) 643 + .mime_type(file.mime_type.map(|m| m.into_static())) 644 + .base64(file.base64) 645 + .build() 646 + )) 647 + } 648 + crate::place_wisp::subfs::EntryNode::Directory(dir) => { 649 + let converted_entries: Vec<Entry<'static>> = dir 650 + .entries 651 + .into_iter() 652 + .map(|e| convert_subfs_entry_to_fs(e.into_static())) 653 + .collect(); 654 + 655 + EntryNode::Directory(Box::new( 656 + Directory::new() 657 + .r#type(dir.r#type.into_static()) 658 + .entries(converted_entries) 659 + .build() 660 + )) 661 + } 662 + crate::place_wisp::subfs::EntryNode::Subfs(_nested_subfs) => { 663 + // Nested subfs should have been expanded already - if we get here, it means expansion failed 664 + // Treat it like a directory reference that should have been expanded 665 + eprintln!(" ⚠️ Warning: unexpanded nested subfs at path, treating as empty directory"); 666 + EntryNode::Directory(Box::new( 667 + Directory::new() 668 + .r#type(CowStr::from("directory")) 669 + .entries(vec![]) 670 + .build() 671 + )) 672 + } 673 + crate::place_wisp::subfs::EntryNode::Unknown(unknown) => { 674 + EntryNode::Unknown(unknown) 675 + } 676 + }; 677 + 678 + Entry::new() 679 + .name(subfs_entry.name.into_static()) 680 + .node(node) 681 + .build() 682 + } 683 +
+336
cli/src/subfs_utils.rs
···
··· 1 + use jacquard_common::types::string::AtUri; 2 + use jacquard_common::types::blob::BlobRef; 3 + use jacquard_common::IntoStatic; 4 + use jacquard::client::{Agent, AgentSession, AgentSessionExt}; 5 + use jacquard::prelude::IdentityResolver; 6 + use miette::IntoDiagnostic; 7 + use std::collections::HashMap; 8 + 9 + use crate::place_wisp::fs::{Directory as FsDirectory, EntryNode as FsEntryNode}; 10 + use crate::place_wisp::subfs::SubfsRecord; 11 + 12 + /// Extract all subfs URIs from a directory tree with their mount paths 13 + pub fn extract_subfs_uris(directory: &FsDirectory, current_path: String) -> Vec<(String, String)> { 14 + let mut uris = Vec::new(); 15 + 16 + for entry in &directory.entries { 17 + let full_path = if current_path.is_empty() { 18 + entry.name.to_string() 19 + } else { 20 + format!("{}/{}", current_path, entry.name) 21 + }; 22 + 23 + match &entry.node { 24 + FsEntryNode::Subfs(subfs_node) => { 25 + // Found a subfs node - store its URI and mount path 26 + uris.push((subfs_node.subject.to_string(), full_path.clone())); 27 + } 28 + FsEntryNode::Directory(subdir) => { 29 + // Recursively search subdirectories 30 + let sub_uris = extract_subfs_uris(subdir, full_path); 31 + uris.extend(sub_uris); 32 + } 33 + FsEntryNode::File(_) => { 34 + // Files don't contain subfs 35 + } 36 + FsEntryNode::Unknown(_) => { 37 + // Skip unknown nodes 38 + } 39 + } 40 + } 41 + 42 + uris 43 + } 44 + 45 + /// Fetch a subfs record from the PDS 46 + pub async fn fetch_subfs_record( 47 + agent: &Agent<impl AgentSession + IdentityResolver>, 48 + uri: &str, 49 + ) -> miette::Result<SubfsRecord<'static>> { 50 + // Parse URI: at://did/collection/rkey 51 + let parts: Vec<&str> = uri.trim_start_matches("at://").split('/').collect(); 52 + 53 + if parts.len() < 3 { 54 + return Err(miette::miette!("Invalid subfs URI: {}", uri)); 55 + } 56 + 57 + let _did = parts[0]; 58 + let collection = parts[1]; 59 + let _rkey = parts[2]; 60 + 61 + if collection != "place.wisp.subfs" { 62 + return Err(miette::miette!("Expected place.wisp.subfs collection, got: {}", collection)); 63 + } 64 + 65 + // Construct AT-URI for fetching 66 + let at_uri = AtUri::new(uri).into_diagnostic()?; 67 + 68 + // Fetch the record 69 + let response = agent.get_record::<SubfsRecord>(&at_uri).await.into_diagnostic()?; 70 + let record_output = response.into_output().into_diagnostic()?; 71 + 72 + Ok(record_output.value.into_static()) 73 + } 74 + 75 + /// Merge blob maps from subfs records into the main blob map 76 + /// Returns the total number of blobs merged from all subfs records 77 + pub async fn merge_subfs_blob_maps( 78 + agent: &Agent<impl AgentSession + IdentityResolver>, 79 + subfs_uris: Vec<(String, String)>, 80 + main_blob_map: &mut HashMap<String, (BlobRef<'static>, String)>, 81 + ) -> miette::Result<usize> { 82 + let mut total_merged = 0; 83 + 84 + println!("Fetching {} subfs records for blob reuse...", subfs_uris.len()); 85 + 86 + // Fetch all subfs records in parallel (but with some concurrency limit) 87 + use futures::stream::{self, StreamExt}; 88 + 89 + let subfs_results: Vec<_> = stream::iter(subfs_uris) 90 + .map(|(uri, mount_path)| async move { 91 + match fetch_subfs_record(agent, &uri).await { 92 + Ok(record) => Some((record, mount_path)), 93 + Err(e) => { 94 + eprintln!(" ⚠️ Failed to fetch subfs {}: {}", uri, e); 95 + None 96 + } 97 + } 98 + }) 99 + .buffer_unordered(5) 100 + .collect() 101 + .await; 102 + 103 + // Convert subfs Directory to fs Directory for blob extraction 104 + // Note: We need to extract blobs from the subfs record's root 105 + for result in subfs_results { 106 + if let Some((subfs_record, mount_path)) = result { 107 + // Extract blobs from this subfs record's root 108 + // The blob_map module works with fs::Directory, but subfs::Directory has the same structure 109 + // We need to convert or work directly with the entries 110 + 111 + let subfs_blob_map = extract_subfs_blobs(&subfs_record.root, mount_path.clone()); 112 + let count = subfs_blob_map.len(); 113 + 114 + for (path, blob_info) in subfs_blob_map { 115 + main_blob_map.insert(path, blob_info); 116 + } 117 + 118 + total_merged += count; 119 + println!(" ✓ Merged {} blobs from subfs at {}", count, mount_path); 120 + } 121 + } 122 + 123 + Ok(total_merged) 124 + } 125 + 126 + /// Extract blobs from a subfs directory (works with subfs::Directory) 127 + /// Returns a map of file paths to their blob refs and CIDs 128 + fn extract_subfs_blobs( 129 + directory: &crate::place_wisp::subfs::Directory, 130 + current_path: String, 131 + ) -> HashMap<String, (BlobRef<'static>, String)> { 132 + let mut blob_map = HashMap::new(); 133 + 134 + for entry in &directory.entries { 135 + let full_path = if current_path.is_empty() { 136 + entry.name.to_string() 137 + } else { 138 + format!("{}/{}", current_path, entry.name) 139 + }; 140 + 141 + match &entry.node { 142 + crate::place_wisp::subfs::EntryNode::File(file_node) => { 143 + let blob_ref = &file_node.blob; 144 + let cid_string = blob_ref.blob().r#ref.to_string(); 145 + blob_map.insert( 146 + full_path, 147 + (blob_ref.clone().into_static(), cid_string) 148 + ); 149 + } 150 + crate::place_wisp::subfs::EntryNode::Directory(subdir) => { 151 + let sub_map = extract_subfs_blobs(subdir, full_path); 152 + blob_map.extend(sub_map); 153 + } 154 + crate::place_wisp::subfs::EntryNode::Subfs(_nested_subfs) => { 155 + // Nested subfs - these should be resolved recursively in the main flow 156 + // For now, we skip them (they'll be fetched separately) 157 + eprintln!(" ⚠️ Found nested subfs at {}, skipping (should be fetched separately)", full_path); 158 + } 159 + crate::place_wisp::subfs::EntryNode::Unknown(_) => { 160 + // Skip unknown nodes 161 + } 162 + } 163 + } 164 + 165 + blob_map 166 + } 167 + 168 + /// Count total files in a directory tree 169 + pub fn count_files_in_directory(directory: &FsDirectory) -> usize { 170 + let mut count = 0; 171 + 172 + for entry in &directory.entries { 173 + match &entry.node { 174 + FsEntryNode::File(_) => count += 1, 175 + FsEntryNode::Directory(subdir) => { 176 + count += count_files_in_directory(subdir); 177 + } 178 + FsEntryNode::Subfs(_) => { 179 + // Subfs nodes don't count towards the main manifest file count 180 + } 181 + FsEntryNode::Unknown(_) => {} 182 + } 183 + } 184 + 185 + count 186 + } 187 + 188 + /// Estimate JSON size of a directory tree 189 + pub fn estimate_directory_size(directory: &FsDirectory) -> usize { 190 + // Serialize to JSON and measure 191 + match serde_json::to_string(directory) { 192 + Ok(json) => json.len(), 193 + Err(_) => 0, 194 + } 195 + } 196 + 197 + /// Information about a directory that could be split into a subfs record 198 + #[derive(Debug)] 199 + pub struct SplittableDirectory { 200 + pub path: String, 201 + pub directory: FsDirectory<'static>, 202 + pub size: usize, 203 + pub file_count: usize, 204 + } 205 + 206 + /// Find large directories that could be split into subfs records 207 + /// Returns directories sorted by size (largest first) 208 + pub fn find_large_directories(directory: &FsDirectory, current_path: String) -> Vec<SplittableDirectory> { 209 + let mut result = Vec::new(); 210 + 211 + for entry in &directory.entries { 212 + if let FsEntryNode::Directory(subdir) = &entry.node { 213 + let dir_path = if current_path.is_empty() { 214 + entry.name.to_string() 215 + } else { 216 + format!("{}/{}", current_path, entry.name) 217 + }; 218 + 219 + let size = estimate_directory_size(subdir); 220 + let file_count = count_files_in_directory(subdir); 221 + 222 + result.push(SplittableDirectory { 223 + path: dir_path.clone(), 224 + directory: (*subdir.clone()).into_static(), 225 + size, 226 + file_count, 227 + }); 228 + 229 + // Recursively find subdirectories 230 + let subdirs = find_large_directories(subdir, dir_path); 231 + result.extend(subdirs); 232 + } 233 + } 234 + 235 + // Sort by size (largest first) 236 + result.sort_by(|a, b| b.size.cmp(&a.size)); 237 + 238 + result 239 + } 240 + 241 + /// Replace a directory with a subfs node in the tree 242 + pub fn replace_directory_with_subfs( 243 + directory: FsDirectory<'static>, 244 + target_path: &str, 245 + subfs_uri: &str, 246 + flat: bool, 247 + ) -> miette::Result<FsDirectory<'static>> { 248 + use jacquard_common::CowStr; 249 + use crate::place_wisp::fs::{Entry, Subfs}; 250 + 251 + let path_parts: Vec<&str> = target_path.split('/').collect(); 252 + 253 + if path_parts.is_empty() { 254 + return Err(miette::miette!("Cannot replace root directory")); 255 + } 256 + 257 + // Parse the subfs URI and make it owned/'static 258 + let at_uri = AtUri::new_cow(jacquard_common::CowStr::from(subfs_uri.to_string())).into_diagnostic()?; 259 + 260 + // If this is a root-level directory 261 + if path_parts.len() == 1 { 262 + let target_name = path_parts[0]; 263 + let new_entries: Vec<Entry> = directory.entries.into_iter().map(|entry| { 264 + if entry.name == target_name { 265 + // Replace this directory with a subfs node 266 + Entry::new() 267 + .name(entry.name) 268 + .node(FsEntryNode::Subfs(Box::new( 269 + Subfs::new() 270 + .r#type(CowStr::from("subfs")) 271 + .subject(at_uri.clone()) 272 + .flat(Some(flat)) 273 + .build() 274 + ))) 275 + .build() 276 + } else { 277 + entry 278 + } 279 + }).collect(); 280 + 281 + return Ok(FsDirectory::new() 282 + .r#type(CowStr::from("directory")) 283 + .entries(new_entries) 284 + .build()); 285 + } 286 + 287 + // Recursively navigate to parent directory 288 + let first_part = path_parts[0]; 289 + let remaining_path = path_parts[1..].join("/"); 290 + 291 + let new_entries: Vec<Entry> = directory.entries.into_iter().filter_map(|entry| { 292 + if entry.name == first_part { 293 + if let FsEntryNode::Directory(subdir) = entry.node { 294 + // Recursively process this subdirectory 295 + match replace_directory_with_subfs((*subdir).into_static(), &remaining_path, subfs_uri, flat) { 296 + Ok(updated_subdir) => { 297 + Some(Entry::new() 298 + .name(entry.name) 299 + .node(FsEntryNode::Directory(Box::new(updated_subdir))) 300 + .build()) 301 + } 302 + Err(_) => None, // Skip entries that fail to update 303 + } 304 + } else { 305 + Some(entry) 306 + } 307 + } else { 308 + Some(entry) 309 + } 310 + }).collect(); 311 + 312 + Ok(FsDirectory::new() 313 + .r#type(CowStr::from("directory")) 314 + .entries(new_entries) 315 + .build()) 316 + } 317 + 318 + /// Delete a subfs record from the PDS 319 + pub async fn delete_subfs_record( 320 + agent: &Agent<impl AgentSession + IdentityResolver>, 321 + uri: &str, 322 + ) -> miette::Result<()> { 323 + use jacquard_common::types::uri::RecordUri; 324 + 325 + // Construct AT-URI and convert to RecordUri 326 + let at_uri = AtUri::new(uri).into_diagnostic()?; 327 + let record_uri: RecordUri<'_, crate::place_wisp::subfs::SubfsRecordRecord> = RecordUri::try_from_uri(at_uri).into_diagnostic()?; 328 + 329 + let rkey = record_uri.rkey() 330 + .ok_or_else(|| miette::miette!("Invalid subfs URI: missing rkey"))? 331 + .clone(); 332 + 333 + agent.delete_record::<SubfsRecord>(rkey).await.into_diagnostic()?; 334 + 335 + Ok(()) 336 + }