Skip to main content

cargo_brief/
lib.rs

1pub mod cfg_parse;
2pub mod cli;
3pub mod code;
4pub mod cross_crate;
5pub mod examples;
6pub mod features;
7pub mod lsp;
8pub mod model;
9pub mod remote;
10pub mod render;
11pub mod resolve;
12pub mod rustdoc_json;
13pub mod search;
14pub mod summary;
15pub mod ts;
16
17/// Clean cached remote crate workspaces. Empty spec = all.
18pub fn clean_cache(spec: &str) -> anyhow::Result<()> {
19    remote::clean_cache(spec)
20}
21
22/// Run the features pipeline and return the rendered feature graph.
23pub fn run_features_pipeline(
24    args: &cli::FeaturesArgs,
25    remote: &cli::RemoteOpts,
26) -> anyhow::Result<String> {
27    use anyhow::Context;
28
29    if remote.crates {
30        let spec = &args.crate_name;
31        match remote::load_remote_feature_graph(spec)? {
32            Some(graph) => return Ok(features::render_features(&graph)),
33            None => {
34                eprintln!(
35                    "warning: feature graph unavailable for '{spec}'; \
36                     crates.io unreachable and no cached payload found"
37                );
38                anyhow::bail!("Cannot show features for '{spec}': no data available offline");
39            }
40        }
41    }
42
43    let metadata = resolve::load_cargo_metadata(args.manifest_path.as_deref())
44        .context("Failed to load cargo metadata")?;
45
46    let target = if args.crate_name == "self" {
47        metadata
48            .current_package
49            .as_deref()
50            .context("Cannot resolve 'self': no package found for current directory")?
51            .to_string()
52    } else {
53        args.crate_name.clone()
54    };
55
56    let graph = metadata
57        .feature_graphs
58        .get(&target)
59        .with_context(|| format!("No feature data found for crate '{target}'"))?;
60
61    Ok(features::render_features(graph))
62}
63
64/// Run an LSP daemon management command (touch/stop/status).
65pub fn run_lsp_command(args: &cli::LspArgs, remote: &cli::RemoteOpts) -> anyhow::Result<()> {
66    lsp::run_lsp_command(args, remote)
67}
68
69use rustdoc_json::LockfilePackages;
70use std::collections::{HashMap, HashSet};
71use std::path::{Path, PathBuf};
72
73use anyhow::{Context, Result, bail};
74use rustdoc_types::{ItemEnum, Visibility};
75
76use cli::{
77    ApiArgs, CodeArgs, ExamplesArgs, FilterArgs, RemoteOpts, SearchArgs, SummaryArgs, TsArgs,
78};
79use model::{CrateModel, ReachableInfo, compute_reachable_set};
80
81/// Result of glob re-export expansion. Contains both the item names (for Phase 1
82/// individual `pub use` lines) and the full source models (for Phase 2 inlining).
83struct GlobExpansionResult {
84    /// Phase 1 data: source crate → sorted list of public item names
85    item_names: HashMap<String, Vec<String>>,
86    /// Phase 2 data: source crate → full CrateModels (direct + recursively discovered)
87    /// Shared by both glob and named expansion — keyed by source crate name.
88    source_models: HashMap<String, Vec<CrateModel>>,
89    /// Named cross-crate re-exports: source crate → list of (item_name, full_source_path)
90    named_reexports: HashMap<String, Vec<(String, String)>>,
91}
92
93/// Shared context produced after target resolution, consumed by api/search pipelines.
94struct PipelineContext {
95    manifest_path: Option<String>,
96    target_dir: PathBuf,
97    package_name: String,
98    module_path: Option<String>,
99    /// Observer package for same-crate detection. None → always external view.
100    observer_package: Option<String>,
101    toolchain: String,
102    verbose: bool,
103    /// Skip cargo rustdoc if JSON exists. True for non-workspace-member crates.
104    use_cache: bool,
105    /// Workspace member package names. Cross-crate expansion uses `use_cache: true`
106    /// for crates NOT in this set (they're external deps, effectively immutable).
107    workspace_members: HashSet<String>,
108    /// All resolved package names/versions from Cargo.lock (for batch validation + disambiguation).
109    available_packages: LockfilePackages,
110    /// Pre-computed crate header with version + features (remote api only).
111    crate_header: Option<String>,
112    /// Holds the remote workspace alive (TempDir drops on scope exit).
113    _workspace: Option<remote::WorkspaceDir>,
114}
115
116/// Generate rustdoc JSON, parse it (bincode-cached), build CrateModel, compute visibility.
117fn generate_and_parse_model(
118    ctx: &PipelineContext,
119) -> Result<(CrateModel, bool, Option<ReachableInfo>)> {
120    if ctx.verbose {
121        eprintln!(
122            "[cargo-brief] Running cargo rustdoc for '{}'...",
123            ctx.package_name
124        );
125    }
126    let json_path = rustdoc_json::generate_rustdoc_json(
127        &ctx.package_name,
128        &ctx.toolchain,
129        ctx.manifest_path.as_deref(),
130        true, // always document private items
131        &ctx.target_dir,
132        ctx.verbose,
133        ctx.use_cache,
134    )
135    .with_context(|| format!("Failed to generate rustdoc JSON for '{}'", ctx.package_name))?;
136
137    if ctx.verbose {
138        eprintln!("[cargo-brief] Parsing rustdoc JSON...");
139    }
140    let krate = rustdoc_json::parse_rustdoc_json_cached(&json_path)
141        .with_context(|| format!("Failed to parse rustdoc JSON at '{}'", json_path.display()))?;
142    let model = CrateModel::from_crate(krate);
143
144    let same_crate = match &ctx.observer_package {
145        Some(obs) => obs == &ctx.package_name || obs.replace('-', "_") == model.crate_name(),
146        None => false,
147    };
148    let reachable = if !same_crate {
149        Some(compute_reachable_set(&model))
150    } else {
151        None
152    };
153
154    Ok((model, same_crate, reachable))
155}
156
157/// Run the API extraction pipeline and return the rendered output string.
158pub fn run_api_pipeline(args: &ApiArgs, remote: &RemoteOpts) -> Result<String> {
159    let ctx = if remote.crates {
160        let spec = &args.target.crate_name;
161        build_remote_context_api(args, spec, remote)?
162    } else {
163        build_local_context_api(args)?
164    };
165    run_shared_api_pipeline(&ctx, args)
166}
167
168fn build_local_context_api(args: &ApiArgs) -> Result<PipelineContext> {
169    if args.global.verbose {
170        eprintln!(
171            "[cargo-brief] Resolving target '{}'...",
172            args.target.crate_name
173        );
174    }
175    let metadata = resolve::load_cargo_metadata(args.target.manifest_path.as_deref())
176        .context("Failed to load cargo metadata")?;
177
178    let resolved = resolve::resolve_target(
179        &args.target.crate_name,
180        args.target.module_path.as_deref(),
181        &metadata,
182    )
183    .context("Failed to resolve target")?;
184
185    let observer_package = args
186        .target
187        .at_package
188        .clone()
189        .or(metadata.current_package.clone());
190
191    let available_packages =
192        rustdoc_json::load_lockfile_packages(args.target.manifest_path.as_deref());
193
194    let is_workspace_member = metadata.workspace_packages.contains(&resolved.package_name);
195
196    Ok(PipelineContext {
197        manifest_path: args.target.manifest_path.clone(),
198        target_dir: metadata.target_dir,
199        package_name: resolved.package_name,
200        module_path: resolved.module_path,
201        observer_package,
202        toolchain: args.global.toolchain.clone(),
203        verbose: args.global.verbose,
204        use_cache: !is_workspace_member,
205        workspace_members: metadata.workspace_packages.into_iter().collect(),
206        available_packages,
207        crate_header: None,
208        _workspace: None,
209    })
210}
211
212fn build_remote_context_api(
213    args: &ApiArgs,
214    spec: &str,
215    remote: &RemoteOpts,
216) -> Result<PipelineContext> {
217    // With -C, crate_name IS the spec. If it contains "::", split into spec + module.
218    // e.g., `bevy::ecs` → spec="bevy", module="ecs"
219    //        `tokio@1::net` → spec="tokio@1", module="net"
220    let (actual_spec, module_path) = if let Some(idx) = spec.find("::") {
221        let rest = &spec[idx + 2..];
222        let module = if rest.is_empty() {
223            None
224        } else {
225            Some(rest.to_string())
226        };
227        (&spec[..idx], module)
228    } else {
229        (spec, args.target.module_path.clone())
230    };
231
232    let (name, _) = remote::parse_crate_spec(actual_spec);
233    if args.global.verbose {
234        eprintln!("[cargo-brief] Resolving workspace for '{name}'...");
235    }
236    let (workspace, resolved_version) = remote::resolve_workspace(
237        actual_spec,
238        remote.features.as_deref(),
239        remote.no_default_features,
240        remote.no_cache,
241    )
242    .with_context(|| format!("Failed to create workspace for '{name}'"))?;
243
244    let manifest_path = workspace
245        .path()
246        .join("Cargo.toml")
247        .to_string_lossy()
248        .into_owned();
249
250    // Pre-validate -F features against the crate's feature graph.
251    if let Some(requested) = remote.features.as_deref() {
252        match remote::load_remote_feature_graph(actual_spec) {
253            Ok(Some(graph)) => features::validate_requested_features(&graph, requested)?,
254            Ok(None) => eprintln!(
255                "warning: feature graph unavailable for '{name}'; \
256                 -F values will not be validated and feature gates will not be annotated"
257            ),
258            Err(_) => {} // network failure already handled inside load_remote_feature_graph
259        }
260    }
261
262    let metadata = resolve::load_cargo_metadata(Some(&manifest_path))
263        .context("Failed to load cargo metadata for remote crate")?;
264
265    let crate_header = build_remote_crate_header(
266        &name,
267        resolved_version.as_deref(),
268        workspace.path(),
269        remote.features.as_deref(),
270    );
271
272    let available_packages = rustdoc_json::load_lockfile_packages(Some(&manifest_path));
273
274    Ok(PipelineContext {
275        manifest_path: Some(manifest_path),
276        target_dir: metadata.target_dir,
277        package_name: name,
278        module_path,
279        observer_package: None, // remote → always external view
280        toolchain: args.global.toolchain.clone(),
281        verbose: args.global.verbose,
282        use_cache: true,                   // remote — versions are locked
283        workspace_members: HashSet::new(), // remote has no workspace
284        available_packages,
285        crate_header,
286        _workspace: Some(workspace),
287    })
288}
289
290fn run_shared_api_pipeline(ctx: &PipelineContext, args: &ApiArgs) -> Result<String> {
291    let (model, same_crate, reachable) = generate_and_parse_model(ctx)?;
292    let has_cross_crate = cross_crate::root_has_cross_crate_reexports(&model);
293    if has_cross_crate {
294        pre_warm_cross_crate_json(&model, ctx);
295    }
296
297    let mut output = if let Some(ref module_path) = ctx.module_path {
298        // Module targeting — try local first, then cross-crate resolution
299        if model.find_module(module_path).is_some() {
300            render_and_expand_globs(
301                &model,
302                Some(module_path),
303                args,
304                ctx,
305                same_crate,
306                reachable.as_ref(),
307            )?
308        } else {
309            // Cross-crate module resolution
310            if ctx.verbose {
311                eprintln!(
312                    "[cargo-brief] Module '{module_path}' not found locally, trying cross-crate resolution..."
313                );
314            }
315            if let Some(resolution) = cross_crate::resolve_cross_crate_module(
316                &model,
317                module_path,
318                &ctx.toolchain,
319                ctx.manifest_path.as_deref(),
320                &ctx.target_dir,
321                ctx.verbose,
322            ) {
323                let sub_reachable = Some(compute_reachable_set(&resolution.model));
324                let mut output = render::render_module_api(
325                    &resolution.model,
326                    resolution.inner_module_path.as_deref(),
327                    args,
328                    None,
329                    false,
330                    sub_reachable.as_ref(),
331                );
332                let result = expand_glob_reexports(
333                    &resolution.model,
334                    resolution.inner_module_path.as_deref(),
335                    &ctx.toolchain,
336                    ctx.manifest_path.as_deref(),
337                    &ctx.target_dir,
338                    ctx.verbose,
339                    &ctx.workspace_members,
340                );
341                apply_glob_expansions(&mut output, &result, !args.no_expand_glob, &args.filter);
342                output
343            } else {
344                // Try leaf item resolution before falling through to error
345                let leaf_result = if let Some((parent, leaf_name)) = module_path.rsplit_once("::") {
346                    model.find_item_in_module(parent, leaf_name)
347                } else {
348                    model.find_item_in_module("", module_path)
349                };
350
351                if let Some((item_id, item)) = leaf_result {
352                    render::render_leaf_item(
353                        &model,
354                        item,
355                        item_id,
356                        args,
357                        if same_crate {
358                            args.target.at_mod.as_deref()
359                        } else {
360                            None
361                        },
362                        same_crate,
363                        reachable.as_ref(),
364                    )
365                } else {
366                    // Check if parent module exists — if so, show leaf-not-found with available items
367                    let (parent_path, leaf_name) =
368                        if let Some((p, l)) = module_path.rsplit_once("::") {
369                            (p, l)
370                        } else {
371                            ("", module_path.as_str())
372                        };
373
374                    let parent_exists = if parent_path.is_empty() {
375                        model.root_module().is_some()
376                    } else {
377                        model.find_module(parent_path).is_some()
378                    };
379
380                    if parent_exists {
381                        render::render_leaf_not_found(
382                            &model,
383                            parent_path,
384                            leaf_name,
385                            same_crate,
386                            reachable.as_ref(),
387                        )
388                    } else {
389                        // Fall through to normal render (produces "module not found" error)
390                        render_and_expand_globs(
391                            &model,
392                            Some(module_path),
393                            args,
394                            ctx,
395                            same_crate,
396                            reachable.as_ref(),
397                        )?
398                    }
399                }
400            }
401        }
402    } else if args.recursive && has_cross_crate {
403        // Recursive mode with cross-crate expansion via accessible-path index
404        let mut output =
405            render_and_expand_globs(&model, None, args, ctx, same_crate, reachable.as_ref())?;
406        if ctx.verbose {
407            eprintln!("[cargo-brief] Building cross-crate accessible path index...");
408        }
409        let index = cross_crate::build_cross_crate_index(
410            &model,
411            &ctx.toolchain,
412            ctx.manifest_path.as_deref(),
413            &ctx.target_dir,
414            ctx.verbose,
415            &ctx.workspace_members,
416            &ctx.available_packages,
417        );
418        let cross_output = render::render_cross_crate_api(&index, model.crate_name(), args);
419        if !cross_output.is_empty() {
420            output.push_str(&cross_output);
421        }
422        output
423    } else {
424        // Normal mode
425        render_and_expand_globs(
426            &model,
427            ctx.module_path.as_deref(),
428            args,
429            ctx,
430            same_crate,
431            reachable.as_ref(),
432        )?
433    };
434
435    // Enrich header with version + features if available
436    if let Some(header) = &ctx.crate_header
437        && let Some(first_newline) = output.find('\n')
438    {
439        let first_line = &output[..first_newline];
440        if first_line.starts_with("// crate ") {
441            output.replace_range(..first_newline, header);
442        }
443    }
444
445    Ok(output)
446}
447
448/// Render module API + expand globs.
449fn render_and_expand_globs(
450    model: &CrateModel,
451    module_path: Option<&str>,
452    args: &ApiArgs,
453    ctx: &PipelineContext,
454    same_crate: bool,
455    reachable: Option<&ReachableInfo>,
456) -> Result<String> {
457    let mut output = render::render_module_api(
458        model,
459        module_path,
460        args,
461        if same_crate {
462            args.target.at_mod.as_deref()
463        } else {
464            None
465        },
466        same_crate,
467        reachable,
468    );
469    let result = expand_glob_reexports(
470        model,
471        module_path,
472        &ctx.toolchain,
473        ctx.manifest_path.as_deref(),
474        &ctx.target_dir,
475        ctx.verbose,
476        &ctx.workspace_members,
477    );
478    apply_glob_expansions(&mut output, &result, !args.no_expand_glob, &args.filter);
479    Ok(output)
480}
481
482/// Run the search pipeline and return the rendered output string.
483pub fn run_search_pipeline(args: &SearchArgs, remote: &RemoteOpts) -> Result<String> {
484    // Validate: need either a pattern or one of the search-narrowing flags
485    if args.patterns.is_empty()
486        && args.methods_of.is_none()
487        && args.in_params.is_none()
488        && args.in_returns.is_none()
489    {
490        anyhow::bail!("search requires a pattern, --methods-of, --in-params, or --in-returns");
491    }
492
493    let mut args = args.clone();
494
495    // --methods-of: synthesize a name pattern when none given; narrows walk to functions only.
496    // methods_of stays set — run_shared_search_pipeline uses it for exact parent matching.
497    if let Some(methods_of) = &args.methods_of {
498        if args.patterns.is_empty() {
499            args.patterns = vec![methods_of.clone()];
500        }
501        apply_function_narrowing(&mut args.filter);
502    }
503
504    // --in-params / --in-returns: narrows walk to functions only; type filter applied in render.
505    if args.in_params.is_some() || args.in_returns.is_some() {
506        apply_function_narrowing(&mut args.filter);
507    }
508
509    let ctx = if remote.crates {
510        build_remote_context_search(&args, &args.crate_name, remote)?
511    } else {
512        build_local_context_search(&args)?
513    };
514    run_shared_search_pipeline(&ctx, &args)
515}
516
517/// Set the filter flags that restrict the item walk to functions and methods only.
518fn apply_function_narrowing(filter: &mut FilterArgs) {
519    filter.no_structs = true;
520    filter.no_enums = true;
521    filter.no_traits = true;
522    filter.no_unions = true;
523    filter.no_constants = true;
524    filter.no_macros = true;
525    filter.no_aliases = true;
526}
527
528fn build_local_context_search(args: &SearchArgs) -> Result<PipelineContext> {
529    if args.global.verbose {
530        eprintln!("[cargo-brief] Resolving target '{}'...", args.crate_name);
531    }
532    let metadata = resolve::load_cargo_metadata(args.manifest_path.as_deref())
533        .context("Failed to load cargo metadata")?;
534
535    let resolved = resolve::resolve_target(&args.crate_name, None, &metadata)
536        .context("Failed to resolve target")?;
537
538    let observer_package = args.at_package.clone().or(metadata.current_package.clone());
539
540    let available_packages = rustdoc_json::load_lockfile_packages(args.manifest_path.as_deref());
541    let is_workspace_member = metadata.workspace_packages.contains(&resolved.package_name);
542
543    Ok(PipelineContext {
544        manifest_path: args.manifest_path.clone(),
545        target_dir: metadata.target_dir,
546        package_name: resolved.package_name,
547        module_path: None, // search doesn't target modules
548        observer_package,
549        toolchain: args.global.toolchain.clone(),
550        verbose: args.global.verbose,
551        use_cache: !is_workspace_member,
552        workspace_members: metadata.workspace_packages.into_iter().collect(),
553        available_packages,
554        crate_header: None,
555        _workspace: None,
556    })
557}
558
559fn build_remote_context_search(
560    args: &SearchArgs,
561    spec: &str,
562    remote: &RemoteOpts,
563) -> Result<PipelineContext> {
564    let (name, _) = remote::parse_crate_spec(spec);
565    if args.global.verbose {
566        eprintln!("[cargo-brief] Resolving workspace for '{name}'...");
567    }
568    let (workspace, _resolved_version) = remote::resolve_workspace(
569        spec,
570        remote.features.as_deref(),
571        remote.no_default_features,
572        remote.no_cache,
573    )
574    .with_context(|| format!("Failed to create workspace for '{name}'"))?;
575
576    // Pre-validate -F features against the crate's feature graph.
577    if let Some(requested) = remote.features.as_deref() {
578        match remote::load_remote_feature_graph(spec) {
579            Ok(Some(graph)) => features::validate_requested_features(&graph, requested)?,
580            Ok(None) => eprintln!(
581                "warning: feature graph unavailable for '{name}'; \
582                 -F values will not be validated and feature gates will not be annotated"
583            ),
584            Err(_) => {}
585        }
586    }
587
588    let manifest_path = workspace
589        .path()
590        .join("Cargo.toml")
591        .to_string_lossy()
592        .into_owned();
593
594    let metadata = resolve::load_cargo_metadata(Some(&manifest_path))
595        .context("Failed to load cargo metadata for remote crate")?;
596
597    let available_packages = rustdoc_json::load_lockfile_packages(Some(&manifest_path));
598
599    Ok(PipelineContext {
600        manifest_path: Some(manifest_path),
601        target_dir: metadata.target_dir,
602        package_name: name,
603        module_path: None,      // search doesn't target modules
604        observer_package: None, // remote → always external view
605        toolchain: args.global.toolchain.clone(),
606        verbose: args.global.verbose,
607        use_cache: true, // remote — versions are locked
608        workspace_members: HashSet::new(),
609        available_packages,
610        crate_header: None,
611        _workspace: Some(workspace),
612    })
613}
614
615fn run_shared_search_pipeline(ctx: &PipelineContext, args: &SearchArgs) -> Result<String> {
616    let (model, same_crate, reachable) = generate_and_parse_model(ctx)?;
617    let pattern = args.pattern();
618    let methods_of = args.methods_of.as_deref();
619    let in_params = args.in_params.as_deref();
620    let in_returns = args.in_returns.as_deref();
621
622    let search_kind = args.search_kind.as_deref();
623    let members = args.members;
624    let search_fn = |model: &CrateModel,
625                     observer: Option<&str>,
626                     same_crate: bool,
627                     reachable: Option<&ReachableInfo>,
628                     header_total: Option<usize>| {
629        search::render_search_filtered_counted(
630            model,
631            &pattern,
632            &args.filter,
633            args.limit.as_deref(),
634            observer,
635            same_crate,
636            reachable,
637            methods_of,
638            search_kind,
639            members,
640            in_params,
641            in_returns,
642            header_total,
643        )
644    };
645
646    let local_output = search_fn(
647        &model,
648        if same_crate {
649            args.at_mod.as_deref()
650        } else {
651            None
652        },
653        same_crate,
654        reachable.as_ref(),
655        None,
656    );
657    let mut output = local_output.output;
658
659    // Cross-crate search: build unified index, search with accessible paths
660    if cross_crate::root_has_cross_crate_reexports(&model) {
661        pre_warm_cross_crate_json(&model, ctx);
662        if ctx.verbose {
663            eprintln!("[cargo-brief] Building cross-crate accessible path index...");
664        }
665        let index = cross_crate::build_cross_crate_index(
666            &model,
667            &ctx.toolchain,
668            ctx.manifest_path.as_deref(),
669            &ctx.target_dir,
670            ctx.verbose,
671            &ctx.workspace_members,
672            &ctx.available_packages,
673        );
674        let cross_output = search::search_cross_crate_index_counted(
675            &index,
676            model.crate_name(),
677            &pattern,
678            &args.filter,
679            args.limit.as_deref(),
680            search_kind,
681            methods_of,
682            members,
683            in_params,
684            in_returns,
685        );
686        if cross_output.total > 0 {
687            output = search_fn(
688                &model,
689                if same_crate {
690                    args.at_mod.as_deref()
691                } else {
692                    None
693                },
694                same_crate,
695                reachable.as_ref(),
696                Some(local_output.total + cross_output.total),
697            )
698            .output;
699        }
700        if !cross_output.output.is_empty() {
701            output.push_str(&cross_output.output);
702        }
703    }
704
705    Ok(output)
706}
707
708/// Run the examples pipeline and return the rendered output string.
709pub fn run_examples_pipeline(args: &ExamplesArgs, remote: &RemoteOpts) -> Result<String> {
710    if remote.crates {
711        // Remote path — crate_name IS the spec
712        let spec = &args.crate_name;
713        let (name, _) = remote::parse_crate_spec(spec);
714        if args.global.verbose {
715            eprintln!("[cargo-brief] Resolving workspace for '{name}'...");
716        }
717        let (workspace, resolved_version) = remote::resolve_workspace(
718            spec,
719            remote.features.as_deref(),
720            remote.no_default_features,
721            remote.no_cache,
722        )
723        .with_context(|| format!("Failed to create workspace for '{name}'"))?;
724
725        let manifest_path = workspace
726            .path()
727            .join("Cargo.toml")
728            .to_string_lossy()
729            .into_owned();
730
731        if args.global.verbose {
732            eprintln!("[cargo-brief] Finding source root for '{name}'...");
733        }
734        let source_root = resolve::find_dep_source_root(&manifest_path, &name)
735            .with_context(|| format!("Failed to find source root for '{name}'"))?;
736
737        let version =
738            resolved_version.or_else(|| remote::resolve_crate_version(workspace.path(), &name));
739        let crate_display = match version {
740            Some(v) => format!("{name}[{v}]"),
741            None => name.clone(),
742        };
743
744        Ok(examples::render_examples(
745            &source_root,
746            &crate_display,
747            args,
748        ))
749    } else {
750        // Local path
751        let metadata = resolve::load_cargo_metadata(args.manifest_path.as_deref())
752            .context("Failed to load cargo metadata")?;
753
754        let (pkg_name, source_root) = if args.crate_name == "self" {
755            let pkg = metadata.current_package.as_ref().ok_or_else(|| {
756                anyhow::anyhow!(
757                    "Cannot resolve 'self': no package found for the current directory."
758                )
759            })?;
760            let dir = metadata
761                .package_manifest_dirs
762                .get(pkg)
763                .cloned()
764                .or(metadata.current_package_manifest_dir.clone())
765                .ok_or_else(|| {
766                    anyhow::anyhow!("Cannot find manifest directory for package '{pkg}'")
767                })?;
768            (pkg.clone(), dir)
769        } else {
770            // Look up named package in workspace
771            let normalized = args.crate_name.replace('-', "_");
772            let found = metadata
773                .package_manifest_dirs
774                .iter()
775                .find(|(k, _)| k.replace('-', "_") == normalized);
776            match found {
777                Some((name, dir)) => (name.clone(), dir.clone()),
778                None => {
779                    anyhow::bail!(
780                        "Package '{}' not found in workspace. Available: {}",
781                        args.crate_name,
782                        metadata.workspace_packages.join(", ")
783                    );
784                }
785            }
786        };
787
788        if args.global.verbose {
789            eprintln!("[cargo-brief] Scanning examples for '{pkg_name}'...");
790        }
791
792        Ok(examples::render_examples(&source_root, &pkg_name, args))
793    }
794}
795
796/// Run the tree-sitter query pipeline and return the rendered output string.
797pub fn run_ts_pipeline(args: &TsArgs, remote: &RemoteOpts) -> Result<String> {
798    if remote.crates {
799        let spec = &args.crate_name;
800        let (name, _) = remote::parse_crate_spec(spec);
801        if args.global.verbose {
802            eprintln!("[cargo-brief] Resolving workspace for '{name}'...");
803        }
804        let (workspace, _resolved_version) = remote::resolve_workspace(
805            spec,
806            remote.features.as_deref(),
807            remote.no_default_features,
808            remote.no_cache,
809        )
810        .with_context(|| format!("Failed to create workspace for '{name}'"))?;
811
812        let manifest_path = workspace
813            .path()
814            .join("Cargo.toml")
815            .to_string_lossy()
816            .into_owned();
817
818        if args.global.verbose {
819            eprintln!("[cargo-brief] Finding source root for '{name}'...");
820        }
821        let source_root = resolve::find_dep_source_root(&manifest_path, &name)
822            .with_context(|| format!("Failed to find source root for '{name}'"))?;
823
824        if args.global.verbose {
825            eprintln!("[cargo-brief] Running tree-sitter query on '{name}'...");
826        }
827
828        ts::run_query(&source_root, &args.query, args)
829    } else {
830        let metadata = resolve::load_cargo_metadata(args.manifest_path.as_deref())
831            .context("Failed to load cargo metadata")?;
832
833        let (_pkg_name, source_root) = if args.crate_name == "self" {
834            let pkg = metadata.current_package.as_ref().ok_or_else(|| {
835                anyhow::anyhow!(
836                    "Cannot resolve 'self': no package found for the current directory."
837                )
838            })?;
839            let dir = metadata
840                .package_manifest_dirs
841                .get(pkg)
842                .cloned()
843                .or(metadata.current_package_manifest_dir.clone())
844                .ok_or_else(|| {
845                    anyhow::anyhow!("Cannot find manifest directory for package '{pkg}'")
846                })?;
847            (pkg.clone(), dir)
848        } else {
849            let normalized = args.crate_name.replace('-', "_");
850            let found = metadata
851                .package_manifest_dirs
852                .iter()
853                .find(|(k, _)| k.replace('-', "_") == normalized);
854            match found {
855                Some((name, dir)) => (name.clone(), dir.clone()),
856                None => {
857                    anyhow::bail!(
858                        "Package '{}' not found in workspace. Available: {}",
859                        args.crate_name,
860                        metadata.workspace_packages.join(", ")
861                    );
862                }
863            }
864        };
865
866        if args.global.verbose {
867            eprintln!(
868                "[cargo-brief] Running tree-sitter query on '{}'...",
869                args.crate_name
870            );
871        }
872
873        ts::run_query(&source_root, &args.query, args)
874    }
875}
876
877/// Run the code lookup pipeline and return the rendered output string.
878pub fn run_code_pipeline(args: &CodeArgs, remote: &RemoteOpts) -> Result<String> {
879    let resolved = code::resolve_code_args(args)?;
880
881    // Phase A — Resolve target into primary sources + dep-resolution info
882    struct CodeTarget {
883        /// Primary source roots to search (pkg_name, dir).
884        primary_sources: Vec<(String, PathBuf)>,
885        effective_manifest: String,
886        target_dir: PathBuf,
887        is_workspace_member: bool,
888        /// Root package name for dep resolution.
889        dep_root_pkg: String,
890        _workspace: Option<remote::WorkspaceDir>,
891    }
892
893    let target = if remote.crates {
894        // Remote mode — "self" is invalid
895        if resolved.target == "self" {
896            bail!("-C (remote) mode requires an explicit crate spec as TARGET");
897        }
898        let spec = &resolved.target;
899        let (crate_name, _) = remote::parse_crate_spec(spec);
900        if args.global.verbose {
901            eprintln!("[cargo-brief] Resolving workspace for '{crate_name}'...");
902        }
903        let (workspace, _resolved_version) = remote::resolve_workspace(
904            spec,
905            remote.features.as_deref(),
906            remote.no_default_features,
907            remote.no_cache,
908        )
909        .with_context(|| format!("Failed to create workspace for '{crate_name}'"))?;
910
911        let manifest_path = workspace
912            .path()
913            .join("Cargo.toml")
914            .to_string_lossy()
915            .into_owned();
916
917        if args.global.verbose {
918            eprintln!("[cargo-brief] Finding source root for '{crate_name}'...");
919        }
920        let source_root = resolve::find_dep_source_root(&manifest_path, &crate_name)
921            .with_context(|| format!("Failed to find source root for '{crate_name}'"))?;
922
923        // For remote crates, target_dir is needed only when searching deps
924        let target_dir = if !args.no_deps {
925            let meta = resolve::load_cargo_metadata(Some(&manifest_path))
926                .context("Failed to load cargo metadata for remote crate")?;
927            meta.target_dir
928        } else {
929            PathBuf::new()
930        };
931
932        CodeTarget {
933            primary_sources: vec![(crate_name.to_string(), source_root)],
934            effective_manifest: manifest_path,
935            target_dir,
936            is_workspace_member: false,
937            dep_root_pkg: crate_name.to_string(),
938            _workspace: Some(workspace),
939        }
940    } else {
941        let metadata = resolve::load_cargo_metadata(args.manifest_path.as_deref())
942            .context("Failed to load cargo metadata")?;
943
944        if resolved.target == "self" {
945            // "self" → all workspace members
946            let mut primary_sources = Vec::new();
947            for pkg in &metadata.workspace_packages {
948                if let Some(dir) = metadata.package_manifest_dirs.get(pkg) {
949                    primary_sources.push((pkg.clone(), dir.clone()));
950                }
951            }
952            if primary_sources.is_empty() {
953                bail!("No workspace packages found. Run from inside a Cargo project.");
954            }
955
956            let effective_manifest = args
957                .manifest_path
958                .clone()
959                .unwrap_or_else(|| "Cargo.toml".to_string());
960
961            let dep_root_pkg = metadata
962                .current_package
963                .clone()
964                .or_else(|| metadata.workspace_packages.first().cloned())
965                .unwrap_or_default();
966
967            CodeTarget {
968                primary_sources,
969                effective_manifest,
970                target_dir: metadata.target_dir,
971                is_workspace_member: true,
972                dep_root_pkg,
973                _workspace: None,
974            }
975        } else {
976            // Named target — single crate
977            let normalized = resolved.target.replace('-', "_");
978            let found = metadata
979                .package_manifest_dirs
980                .iter()
981                .find(|(k, _)| k.replace('-', "_") == normalized);
982            let (pkg_name, source_root) = match found {
983                Some((name, dir)) => (name.clone(), dir.clone()),
984                None => {
985                    anyhow::bail!(
986                        "Package '{}' not found in workspace. Available: {}",
987                        resolved.target,
988                        metadata.workspace_packages.join(", ")
989                    );
990                }
991            };
992
993            let effective_manifest = metadata
994                .package_manifest_dirs
995                .get(&pkg_name)
996                .map(|d| d.join("Cargo.toml").to_string_lossy().into_owned())
997                .or_else(|| args.manifest_path.clone())
998                .unwrap_or_else(|| "Cargo.toml".to_string());
999
1000            CodeTarget {
1001                primary_sources: vec![(pkg_name.clone(), source_root)],
1002                effective_manifest,
1003                target_dir: metadata.target_dir,
1004                is_workspace_member: true,
1005                dep_root_pkg: pkg_name,
1006                _workspace: None,
1007            }
1008        }
1009    };
1010
1011    if args.global.verbose {
1012        let names: Vec<&str> = target
1013            .primary_sources
1014            .iter()
1015            .map(|(n, _)| n.as_str())
1016            .collect();
1017        eprintln!(
1018            "[cargo-brief] Searching {} for code definitions...",
1019            names.join(", ")
1020        );
1021    }
1022
1023    // Phase B — Collect dep sources
1024    let mut sources = target.primary_sources.clone();
1025
1026    if !args.no_deps {
1027        let dep_sources = if args.all_deps {
1028            collect_all_deps_sources(&target.effective_manifest, &target.dep_root_pkg)?
1029        } else {
1030            collect_accessible_deps_sources(
1031                &target.dep_root_pkg,
1032                &target.effective_manifest,
1033                &target.target_dir,
1034                &args.global.toolchain,
1035                args.global.verbose,
1036                !target.is_workspace_member,
1037            )?
1038        };
1039        // Filter out deps that are already in primary sources
1040        let primary_names: std::collections::HashSet<&str> = target
1041            .primary_sources
1042            .iter()
1043            .map(|(n, _)| n.as_str())
1044            .collect();
1045        sources.extend(
1046            dep_sources
1047                .into_iter()
1048                .filter(|(n, _)| !primary_names.contains(n.as_str())),
1049        );
1050        if args.global.verbose {
1051            eprintln!("[cargo-brief] Searching {} crate(s)...", sources.len());
1052        }
1053    }
1054
1055    // Phase C — Search
1056    let mut output = String::new();
1057
1058    // Definitions (unless --refs-only)
1059    if !args.refs_only {
1060        output = code::search_code(
1061            &sources,
1062            &resolved.name,
1063            resolved.kind,
1064            args,
1065            args.in_type.as_deref(),
1066        )?;
1067    }
1068
1069    // References (if --refs or --refs-only)
1070    if args.refs || args.refs_only {
1071        let ref_limit = if args.refs_only {
1072            args.limit.as_deref()
1073        } else {
1074            None
1075        };
1076        let refs = code::search_references(
1077            &sources,
1078            &resolved.name,
1079            args.src_only,
1080            args.quiet,
1081            ref_limit,
1082        );
1083        if !refs.is_empty() && !refs.starts_with("// no references") {
1084            if !output.is_empty() {
1085                output.push_str("\n// --- References ---\n\n");
1086            }
1087            output.push_str(&refs);
1088        } else if args.refs_only {
1089            output = refs;
1090        }
1091    }
1092
1093    Ok(output)
1094}
1095
1096/// Collect source dirs for all direct dependencies via cargo metadata.
1097fn collect_all_deps_sources(
1098    manifest_path: &str,
1099    root_package: &str,
1100) -> Result<Vec<(String, PathBuf)>> {
1101    let (all_dirs, direct_deps) =
1102        resolve::load_dep_package_dirs(Some(manifest_path), root_package)?;
1103
1104    let mut result = Vec::new();
1105    for dep_name in &direct_deps {
1106        if let Some(dir) = all_dirs.get(dep_name) {
1107            result.push((dep_name.clone(), dir.clone()));
1108        }
1109    }
1110    Ok(result)
1111}
1112
1113/// Collect source dirs for accessible dependencies via rustdoc JSON BFS.
1114fn collect_accessible_deps_sources(
1115    pkg_name: &str,
1116    manifest_path: &str,
1117    target_dir: &Path,
1118    toolchain: &str,
1119    verbose: bool,
1120    use_cache: bool,
1121) -> Result<Vec<(String, PathBuf)>> {
1122    // Generate rustdoc JSON for the target crate
1123    let json_path = rustdoc_json::generate_rustdoc_json(
1124        pkg_name,
1125        toolchain,
1126        Some(manifest_path),
1127        true, // document_private_items
1128        target_dir,
1129        verbose,
1130        use_cache,
1131    )
1132    .with_context(|| format!("Failed to generate rustdoc JSON for '{pkg_name}'"))?;
1133
1134    let krate = rustdoc_json::parse_rustdoc_json_cached(&json_path)
1135        .with_context(|| format!("Failed to parse rustdoc JSON for '{pkg_name}'"))?;
1136
1137    let model = CrateModel::from_crate(krate);
1138
1139    // BFS discover accessible deps
1140    let accessible =
1141        discover_accessible_deps(&model, toolchain, Some(manifest_path), target_dir, verbose);
1142
1143    if accessible.is_empty() {
1144        return Ok(Vec::new());
1145    }
1146
1147    // Map accessible dep names to source directories
1148    let (all_dirs, _) = resolve::load_dep_package_dirs(Some(manifest_path), pkg_name)?;
1149
1150    let mut result = Vec::new();
1151    for dep_name in &accessible {
1152        let base = dep_name.split('@').next().unwrap_or(dep_name);
1153        if let Some(dir) = all_dirs.get(base) {
1154            result.push((base.to_string(), dir.clone()));
1155        } else {
1156            // Try hyphen/underscore normalization
1157            let alt = base.replace('_', "-");
1158            if let Some(dir) = all_dirs.get(&alt) {
1159                result.push((alt, dir.clone()));
1160            }
1161        }
1162    }
1163    Ok(result)
1164}
1165
1166/// Run the summary pipeline and return the rendered output string.
1167pub fn run_summary_pipeline(args: &SummaryArgs, remote: &RemoteOpts) -> Result<String> {
1168    let ctx = if remote.crates {
1169        let spec = &args.target.crate_name;
1170        build_remote_context_summary(args, spec, remote)?
1171    } else {
1172        build_local_context_summary(args)?
1173    };
1174    run_shared_summary_pipeline(&ctx)
1175}
1176
1177fn build_local_context_summary(args: &SummaryArgs) -> Result<PipelineContext> {
1178    if args.global.verbose {
1179        eprintln!(
1180            "[cargo-brief] Resolving target '{}'...",
1181            args.target.crate_name
1182        );
1183    }
1184    let metadata = resolve::load_cargo_metadata(args.target.manifest_path.as_deref())
1185        .context("Failed to load cargo metadata")?;
1186
1187    let resolved = resolve::resolve_target(
1188        &args.target.crate_name,
1189        args.target.module_path.as_deref(),
1190        &metadata,
1191    )
1192    .context("Failed to resolve target")?;
1193
1194    let observer_package = args
1195        .target
1196        .at_package
1197        .clone()
1198        .or(metadata.current_package.clone());
1199
1200    let available_packages =
1201        rustdoc_json::load_lockfile_packages(args.target.manifest_path.as_deref());
1202    let is_workspace_member = metadata.workspace_packages.contains(&resolved.package_name);
1203
1204    Ok(PipelineContext {
1205        manifest_path: args.target.manifest_path.clone(),
1206        target_dir: metadata.target_dir,
1207        package_name: resolved.package_name,
1208        module_path: resolved.module_path,
1209        observer_package,
1210        toolchain: args.global.toolchain.clone(),
1211        verbose: args.global.verbose,
1212        use_cache: !is_workspace_member,
1213        workspace_members: metadata.workspace_packages.into_iter().collect(),
1214        available_packages,
1215        crate_header: None,
1216        _workspace: None,
1217    })
1218}
1219
1220fn build_remote_context_summary(
1221    args: &SummaryArgs,
1222    spec: &str,
1223    remote: &RemoteOpts,
1224) -> Result<PipelineContext> {
1225    // With -C, crate_name IS the spec. If it contains "::", split into spec + module.
1226    let (actual_spec, module_path) = if let Some(idx) = spec.find("::") {
1227        let rest = &spec[idx + 2..];
1228        let module = if rest.is_empty() {
1229            None
1230        } else {
1231            Some(rest.to_string())
1232        };
1233        (&spec[..idx], module)
1234    } else {
1235        (spec, args.target.module_path.clone())
1236    };
1237
1238    let (name, _) = remote::parse_crate_spec(actual_spec);
1239    if args.global.verbose {
1240        eprintln!("[cargo-brief] Resolving workspace for '{name}'...");
1241    }
1242    let (workspace, resolved_version) = remote::resolve_workspace(
1243        actual_spec,
1244        remote.features.as_deref(),
1245        remote.no_default_features,
1246        remote.no_cache,
1247    )
1248    .with_context(|| format!("Failed to create workspace for '{name}'"))?;
1249
1250    // Pre-validate -F features against the crate's feature graph.
1251    if let Some(requested) = remote.features.as_deref() {
1252        match remote::load_remote_feature_graph(actual_spec) {
1253            Ok(Some(graph)) => features::validate_requested_features(&graph, requested)?,
1254            Ok(None) => eprintln!(
1255                "warning: feature graph unavailable for '{name}'; \
1256                 -F values will not be validated and feature gates will not be annotated"
1257            ),
1258            Err(_) => {}
1259        }
1260    }
1261
1262    let manifest_path = workspace
1263        .path()
1264        .join("Cargo.toml")
1265        .to_string_lossy()
1266        .into_owned();
1267
1268    let metadata = resolve::load_cargo_metadata(Some(&manifest_path))
1269        .context("Failed to load cargo metadata for remote crate")?;
1270
1271    let crate_header = build_remote_crate_header(
1272        &name,
1273        resolved_version.as_deref(),
1274        workspace.path(),
1275        remote.features.as_deref(),
1276    );
1277
1278    let available_packages = rustdoc_json::load_lockfile_packages(Some(&manifest_path));
1279
1280    Ok(PipelineContext {
1281        manifest_path: Some(manifest_path),
1282        target_dir: metadata.target_dir,
1283        package_name: name,
1284        module_path,
1285        observer_package: None,
1286        toolchain: args.global.toolchain.clone(),
1287        verbose: args.global.verbose,
1288        use_cache: true,
1289        workspace_members: HashSet::new(),
1290        available_packages,
1291        crate_header,
1292        _workspace: Some(workspace),
1293    })
1294}
1295
1296fn run_shared_summary_pipeline(ctx: &PipelineContext) -> Result<String> {
1297    let (model, same_crate, reachable) = generate_and_parse_model(ctx)?;
1298
1299    let mut output = summary::render_summary(
1300        &model,
1301        ctx.module_path.as_deref(),
1302        same_crate,
1303        reachable.as_ref(),
1304    );
1305
1306    // Cross-crate: if facade and no module scoping, build accessible-path index
1307    if ctx.module_path.is_none() && cross_crate::root_has_cross_crate_reexports(&model) {
1308        pre_warm_cross_crate_json(&model, ctx);
1309        if ctx.verbose {
1310            eprintln!("[cargo-brief] Building cross-crate accessible path index...");
1311        }
1312        let index = cross_crate::build_cross_crate_index(
1313            &model,
1314            &ctx.toolchain,
1315            ctx.manifest_path.as_deref(),
1316            &ctx.target_dir,
1317            ctx.verbose,
1318            &ctx.workspace_members,
1319            &ctx.available_packages,
1320        );
1321        let cross_summary = summary::summarize_cross_crate_index(&index);
1322        if !cross_summary.is_empty() {
1323            output.push_str(&cross_summary);
1324        }
1325    }
1326
1327    // Enrich header with version + features if available
1328    if let Some(header) = &ctx.crate_header
1329        && let Some(first_newline) = output.find('\n')
1330    {
1331        let first_line = &output[..first_newline];
1332        if first_line.starts_with("// crate ") {
1333            output.replace_range(..first_newline, header);
1334        }
1335    }
1336
1337    Ok(output)
1338}
1339
1340/// Pre-warm rustdoc JSON cache for cross-crate dependencies via batch generation.
1341///
1342/// Recursive BFS: each iteration discovers new crate names from the previous batch,
1343/// generates them, and repeats until no new crates are found or MAX_DEPTH is reached.
1344fn pre_warm_cross_crate_json(model: &CrateModel, ctx: &PipelineContext) {
1345    let mut seen = HashSet::new();
1346
1347    // Seed: collect external crate names from the primary model.
1348    // Names from rustdoc use underscores; normalize to Cargo.lock form (may be hyphenated).
1349    let mut batch: Vec<String> = cross_crate::collect_external_crate_names(model)
1350        .into_iter()
1351        .filter_map(|n| normalize_to_lockfile_name(&n, &ctx.available_packages))
1352        .collect();
1353    batch.sort();
1354    batch.dedup();
1355    seen.extend(batch.iter().cloned());
1356
1357    const MAX_DEPTH: usize = 8;
1358    for _ in 0..MAX_DEPTH {
1359        if batch.is_empty() {
1360            break;
1361        }
1362
1363        let refs: Vec<&str> = batch.iter().map(|s| s.as_str()).collect();
1364        rustdoc_json::batch_generate_rustdoc_json(
1365            &refs,
1366            &ctx.toolchain,
1367            ctx.manifest_path.as_deref(),
1368            &ctx.target_dir,
1369            ctx.verbose,
1370        );
1371
1372        // Parse this batch's crates to discover the next level
1373        let mut next_batch = Vec::new();
1374        for name in &batch {
1375            let doc_dir = ctx.target_dir.join("doc");
1376            let Some(json_path) =
1377                rustdoc_json::find_lib_json_path(name, ctx.manifest_path.as_deref(), &doc_dir)
1378            else {
1379                continue;
1380            };
1381            let Ok(krate) = rustdoc_json::parse_rustdoc_json_cached(&json_path) else {
1382                continue;
1383            };
1384            let sub_model = CrateModel::from_crate(krate);
1385            for sub_name in cross_crate::collect_external_crate_names(&sub_model) {
1386                if let Some(pkg_name) =
1387                    normalize_to_lockfile_name(&sub_name, &ctx.available_packages)
1388                    && !seen.contains(&pkg_name)
1389                {
1390                    seen.insert(pkg_name.clone());
1391                    next_batch.push(pkg_name);
1392                }
1393            }
1394        }
1395        next_batch.sort();
1396        next_batch.dedup();
1397        batch = next_batch;
1398    }
1399}
1400
1401/// BFS-discover accessible dependency crate names via rustdoc JSON.
1402/// Standalone version — takes explicit params instead of PipelineContext.
1403/// Returns cargo-compatible package names (possibly with @version suffix).
1404fn discover_accessible_deps(
1405    model: &CrateModel,
1406    toolchain: &str,
1407    manifest_path: Option<&str>,
1408    target_dir: &Path,
1409    verbose: bool,
1410) -> HashSet<String> {
1411    let packages = rustdoc_json::load_lockfile_packages(manifest_path);
1412
1413    // Seed: collect external crate names from the primary model.
1414    let mut batch: Vec<String> = cross_crate::collect_external_crate_names(model)
1415        .into_iter()
1416        .filter_map(|n| normalize_to_lockfile_name(&n, &packages))
1417        .collect();
1418    batch.sort();
1419    batch.dedup();
1420    let mut seen: HashSet<String> = batch.iter().cloned().collect();
1421
1422    const MAX_DEPTH: usize = 8;
1423    for _ in 0..MAX_DEPTH {
1424        if batch.is_empty() {
1425            break;
1426        }
1427
1428        let refs: Vec<&str> = batch.iter().map(|s| s.as_str()).collect();
1429        rustdoc_json::batch_generate_rustdoc_json(
1430            &refs,
1431            toolchain,
1432            manifest_path,
1433            target_dir,
1434            verbose,
1435        );
1436
1437        let mut next_batch = Vec::new();
1438        for name in &batch {
1439            let doc_dir = target_dir.join("doc");
1440            let Some(json_path) = rustdoc_json::find_lib_json_path(name, manifest_path, &doc_dir)
1441            else {
1442                continue;
1443            };
1444            let Ok(krate) = rustdoc_json::parse_rustdoc_json_cached(&json_path) else {
1445                continue;
1446            };
1447            let sub_model = CrateModel::from_crate(krate);
1448            for sub_name in cross_crate::collect_external_crate_names(&sub_model) {
1449                if let Some(pkg_name) = normalize_to_lockfile_name(&sub_name, &packages)
1450                    && !seen.contains(&pkg_name)
1451                {
1452                    seen.insert(pkg_name.clone());
1453                    next_batch.push(pkg_name);
1454                }
1455            }
1456        }
1457        next_batch.sort();
1458        next_batch.dedup();
1459        batch = next_batch;
1460    }
1461
1462    seen
1463}
1464
1465/// Normalize a rustdoc crate name (underscores) to the Cargo.lock package spec.
1466///
1467/// Rustdoc `use_item.source` gives Rust identifiers (e.g. `bevy_ecs`), but
1468/// `cargo doc -p` expects Cargo package names (e.g. `bevy-ecs`). Returns the
1469/// spec that can be passed to cargo, with `@version` suffix when multiple
1470/// versions exist. Returns None if not found.
1471fn normalize_to_lockfile_name(name: &str, packages: &LockfilePackages) -> Option<String> {
1472    packages.resolve_spec(name)
1473}
1474
1475/// Build an enriched `// crate name[version] features = [...]` header for remote crates.
1476/// Returns None if version cannot be determined.
1477fn build_remote_crate_header(
1478    crate_name: &str,
1479    resolved_version: Option<&str>,
1480    workspace_dir: &Path,
1481    features: Option<&str>,
1482) -> Option<String> {
1483    let version = resolved_version
1484        .map(|v| v.to_string())
1485        .or_else(|| remote::resolve_crate_version(workspace_dir, crate_name))?;
1486    let mut header = format!("// crate {crate_name}[{version}]");
1487    if let Some(feats) = features {
1488        let feat_list: Vec<&str> = feats.split(',').map(|s| s.trim()).collect();
1489        let formatted = feat_list
1490            .iter()
1491            .map(|f| format!("\"{f}\""))
1492            .collect::<Vec<_>>()
1493            .join(", ");
1494        header.push_str(&format!(" features = [{formatted}]"));
1495    }
1496    Some(header)
1497}
1498
1499/// Apply glob expansion results to the rendered output.
1500fn apply_glob_expansions(
1501    output: &mut String,
1502    result: &GlobExpansionResult,
1503    expand_glob: bool,
1504    filter: &FilterArgs,
1505) {
1506    if expand_glob && !result.source_models.is_empty() {
1507        // Phase 2: inline full definitions from source crates (only glob sources)
1508        let mut seen_names = HashSet::new();
1509        for source in result.item_names.keys() {
1510            if let Some(models) = result.source_models.get(source) {
1511                let mut rendered = String::new();
1512                for model in models {
1513                    rendered.push_str(&render::render_inlined_items(
1514                        model,
1515                        filter,
1516                        &mut seen_names,
1517                    ));
1518                }
1519                let pattern = format!("pub use {source}::*;");
1520                replace_glob_lines(output, &pattern, &rendered);
1521            }
1522        }
1523
1524        // Named cross-crate re-exports (same expand_glob gate as Phase 2)
1525        for (source, items) in &result.named_reexports {
1526            if let Some(models) = result.source_models.get(source) {
1527                for (item_name, full_source_path) in items {
1528                    if let Some(rendered) = render::render_single_inlined_item(
1529                        models,
1530                        item_name,
1531                        filter,
1532                        &mut seen_names,
1533                    ) {
1534                        let pattern = format!("pub use {full_source_path};");
1535                        replace_glob_lines(output, &pattern, &rendered);
1536                    }
1537                }
1538            }
1539        }
1540    } else if !result.item_names.is_empty() {
1541        // Phase 1: individual pub use lines
1542        for (source, items) in &result.item_names {
1543            let pattern = format!("pub use {source}::*;");
1544            let mut replacement = String::new();
1545            for name in items {
1546                replacement.push_str(&format!("pub use {source}::{name};\n"));
1547            }
1548            replace_glob_lines(output, &pattern, &replacement);
1549        }
1550    }
1551}
1552
1553/// Find and replace all lines whose normalized content matches `pattern`.
1554///
1555/// Normalization: trim whitespace, collapse multiple spaces.
1556/// Replacement lines inherit the original line's indentation.
1557fn replace_glob_lines(output: &mut String, pattern: &str, replacement: &str) {
1558    while let Some((start, end, indent)) = find_normalized_line(output, pattern) {
1559        let indented: String = replacement
1560            .lines()
1561            .map(|l| {
1562                if l.is_empty() {
1563                    "\n".to_string()
1564                } else {
1565                    format!("{indent}{l}\n")
1566                }
1567            })
1568            .collect();
1569        output.replace_range(start..end, &indented);
1570    }
1571}
1572
1573/// Find the first line in `text` whose trimmed, space-collapsed content equals `pattern`.
1574/// Returns `(start_byte, end_byte, indent_str)`.
1575fn find_normalized_line(text: &str, pattern: &str) -> Option<(usize, usize, String)> {
1576    let mut start = 0;
1577    for line in text.split('\n') {
1578        let end = start + line.len() + 1; // +1 for '\n'
1579        let normalized: String = line.split_whitespace().collect::<Vec<_>>().join(" ");
1580        if normalized == pattern {
1581            let indent = &line[..line.len() - line.trim_start().len()];
1582            return Some((start, end.min(text.len()), indent.to_string()));
1583        }
1584        start = end;
1585    }
1586    None
1587}
1588
1589/// Try generating rustdoc JSON for a crate, falling back to hyphenated name.
1590///
1591/// Rustdoc `use_item.source` gives Rust identifiers (underscores), but
1592/// `cargo rustdoc -p` expects package names (hyphens). Try the original name
1593/// first, then try with `_` → `-` if it fails.
1594fn try_generate_rustdoc_json(
1595    source: &str,
1596    toolchain: &str,
1597    manifest_path: Option<&str>,
1598    target_dir: &Path,
1599    verbose: bool,
1600    use_cache: bool,
1601) -> Option<PathBuf> {
1602    // Try original name first (works for crates without hyphens)
1603    if let Ok(path) = rustdoc_json::generate_rustdoc_json(
1604        source,
1605        toolchain,
1606        manifest_path,
1607        false,
1608        target_dir,
1609        verbose,
1610        use_cache,
1611    ) {
1612        return Some(path);
1613    }
1614    // Fallback: try hyphenated name (glob_source → glob-source)
1615    let hyphenated = source.replace('_', "-");
1616    if hyphenated != source
1617        && let Ok(path) = rustdoc_json::generate_rustdoc_json(
1618            &hyphenated,
1619            toolchain,
1620            manifest_path,
1621            false,
1622            target_dir,
1623            verbose,
1624            use_cache,
1625        )
1626    {
1627        return Some(path);
1628    }
1629    None
1630}
1631
1632/// Detect glob re-exports in the target module and expand each by generating
1633/// rustdoc JSON for the source crate and enumerating its public items.
1634///
1635/// Returns both item names (for Phase 1 `pub use` lines) and source models
1636/// (for Phase 2 full definition inlining). Recursively follows cross-crate
1637/// glob chains (max depth 8).
1638fn expand_glob_reexports(
1639    model: &CrateModel,
1640    target_module_path: Option<&str>,
1641    toolchain: &str,
1642    manifest_path: Option<&str>,
1643    target_dir: &Path,
1644    verbose: bool,
1645    workspace_members: &HashSet<String>,
1646) -> GlobExpansionResult {
1647    let target_item = if let Some(path) = target_module_path {
1648        model.find_module(path)
1649    } else {
1650        model.root_module()
1651    };
1652
1653    let Some(target_item) = target_item else {
1654        return GlobExpansionResult {
1655            item_names: HashMap::new(),
1656            source_models: HashMap::new(),
1657            named_reexports: HashMap::new(),
1658        };
1659    };
1660
1661    let mut item_names = HashMap::new();
1662    let mut source_models = HashMap::new();
1663
1664    for (_id, child) in model.module_children(target_item) {
1665        let ItemEnum::Use(use_item) = &child.inner else {
1666            continue;
1667        };
1668        if !use_item.is_glob {
1669            continue;
1670        }
1671
1672        let source = &use_item.source;
1673
1674        // Cache non-workspace deps (immutable once resolved via Cargo.lock)
1675        let dep_use_cache = !workspace_members.contains(source.as_str())
1676            && !workspace_members.contains(&source.replace('_', "-"));
1677
1678        // Generate JSON for the source crate (pub items only, no private items)
1679        let Some(json_path) = try_generate_rustdoc_json(
1680            source,
1681            toolchain,
1682            manifest_path,
1683            target_dir,
1684            verbose,
1685            dep_use_cache,
1686        ) else {
1687            continue;
1688        };
1689        let Ok(source_krate) = rustdoc_json::parse_rustdoc_json_cached(&json_path) else {
1690            continue;
1691        };
1692
1693        let source_model = CrateModel::from_crate(source_krate);
1694        let mut all_items = Vec::new();
1695        let mut all_models = Vec::new();
1696        let mut visited = HashSet::new();
1697        visited.insert(source.clone());
1698
1699        collect_glob_items_recursive(
1700            &source_model,
1701            toolchain,
1702            manifest_path,
1703            target_dir,
1704            verbose,
1705            workspace_members,
1706            &mut visited,
1707            &mut all_items,
1708            &mut all_models,
1709            0,
1710        );
1711
1712        all_items.sort();
1713        all_items.dedup();
1714
1715        // Direct source model goes first (index 0)
1716        let mut models = vec![source_model];
1717        models.extend(all_models);
1718
1719        item_names.insert(source.clone(), all_items);
1720        source_models.insert(source.clone(), models);
1721    }
1722
1723    // Second pass: named cross-crate re-exports
1724    let mut named_reexports: HashMap<String, Vec<(String, String)>> = HashMap::new();
1725
1726    for (_id, child) in model.module_children(target_item) {
1727        let ItemEnum::Use(use_item) = &child.inner else {
1728            continue;
1729        };
1730        if use_item.is_glob {
1731            continue;
1732        }
1733
1734        // Cross-crate: target id exists but is not in the local model's index
1735        let is_cross_crate = match &use_item.id {
1736            Some(id) => !model.krate.index.contains_key(id),
1737            None => continue, // unresolvable (primitive re-export) — skip
1738        };
1739        if !is_cross_crate {
1740            continue;
1741        }
1742
1743        // Extract source crate name (first :: segment) and item name (last :: segment)
1744        let source_path = &use_item.source;
1745        let Some((source_prefix, item_name)) = source_path.rsplit_once("::") else {
1746            continue;
1747        };
1748        let crate_name = source_prefix.split("::").next().unwrap();
1749
1750        // Module re-exports (e.g., `pub use serde_core::de;`) are not filtered here —
1751        // render_single_inlined_item returns None for modules, leaving pub use line intact.
1752
1753        // Generate source model if not already present from glob processing
1754        if !source_models.contains_key(crate_name) {
1755            let dep_use_cache = !workspace_members.contains(crate_name)
1756                && !workspace_members.contains(&crate_name.replace('_', "-"));
1757            let Some(json_path) = try_generate_rustdoc_json(
1758                crate_name,
1759                toolchain,
1760                manifest_path,
1761                target_dir,
1762                verbose,
1763                dep_use_cache,
1764            ) else {
1765                continue;
1766            };
1767            let Ok(source_krate) = rustdoc_json::parse_rustdoc_json_cached(&json_path) else {
1768                continue;
1769            };
1770            source_models.insert(
1771                crate_name.to_string(),
1772                vec![CrateModel::from_crate(source_krate)],
1773            );
1774        }
1775
1776        named_reexports
1777            .entry(crate_name.to_string())
1778            .or_default()
1779            .push((item_name.to_string(), source_path.clone()));
1780    }
1781
1782    GlobExpansionResult {
1783        item_names,
1784        source_models,
1785        named_reexports,
1786    }
1787}
1788
1789/// Recursively collect public item names and models from a source crate.
1790///
1791/// For each child of the source model's root:
1792/// - `is_glob=true` Use: attempt to generate rustdoc JSON for the source and recurse
1793/// - Non-glob Use: collect the re-exported name
1794/// - Direct item (non-module): collect the item name
1795/// - Module: skip (same as top-level expansion)
1796#[allow(clippy::too_many_arguments)]
1797fn collect_glob_items_recursive(
1798    source_model: &CrateModel,
1799    toolchain: &str,
1800    manifest_path: Option<&str>,
1801    target_dir: &Path,
1802    verbose: bool,
1803    workspace_members: &HashSet<String>,
1804    visited: &mut HashSet<String>,
1805    all_items: &mut Vec<String>,
1806    all_models: &mut Vec<CrateModel>,
1807    depth: usize,
1808) {
1809    const MAX_DEPTH: usize = 8;
1810
1811    let Some(root) = source_model.root_module() else {
1812        return;
1813    };
1814
1815    for (_, child) in source_model.module_children(root) {
1816        if !matches!(child.visibility, Visibility::Public) {
1817            continue;
1818        }
1819        if matches!(child.inner, ItemEnum::Module(_)) {
1820            continue;
1821        }
1822
1823        if let ItemEnum::Use(use_item) = &child.inner {
1824            if use_item.is_glob {
1825                // Cross-crate glob — recurse if within depth limit
1826                if depth >= MAX_DEPTH {
1827                    continue;
1828                }
1829                let nested_source = &use_item.source;
1830                if !visited.insert(nested_source.clone()) {
1831                    continue; // cycle prevention
1832                }
1833                if verbose {
1834                    eprintln!(
1835                        "[cargo-brief] Following nested glob re-export: {nested_source} (depth {})",
1836                        depth + 1
1837                    );
1838                }
1839                let nested_use_cache = !workspace_members.contains(nested_source.as_str())
1840                    && !workspace_members.contains(&nested_source.replace('_', "-"));
1841                let Some(json_path) = try_generate_rustdoc_json(
1842                    nested_source,
1843                    toolchain,
1844                    manifest_path,
1845                    target_dir,
1846                    verbose,
1847                    nested_use_cache,
1848                ) else {
1849                    continue; // intra-crate path or missing dep — skip
1850                };
1851                let Ok(nested_krate) = rustdoc_json::parse_rustdoc_json_cached(&json_path) else {
1852                    continue;
1853                };
1854                let nested_model = CrateModel::from_crate(nested_krate);
1855                collect_glob_items_recursive(
1856                    &nested_model,
1857                    toolchain,
1858                    manifest_path,
1859                    target_dir,
1860                    verbose,
1861                    workspace_members,
1862                    visited,
1863                    all_items,
1864                    all_models,
1865                    depth + 1,
1866                );
1867                all_models.push(nested_model);
1868            } else {
1869                // Non-glob Use: collect the re-exported name
1870                if let Some(name) = child.name.as_ref().or(Some(&use_item.name)) {
1871                    all_items.push(name.clone());
1872                }
1873            }
1874        } else {
1875            // Direct item (struct, trait, fn, etc.)
1876            if let Some(name) = &child.name {
1877                all_items.push(name.clone());
1878            }
1879        }
1880    }
1881}