Something went wrong. Try again.
A human-friendly DSL for ATProto Lexicons forked from stavola.xyz/mlf
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888use crate::config::{ ConfigError, LockFile, MlfConfig, find_project_root, get_mlf_cache_dir, init_mlf_cache,};use miette::Diagnostic;use mlf_lexicon_fetcher::{ProductionLexiconFetcher, optimize_fetch_patterns};use sha2::{Digest, Sha256};use std::collections::HashSet;use thiserror::Error;
#[derive(Error, Debug, Diagnostic)]pub enum FetchError { #[error("Failed to find project root")] #[diagnostic(code(mlf::fetch::no_project_root))] NoProjectRoot(#[from] ConfigError),
#[error("Failed to create .mlf directory: {0}")] #[diagnostic(code(mlf::fetch::init_failed))] InitFailed(#[source] std::io::Error),
#[error("Failed to fetch lexicon from ATProto repo: {0}")] #[diagnostic(code(mlf::fetch::http_error))] HttpError(String),
#[error("Failed to parse lexicon JSON: {0}")] #[diagnostic(code(mlf::fetch::parse_error))] ParseError(#[from] serde_json::Error),
#[error("Failed to convert lexicon to MLF: {0}")] #[diagnostic(code(mlf::fetch::conversion_error))] ConversionError(String),
#[error("IO error: {0}")] #[diagnostic(code(mlf::fetch::io_error))] IoError(#[from] std::io::Error),
#[error("Invalid NSID format: {0}")] #[diagnostic(code(mlf::fetch::invalid_nsid))] InvalidNsid(String),}
/// Main entry point for fetch commandpub async fn run_fetch( nsid: Option<String>, save: bool, update: bool, locked: bool,) -> Result<(), FetchError> { // Validate flags if update && locked { return Err(FetchError::HttpError( "Cannot use --update and --locked together".to_string(), )); }
// Find project root let current_dir = std::env::current_dir()?; let project_root = ensure_project_root(¤t_dir)?;
match nsid { Some(namespace) => { // Fetch single namespace with transitive dependencies let lockfile_path = project_root.join("mlf-lock.toml"); let mut lockfile = LockFile::load(&lockfile_path).unwrap_or_else(|_| LockFile::new());
// Load config to check if transitive deps are enabled let config_path = project_root.join("mlf.toml"); let config = MlfConfig::load(&config_path).map_err(FetchError::NoProjectRoot)?;
fetch_lexicon_with_lock(&namespace, &project_root, &mut lockfile).await?;
// Handle transitive dependencies if enabled if config.dependencies.allow_transitive_deps { println!("\n→ Checking for transitive dependencies..."); fetch_transitive_dependencies( &project_root, &mut lockfile, config.dependencies.optimize_transitive_fetches, ) .await?; }
// Save lockfile lockfile .save(&lockfile_path) .map_err(FetchError::NoProjectRoot)?; println!("\n→ Updated mlf-lock.toml");
// Save to mlf.toml if --save flag is provided if save { save_dependency(&project_root, &namespace)?; }
Ok(()) } None => { // Fetch all dependencies from mlf.toml fetch_all_dependencies(&project_root, update, locked).await } }}
fn ensure_project_root(current_dir: &std::path::Path) -> Result<std::path::PathBuf, FetchError> { match find_project_root(current_dir) { Ok(root) => Ok(root), Err(ConfigError::NotFound) => { eprintln!("No mlf.toml found in current or parent directories."); eprintln!( "Run `mlf init` first to set up a project (it will prompt for the package NSID prefix)." ); Err(FetchError::NoProjectRoot(ConfigError::NotFound)) } Err(e) => Err(FetchError::NoProjectRoot(e)), }}
async fn fetch_all_dependencies( project_root: &std::path::Path, update: bool, locked: bool,) -> Result<(), FetchError> { // Load mlf.toml let config_path = project_root.join("mlf.toml"); let config = MlfConfig::load(&config_path).map_err(FetchError::NoProjectRoot)?;
if config.dependencies.dependencies.is_empty() { println!("No dependencies found in mlf.toml"); return Ok(()); }
let allow_transitive = config.dependencies.allow_transitive_deps;
// Load or create lockfile let lockfile_path = project_root.join("mlf-lock.toml"); let existing_lockfile = LockFile::load(&lockfile_path).map_err(FetchError::NoProjectRoot)?; let has_existing_lockfile = lockfile_path.exists() && !existing_lockfile.lexicons.is_empty();
// Handle --locked mode if locked { if !has_existing_lockfile { return Err(FetchError::HttpError( "No lockfile found. Run `mlf fetch` first to create mlf-lock.toml".to_string(), )); }
// In locked mode, we use the lockfile and verify nothing needs updating // For now, we'll just use the lockfile - verification can be enhanced later println!("Using locked dependencies from mlf-lock.toml"); return fetch_from_lockfile(project_root, &existing_lockfile).await; }
// Determine fetch mode let mode = if update { "update (ignoring lockfile)" } else if has_existing_lockfile { "lockfile" } else { "fresh" };
println!( "Fetching {} dependencies... (mode: {}, transitive deps: {})", config.dependencies.dependencies.len(), mode, if allow_transitive { "enabled" } else { "disabled" } );
// In update mode or if no lockfile, do full fetch // In normal mode with lockfile, use lockfile for cached entries let mut lockfile = if update || !has_existing_lockfile { LockFile::new() } else { existing_lockfile };
let mut errors = Vec::new(); let mut success_count = 0; let mut fetched_nsids = HashSet::new();
// Fetch initial dependencies for dep in &config.dependencies.dependencies { println!("\nFetching: {}", dep); match fetch_lexicon_with_lock(dep, project_root, &mut lockfile).await { Ok(()) => { success_count += 1; fetched_nsids.insert(dep.clone()); } Err(e) => { errors.push((dep.clone(), format!("{}", e))); } } }
// If transitive dependencies are enabled, fetch them if allow_transitive { fetch_transitive_dependencies( &project_root, &mut lockfile, config.dependencies.optimize_transitive_fetches, ) .await?; }
// Save the lockfile lockfile .save(&lockfile_path) .map_err(FetchError::NoProjectRoot)?; println!("\n→ Updated mlf-lock.toml");
if !errors.is_empty() { eprintln!( "\n{} dependency(ies) fetched successfully, {} error(s):", success_count, errors.len() ); for (dep, error) in &errors { eprintln!(" {} - {}", dep, error); } return Err(FetchError::HttpError(format!( "Failed to fetch {} dependencies", errors.len() ))); }
println!( "\n✓ Successfully fetched all {} dependencies", success_count ); Ok(())}
/// Fetch transitive dependencies by iteratively resolving unresolved referencesasync fn fetch_transitive_dependencies( project_root: &std::path::Path, lockfile: &mut LockFile, optimize_fetches: bool,) -> Result<(), FetchError> { let mut fetched_nsids = HashSet::new(); // Track NSIDs from lockfile as already fetched for nsid in lockfile.lexicons.keys() { fetched_nsids.insert(nsid.clone()); }
let mut iteration = 0; const MAX_ITERATIONS: usize = 10;
loop { iteration += 1; if iteration > MAX_ITERATIONS { eprintln!("\nWarning: Reached maximum iteration limit for transitive dependencies"); break; }
// Collect unresolved references let unresolved = match collect_unresolved_references(project_root) { Ok(refs) => refs, Err(e) => { eprintln!("\nWarning: Failed to analyze dependencies: {}", e); break; } };
// Filter out NSIDs we've already fetched or tried to fetch let new_deps: HashSet<String> = unresolved .into_iter() .filter(|nsid| !fetched_nsids.contains(nsid)) .collect();
if new_deps.is_empty() { break; }
if optimize_fetches { // Optimize the fetch patterns to reduce number of fetches let optimized_patterns = optimize_fetch_patterns(&new_deps);
println!( "\n→ Found {} unresolved reference(s), fetching {} optimized pattern(s)...", new_deps.len(), optimized_patterns.len() );
// Track which patterns are wildcards and their constituent NSIDs let mut wildcard_failures: Vec<(String, Vec<String>)> = Vec::new();
for pattern in optimized_patterns { let is_wildcard = pattern.ends_with(".*"); println!("\nFetching transitive dependency: {}", pattern); fetched_nsids.insert(pattern.clone());
match fetch_lexicon_with_lock(&pattern, project_root, lockfile).await { Ok(()) => {} Err(e) => { eprintln!(" Warning: Failed to fetch {}: {}", pattern, e);
// If this was a wildcard that failed, collect the individual NSIDs for retry if is_wildcard { let pattern_prefix = pattern.strip_suffix(".*").unwrap(); let matching_nsids: Vec<String> = new_deps .iter() .filter(|nsid| nsid.starts_with(pattern_prefix)) .cloned() .collect();
if !matching_nsids.is_empty() { wildcard_failures.push((pattern.clone(), matching_nsids)); } } } } }
// Retry failed wildcards. First try each pattern broadened by one // segment (handles 2-segment authorities like `rocksky.app` that // the 3-segment guess in `extract_namespace_pattern` misses). Only // fall through to individual NSID fetches when the broader pattern // also fails — those go through the same DNS path, so they're // unlikely to succeed if the pattern didn't, but we leave them in // place as a last resort. if !wildcard_failures.is_empty() { let mut still_failing: Vec<(String, Vec<String>)> = Vec::new();
println!("\n→ Retrying failed wildcard patterns with broader authority..."); for (failed_pattern, nsids) in wildcard_failures { match broaden_pattern(&failed_pattern) { Some(broader) if !fetched_nsids.contains(&broader) => { println!(" {} → {}", failed_pattern, broader); fetched_nsids.insert(broader.clone()); match fetch_lexicon_with_lock(&broader, project_root, lockfile).await { Ok(()) => continue, Err(e) => { eprintln!( " Warning: broader pattern {} also failed: {}", broader, e ); } } } _ => {} } still_failing.push((failed_pattern, nsids)); }
if !still_failing.is_empty() { println!( "\n→ Falling back to individual NSID fetches for patterns that couldn't be broadened..." ); for (failed_pattern, nsids) in still_failing { println!( " Retrying {} NSIDs from failed pattern: {}", nsids.len(), failed_pattern );
for nsid in nsids { if !fetched_nsids.contains(&nsid) { println!(" Fetching: {}", nsid); fetched_nsids.insert(nsid.clone());
match fetch_lexicon_with_lock(&nsid, project_root, lockfile).await { Ok(()) => {} Err(e) => { eprintln!(" Warning: Failed to fetch {}: {}", nsid, e); } } } } } } } } else { // Fetch individually without optimization (safer, more predictable) println!( "\n→ Found {} unresolved reference(s), fetching individually...", new_deps.len() );
for nsid in &new_deps { println!("\nFetching transitive dependency: {}", nsid); fetched_nsids.insert(nsid.clone());
match fetch_lexicon_with_lock(nsid, project_root, lockfile).await { Ok(()) => {} Err(e) => { // Don't fail the entire fetch for transitive deps eprintln!(" Warning: Failed to fetch {}: {}", nsid, e); } } } } }
Ok(())}
/// Fetch dependencies using the lockfile/// This refetches each lexicon from its recorded DID and verifies the checksumasync fn fetch_from_lockfile( project_root: &std::path::Path, lockfile: &LockFile,) -> Result<(), FetchError> { if lockfile.lexicons.is_empty() { println!("Lockfile is empty"); return Ok(()); }
println!( "Fetching {} lexicon(s) from lockfile...", lockfile.lexicons.len() );
let mut errors = Vec::new(); let mut success_count = 0;
// Fetch each lexicon from its DID for (nsid, locked) in &lockfile.lexicons { println!("\nRefetching: {}", nsid);
// Fetch the lexicon using the DID from lockfile match fetch_specific_lexicon(nsid, &locked.did, &locked.checksum, project_root).await { Ok(()) => { success_count += 1; } Err(e) => { errors.push((nsid.clone(), format!("{}", e))); } } }
if !errors.is_empty() { eprintln!( "\n{} lexicon(s) fetched successfully, {} error(s):", success_count, errors.len() ); for (nsid, error) in &errors { eprintln!(" {} - {}", nsid, error); } return Err(FetchError::HttpError(format!( "Failed to fetch {} lexicons", errors.len() ))); }
println!("\n✓ Successfully fetched all {} lexicons", success_count); Ok(())}
/// Fetch a specific lexicon by NSID from a known DID, verifying checksumasync fn fetch_specific_lexicon( nsid: &str, did: &str, expected_checksum: &str, project_root: &std::path::Path,) -> Result<(), FetchError> { // Initialize .mlf directory init_mlf_cache(project_root).map_err(FetchError::InitFailed)?; let mlf_dir = get_mlf_cache_dir(project_root);
// Create fetcher and fetch from known DID (bypassing DNS) let fetcher = ProductionLexiconFetcher::production() .await .map_err(|e| FetchError::HttpError(format!("Failed to create fetcher: {}", e)))?;
let result = fetcher .fetch_from_did_with_metadata(did, nsid) .await .map_err(|e| FetchError::HttpError(format!("Failed to fetch from DID: {}", e)))?;
if result.lexicons.is_empty() { return Err(FetchError::HttpError(format!( "Lexicon {} not found in repo {}", nsid, did ))); }
// We should only get one lexicon for an exact NSID match let fetched = &result.lexicons[0];
if fetched.nsid != nsid { return Err(FetchError::HttpError(format!( "Expected lexicon {}, but got {}", nsid, fetched.nsid ))); }
// Verify checksum let json_str = serde_json::to_string_pretty(&fetched.lexicon)?; let hash = calculate_hash(&json_str);
if hash != expected_checksum { return Err(FetchError::HttpError(format!( "Checksum mismatch for {}: expected {}, got {}", nsid, expected_checksum, hash ))); }
// Save JSON let mut json_path = mlf_dir.join("lexicons/json"); for segment in nsid.split('.') { json_path.push(segment); } json_path.set_extension("json");
if let Some(parent) = json_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&json_path, &json_str)?; println!(" → Saved JSON (checksum verified)");
// Convert to MLF let converted = crate::generate::mlf::generate_mlf_from_json(&fetched.lexicon) .map_err(|e| FetchError::ConversionError(format!("{:?}", e)))?; for warning in &converted.warnings { eprintln!(" ⚠ {}: {}", warning.namespace, warning.message); }
let mut mlf_path = mlf_dir.join("lexicons/mlf"); for segment in nsid.split('.') { mlf_path.push(segment); } mlf_path.set_extension("mlf");
if let Some(parent) = mlf_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&mlf_path, converted.mlf)?; println!(" → Converted to MLF");
Ok(())}
fn save_dependency(project_root: &std::path::Path, nsid: &str) -> Result<(), FetchError> { let config_path = project_root.join("mlf.toml"); let mut config = MlfConfig::load(&config_path).map_err(FetchError::NoProjectRoot)?;
if config.dependencies.dependencies.contains(&nsid.to_string()) { println!("Dependency '{}' already in mlf.toml", nsid); return Ok(()); }
config.dependencies.dependencies.push(nsid.to_string()); config .save(&config_path) .map_err(FetchError::NoProjectRoot)?;
println!("Added '{}' to dependencies in mlf.toml", nsid); Ok(())}
async fn fetch_lexicon_with_lock( nsid: &str, project_root: &std::path::Path, lockfile: &mut LockFile,) -> Result<(), FetchError> { // Initialize .mlf directory init_mlf_cache(project_root).map_err(FetchError::InitFailed)?; let mlf_dir = get_mlf_cache_dir(&project_root);
// Validate NSID format validate_nsid_format(nsid)?;
println!("Fetching lexicons for pattern: {}", nsid);
// Create the lexicon fetcher (encapsulates all DNS and HTTP logic) let fetcher = ProductionLexiconFetcher::production() .await .map_err(|e| FetchError::HttpError(format!("Failed to create fetcher: {}", e)))?;
// Fetch lexicons with metadata let result = fetcher .fetch_with_metadata(nsid) .await .map_err(|e| FetchError::HttpError(format!("Failed to fetch: {}", e)))?;
if result.lexicons.is_empty() { return Err(FetchError::HttpError(format!( "No lexicons matched pattern: {}", nsid ))); }
println!(" → Found {} lexicon record(s)", result.lexicons.len());
// Process each fetched lexicon for fetched in &result.lexicons { println!(" Processing: {}", fetched.nsid);
// Save JSON file let json_str = serde_json::to_string_pretty(&fetched.lexicon)?; let mut json_path = mlf_dir.join("lexicons/json"); for segment in fetched.nsid.split('.') { json_path.push(segment); } json_path.set_extension("json");
if let Some(parent) = json_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&json_path, &json_str)?; println!(" → Saved JSON to {}", json_path.display());
// Convert to MLF let converted = crate::generate::mlf::generate_mlf_from_json(&fetched.lexicon) .map_err(|e| FetchError::ConversionError(format!("{:?}", e)))?; for warning in &converted.warnings { eprintln!(" ⚠ {}: {}", warning.namespace, warning.message); }
// Save MLF file let mut mlf_path = mlf_dir.join("lexicons/mlf"); for segment in fetched.nsid.split('.') { mlf_path.push(segment); } mlf_path.set_extension("mlf");
if let Some(parent) = mlf_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&mlf_path, converted.mlf)?; println!(" → Converted to MLF at {}", mlf_path.display());
// Calculate hash and extract dependencies for lockfile let hash = calculate_hash(&json_str); let dependencies = extract_dependencies_from_json(&fetched.lexicon);
// Update lockfile with DID from fetcher metadata lockfile.add_lexicon( fetched.nsid.clone(), fetched.did.clone(), hash, dependencies, ); }
println!( "✓ Successfully fetched {} lexicon(s) for {}", result.lexicons.len(), nsid ); Ok(())}
fn validate_nsid_format(nsid: &str) -> Result<(), FetchError> { // Remove wildcard suffix for validation (both .* and ._) let nsid_base = nsid .strip_suffix(".*") .or_else(|| nsid.strip_suffix("._")) .unwrap_or(nsid);
let parts: Vec<&str> = nsid_base.split('.').collect();
// NSID must have at least 2 segments (authority) // e.g., "place.stream", "place.stream.key", "place.stream.*", or "place.stream._" if parts.len() < 2 { return Err(FetchError::InvalidNsid(format!( "NSID must have at least 2 segments (e.g., 'place.stream' or 'com.atproto.repo.strongRef'): {}", nsid ))); }
Ok(())}
/// Calculate SHA-256 hash of contentfn calculate_hash(content: &str) -> String { let mut hasher = Sha256::new(); hasher.update(content.as_bytes()); format!("sha256:{:x}", hasher.finalize())}
/// Extract external references from a lexicon JSON/// Returns a list of NSIDs that this lexicon depends onfn extract_dependencies_from_json(json: &serde_json::Value) -> Vec<String> { let mut deps = HashSet::new();
fn visit_value(value: &serde_json::Value, deps: &mut HashSet<String>) { match value { serde_json::Value::Object(map) => { // Check if this is a ref object if let Some(ref_val) = map.get("ref") { if let Some(ref_str) = ref_val.as_str() { // External refs are multi-segment NSIDs if ref_str.contains('.') { deps.insert(ref_str.to_string()); } } }
// Recurse into all values for val in map.values() { visit_value(val, deps); } } serde_json::Value::Array(arr) => { for val in arr { visit_value(val, deps); } } _ => {} } }
visit_value(json, &mut deps); let mut result: Vec<String> = deps.into_iter().collect(); result.sort(); result}
/// Extract external references from MLF files that need to be resolved/// Returns a set of namespace patterns (not full NSIDs) that need to be fetchedfn collect_unresolved_references( project_root: &std::path::Path,) -> Result<HashSet<String>, FetchError> { use mlf_lang::{parser, workspace::Workspace};
let mlf_dir = get_mlf_cache_dir(project_root); let mlf_lexicons_dir = mlf_dir.join("lexicons/mlf");
if !mlf_lexicons_dir.exists() { return Ok(HashSet::new()); }
// Build a workspace with std library to avoid fetching std types let mut workspace = Workspace::with_std().map_err(|e| { FetchError::IoError(std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to load standard library: {:?}", e), )) })?; let mut unresolved = HashSet::new();
// Recursively find all .mlf files fn collect_mlf_files( dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>, ) -> std::io::Result<()> { if dir.is_dir() { for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { collect_mlf_files(&path, files)?; } else if path.extension().and_then(|s| s.to_str()) == Some("mlf") { files.push(path); } } } Ok(()) }
let mut mlf_files = Vec::new(); collect_mlf_files(&mlf_lexicons_dir, &mut mlf_files)?;
// Parse each MLF file and add to workspace for mlf_file in mlf_files { let content = std::fs::read_to_string(&mlf_file)?;
// Extract namespace from file path // e.g., ".mlf/lexicons/mlf/place/stream/key.mlf" -> "place.stream.key" let relative_path = mlf_file.strip_prefix(&mlf_lexicons_dir).map_err(|_| { FetchError::IoError(std::io::Error::new( std::io::ErrorKind::Other, "Failed to compute relative path", )) })?;
let namespace = relative_path .with_extension("") .to_string_lossy() .replace(std::path::MAIN_SEPARATOR, ".");
// Parse the lexicon if let Ok(lexicon) = parser::parse_lexicon(&content) { let _ = workspace.add_module(namespace, lexicon); } }
// Resolve to find undefined references if let Err(errors) = workspace.resolve() { for error in errors.errors { if let mlf_lang::error::ValidationError::UndefinedReference { name, .. } = error { // Only collect multi-segment NSIDs (external references) // Single-segment names are likely local typos if name.contains('.') { // Convert type reference to namespace pattern // e.g., "app.bsky.actor.defs.profileViewBasic" -> "app.bsky.actor.*" // We fetch the whole namespace since we don't know which specific // lexicon file contains the type definition let namespace_pattern = extract_namespace_pattern(&name); unresolved.insert(namespace_pattern); } } } }
Ok(unresolved)}
/// Extract the namespace pattern from a type reference/// For "app.bsky.actor.defs.profileViewBasic" returns "app.bsky.actor.*"/// This handles the common ATProto pattern where defs are in a separate namespacefn extract_namespace_pattern(type_ref: &str) -> String { let parts: Vec<&str> = type_ref.split('.').collect();
// For references with 3+ segments, use the first 3 segments as the namespace // e.g., "app.bsky.actor.defs.profileViewBasic" -> "app.bsky.actor.*" // e.g., "com.atproto.repo.strongRef" -> "com.atproto.repo.*" if parts.len() >= 3 { format!("{}.{}.{}.*", parts[0], parts[1], parts[2]) } else if parts.len() == 2 { // For 2-segment refs like "place.stream", fetch everything under that authority format!("{}.*", type_ref) } else { // Single segment or empty, just return as-is (shouldn't happen) type_ref.to_string() }}
/// Given a wildcard pattern, return a pattern one segment broader, or/// `None` if already at the 2-segment floor.////// ATProto authorities may have anywhere from 2 segments upward/// (`rocksky.app`, `bsky.app`, `repo.atproto.com`, ...). The extractor/// above guesses 3 segments for any NSID with ≥3 parts, which is right/// for most traffic (`app.bsky.*`, `com.atproto.*`) but wrong when the/// real authority has only 2 segments. Example: `app.rocksky.playlist.Foo`/// → guessed pattern `app.rocksky.playlist.*` → DNS at/// `_lexicon.playlist.rocksky.app` (NXDOMAIN); the correct fallback is/// `app.rocksky.*` → DNS at `_lexicon.rocksky.app`.fn broaden_pattern(pattern: &str) -> Option<String> { let prefix = pattern.strip_suffix(".*")?; let parts: Vec<&str> = prefix.split('.').collect(); if parts.len() <= 2 { return None; } let broader_prefix = parts[..parts.len() - 1].join("."); Some(format!("{}.*", broader_prefix))}
#[cfg(test)]mod tests { use super::*;
#[test] fn extract_namespace_pattern_three_plus_segments() { assert_eq!( extract_namespace_pattern("app.bsky.actor.defs.profileViewBasic"), "app.bsky.actor.*" ); assert_eq!( extract_namespace_pattern("com.atproto.repo.strongRef"), "com.atproto.repo.*" ); }
#[test] fn extract_namespace_pattern_two_segments() { assert_eq!(extract_namespace_pattern("place.stream"), "place.stream.*"); }
#[test] fn broaden_pattern_drops_one_segment() { assert_eq!( broaden_pattern("app.rocksky.playlist.*").as_deref(), Some("app.rocksky.*") ); assert_eq!( broaden_pattern("com.atproto.repo.*").as_deref(), Some("com.atproto.*") ); }
#[test] fn broaden_pattern_floors_at_two_segments() { assert_eq!(broaden_pattern("app.rocksky.*"), None); assert_eq!(broaden_pattern("place.stream.*"), None); }
#[test] fn broaden_pattern_requires_wildcard_suffix() { assert_eq!(broaden_pattern("app.rocksky.playlist"), None); }}