diff --git a/mlf-cli/src/fetch.rs b/mlf-cli/src/fetch.rs index 8cd438c..c6da523 100644 --- a/mlf-cli/src/fetch.rs +++ b/mlf-cli/src/fetch.rs @@ -293,22 +293,49 @@ async fn fetch_transitive_dependencies( } } - // Retry failed wildcards with individual 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() { - println!("\n→ Retrying failed wildcard patterns with individual NSIDs..."); + let mut still_failing: Vec<(String, Vec)> = Vec::new(); + println!("\n→ Retrying failed wildcard patterns with broader authority..."); for (failed_pattern, nsids) in wildcard_failures { - 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(()) => {} + 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: Failed to fetch {}: {}", nsid, 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); + } } } } @@ -735,3 +762,69 @@ fn extract_namespace_pattern(type_ref: &str) -> 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 { + 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); + } +} +