Skip to main content

fallow_api/
list_runtime.rs

1//! Programmatic list-command runtime helpers.
2
3use std::path::{Path, PathBuf};
4
5use fallow_config::{AuthoredRule, LogicalGroup, LogicalGroupStatus, ResolvedBoundaryConfig};
6use fallow_output::{ListEntryPointOutput, WorkspaceInfo as WorkspaceOutputInfo, WorkspacesOutput};
7use fallow_types::discover::{DiscoveredFile, EntryPoint};
8use fallow_types::path_util::display_relative;
9use rustc_hash::FxHashMap;
10
11use crate::{
12    AnalysisOptions, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
13    BoundariesListing, ListJsonEnvelope, ListJsonOutputInput, ProgrammaticError,
14    analysis_context::{changed_files_for_run, validate_workspace_selection},
15    resolve_programmatic_analysis_context, serialize_list_json_output,
16};
17
18type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
19
20/// Options for MCP/project metadata listing through the programmatic API.
21#[derive(Debug, Clone, Default)]
22pub struct ProjectInfoOptions {
23    /// Shared analysis options: root, config path, and cache behavior.
24    pub analysis: AnalysisOptions,
25    /// Include the resolved entry points in the output.
26    pub entry_points: bool,
27    /// Include the discovered file list in the output.
28    pub files: bool,
29    /// Include the detected plugin names in the output.
30    pub plugins: bool,
31    /// Include the boundaries listing in the output.
32    pub boundaries: bool,
33}
34
35/// Options for `fallow list --boundaries` through the programmatic API.
36#[derive(Debug, Clone, Default)]
37pub struct ListBoundariesOptions {
38    /// Shared analysis options: root, config path, and cache behavior.
39    pub analysis: AnalysisOptions,
40}
41
42/// Typed output for project metadata listing before JSON serialization.
43#[derive(Debug, Clone)]
44pub struct ProjectInfoProgrammaticOutput {
45    /// Detected plugin names; `None` when the section was not requested.
46    pub plugins: Option<Vec<String>>,
47    /// Discovered file paths; `None` when the section was not requested.
48    pub files: Option<Vec<String>>,
49    /// Resolved entry points; `None` when the section was not requested.
50    pub entry_points: Option<Vec<ListEntryPointOutput>>,
51    /// Boundaries listing; `None` when the section was not requested.
52    pub boundaries: Option<BoundariesListing>,
53    /// Workspace listing; `None` when the section was not requested.
54    pub workspaces: Option<WorkspacesOutput<fallow_config::WorkspaceDiagnostic>>,
55    /// Diagnostics the plugin stage recorded, project-root-relative; empty when
56    /// the stage did not run or recorded none.
57    pub plugin_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
58    /// Which list envelope shape wraps the serialized body.
59    pub envelope: ListJsonEnvelope,
60}
61
62/// Serialize typed project-info output to the stable JSON contract.
63///
64/// # Errors
65///
66/// Returns a structured programmatic error when JSON serialization fails.
67pub fn serialize_project_info_programmatic_json(
68    output: ProjectInfoProgrammaticOutput,
69) -> ProgrammaticResult<serde_json::Value> {
70    serialize_list_json_output(
71        ListJsonOutputInput {
72            plugins: output.plugins,
73            files: output.files,
74            entry_points: output.entry_points,
75            boundaries: output.boundaries,
76            entry_weight: None,
77            workspaces: output.workspaces,
78            plugin_diagnostics: output.plugin_diagnostics,
79        },
80        output.envelope,
81    )
82    .map_err(|err| {
83        ProgrammaticError::new(format!("failed to serialize project info output: {err}"), 2)
84            .with_code("FALLOW_PROJECT_INFO_SERIALIZE_FAILED")
85            .with_context("project_info")
86    })
87}
88
89/// Typed output for `fallow list --boundaries` before JSON serialization.
90#[derive(Debug, Clone)]
91pub struct ListBoundariesProgrammaticOutput {
92    /// Typed boundaries listing produced by the run.
93    pub boundaries: BoundariesListing,
94}
95
96/// Serialize typed boundary-list output to the stable JSON contract.
97///
98/// # Errors
99///
100/// Returns a structured programmatic error when JSON serialization fails.
101pub fn serialize_list_boundaries_programmatic_json(
102    output: ListBoundariesProgrammaticOutput,
103) -> ProgrammaticResult<serde_json::Value> {
104    serialize_list_json_output(
105        ListJsonOutputInput::<BoundariesListing, serde_json::Value> {
106            plugins: None,
107            files: None,
108            entry_points: None,
109            boundaries: Some(output.boundaries),
110            entry_weight: None,
111            workspaces: None,
112            plugin_diagnostics: Vec::new(),
113        },
114        ListJsonEnvelope::Boundaries,
115    )
116    .map_err(|err| {
117        ProgrammaticError::new(
118            format!("failed to serialize list boundaries output: {err}"),
119            2,
120        )
121        .with_code("FALLOW_LIST_BOUNDARIES_SERIALIZE_FAILED")
122        .with_context("list_boundaries")
123    })
124}
125
126/// Owned boundary listing data shared by CLI and programmatic renderers.
127#[derive(Debug, Clone)]
128pub struct BoundaryData {
129    /// Configured zones with per-zone matched file counts.
130    pub zones: Vec<ZoneInfo>,
131    /// Configured allow rules between zones.
132    pub rules: Vec<RuleInfo>,
133    /// Logical parent/child groups with derived file-count totals.
134    pub logical_groups: Vec<LogicalGroupInfo>,
135    /// Whether the resolved config declares no boundaries at all.
136    pub is_empty: bool,
137}
138
139/// One configured boundary zone with its matched file count.
140#[derive(Debug, Clone)]
141pub struct ZoneInfo {
142    /// Zone name from the config.
143    pub name: String,
144    /// Glob patterns that assign files to the zone.
145    pub patterns: Vec<String>,
146    /// Number of discovered files matching the zone.
147    pub file_count: usize,
148}
149
150/// One configured boundary allow rule.
151#[derive(Debug, Clone)]
152pub struct RuleInfo {
153    /// Source zone the rule applies to.
154    pub from: String,
155    /// Zone names the source zone may import from.
156    pub allow: Vec<String>,
157}
158
159/// View-model mirror of [`LogicalGroup`] with derived file-count totals.
160#[derive(Debug, Clone)]
161pub struct LogicalGroupInfo {
162    /// Parent zone name.
163    pub name: String,
164    /// Child zone names.
165    pub children: Vec<String>,
166    /// Authored `autoDiscover` paths.
167    pub auto_discover: Vec<String>,
168    /// Authored parent rule, if any.
169    pub authored_rule: Option<AuthoredRule>,
170    /// Fallback zone name, if the parent kept patterns.
171    pub fallback_zone: Option<String>,
172    /// Original `zones[]` index.
173    pub source_zone_index: usize,
174    /// Discovery status.
175    pub status: LogicalGroupStatus,
176    /// Total files across children plus the fallback zone.
177    pub file_count: usize,
178    /// Files matched by the child zones only.
179    pub child_file_count: usize,
180    /// Files matched by the fallback zone only.
181    pub fallback_file_count: usize,
182    /// Merged duplicate parent indices.
183    pub merged_from: Option<Vec<usize>>,
184    /// Authored parent root, if any.
185    pub original_zone_root: Option<String>,
186    /// Child-to-source zone indexes.
187    pub child_source_indices: Vec<usize>,
188}
189
190/// Run `list_boundaries` through the API-owned runtime path.
191///
192/// # Errors
193///
194/// Returns a structured programmatic error for invalid options or config-load
195/// failures, and `FALLOW_CANCELLED` when the caller's cancellation token is
196/// set. This route parses nothing: config load and file discovery are its only
197/// stage, so the token is observed on either side of that and nowhere within
198/// it.
199pub fn run_list_boundaries(
200    options: &ListBoundariesOptions,
201) -> ProgrammaticResult<ListBoundariesProgrammaticOutput> {
202    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
203    resolved.install(|| {
204        resolved.ensure_not_cancelled("config load and file discovery")?;
205        let project_config = load_list_project_config(&resolved)?;
206        let session = fallow_engine::session::AnalysisSession::from_config(project_config);
207        validate_workspace_selection(&resolved, session.workspaces())?;
208        resolved.ensure_not_cancelled("the boundary listing")?;
209        let changed_files = changed_files_for_run(&resolved)?;
210        let discovered = scoped_discovered_files(session.files(), changed_files.as_ref());
211        let data = compute_boundary_data(session.config(), Some(&discovered));
212
213        Ok(ListBoundariesProgrammaticOutput {
214            boundaries: boundary_data_to_output(&data),
215        })
216    })
217}
218
219/// Run project metadata listing through the API-owned runtime path.
220///
221/// # Errors
222///
223/// Returns a structured programmatic error for invalid options, config-load
224/// failures, or plugin regex errors, and `FALLOW_CANCELLED` when the caller's
225/// cancellation token is set. This route parses nothing: config load and file
226/// discovery are its only stage, so the token is observed on either side of
227/// that and nowhere within it.
228pub fn run_project_info(
229    options: &ProjectInfoOptions,
230) -> ProgrammaticResult<ProjectInfoProgrammaticOutput> {
231    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
232    resolved.install(|| {
233        resolved.ensure_not_cancelled("config load and file discovery")?;
234        let project_config = load_list_project_config(&resolved)?;
235        let session = fallow_engine::session::AnalysisSession::from_config(project_config);
236        validate_workspace_selection(&resolved, session.workspaces())?;
237        resolved.ensure_not_cancelled("the project listing")?;
238        let config = session.config();
239        let workspaces = session.workspaces();
240        let show_all = project_info_should_show_all(options);
241        let changed_files = changed_files_for_run(&resolved)?;
242        let discovered =
243            project_info_discovered_files(options, show_all, &session, changed_files.as_ref());
244        let discovered_ref = discovered.as_deref();
245
246        let inventory = collect_inventory(&session, options, show_all)?;
247        let (plugin_result, entry_points) = match inventory {
248            Some(inventory) => (
249                Some(inventory.plugins),
250                inventory
251                    .entry_points
252                    .map(|entries| scoped_entry_points(entries, changed_files.as_ref())),
253            ),
254            None => (None, None),
255        };
256        let plugin_diagnostics = plugin_result
257            .as_ref()
258            .map(|result| {
259                result
260                    .plugin_diagnostics(&config.root)
261                    .into_iter()
262                    .map(|diagnostic| diagnostic.into_root_relative(&config.root))
263                    .collect()
264            })
265            .unwrap_or_default();
266        let boundaries = options
267            .boundaries
268            .then(|| boundary_data_to_output(&compute_boundary_data(config, discovered_ref)));
269        let workspaces = if show_all {
270            Some(collect_workspace_output(
271                resolved.root(),
272                workspaces,
273                session.workspace_diagnostics(),
274            ))
275        } else {
276            None
277        };
278        let envelope = if boundaries.is_some() {
279            ListJsonEnvelope::Boundaries
280        } else {
281            ListJsonEnvelope::Plain
282        };
283
284        Ok(ProjectInfoProgrammaticOutput {
285            plugins: collect_plugins(options, show_all, plugin_result.as_ref()),
286            files: collect_files(options, show_all, discovered_ref, resolved.root()),
287            entry_points: entry_points
288                .map(|entries| entry_points_to_output(&entries, resolved.root())),
289            boundaries,
290            workspaces,
291            plugin_diagnostics,
292            envelope,
293        })
294    })
295}
296
297fn project_info_discovered_files(
298    options: &ProjectInfoOptions,
299    show_all: bool,
300    session: &fallow_engine::session::AnalysisSession,
301    changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
302) -> Option<Vec<DiscoveredFile>> {
303    let needs_discovery =
304        options.files || options.entry_points || options.boundaries || options.plugins || show_all;
305    needs_discovery.then(|| scoped_discovered_files(session.files(), changed_files))
306}
307
308fn scoped_discovered_files(
309    files: &[DiscoveredFile],
310    changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
311) -> Vec<DiscoveredFile> {
312    let Some(changed_files) = changed_files else {
313        return files.to_vec();
314    };
315    files
316        .iter()
317        .filter(|file| changed_files.contains(&file.path))
318        .cloned()
319        .collect()
320}
321
322fn load_list_project_config(
323    resolved: &crate::ProgrammaticAnalysisContext,
324) -> ProgrammaticResult<fallow_engine::project_config::ProjectConfig> {
325    fallow_engine::project_config::config_for_project_analysis(
326        resolved.root(),
327        resolved.config_path().as_deref(),
328        fallow_engine::project_config::ProjectConfigOptions {
329            output: fallow_types::output_format::OutputFormat::Json,
330            no_cache: resolved.no_cache(),
331            threads: resolved.threads(),
332            production_override: resolved.production_override(),
333            quiet: true,
334            analysis: fallow_config::ProductionAnalysis::DeadCode,
335            allow_remote_extends: resolved.allow_remote_extends(),
336        },
337    )
338    .map_err(|err| {
339        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
340            .with_code("FALLOW_CONFIG_LOAD_FAILED")
341            .with_context("analysis.configPath")
342    })
343}
344
345const fn project_info_should_show_all(options: &ProjectInfoOptions) -> bool {
346    !options.entry_points && !options.files && !options.plugins && !options.boundaries
347}
348
349fn collect_plugins(
350    options: &ProjectInfoOptions,
351    show_all: bool,
352    plugin_result: Option<&fallow_engine::plugins::AggregatedPluginResult>,
353) -> Option<Vec<String>> {
354    if options.plugins || show_all {
355        plugin_result.map(|plugin_result| plugin_result.active_plugins().to_vec())
356    } else {
357        None
358    }
359}
360
361fn collect_files(
362    options: &ProjectInfoOptions,
363    show_all: bool,
364    discovered: Option<&[DiscoveredFile]>,
365    root: &Path,
366) -> Option<Vec<String>> {
367    if options.files || show_all {
368        discovered.map(|files| {
369            files
370                .iter()
371                .map(|file| display_relative(root, &file.path))
372                .collect()
373        })
374    } else {
375        None
376    }
377}
378
379/// Run the analysis prelude and entry-point discovery when the listing needs
380/// plugins or entry points, over the whole discovery: a changed-file scope
381/// narrows the listing, never which plugins are active.
382fn collect_inventory(
383    session: &fallow_engine::session::AnalysisSession,
384    options: &ProjectInfoOptions,
385    show_all: bool,
386) -> ProgrammaticResult<Option<fallow_engine::list_inventory::ListingInventory>> {
387    if !(options.plugins || options.entry_points || show_all) {
388        return Ok(None);
389    }
390    fallow_engine::list_inventory::collect_listing_inventory(
391        session,
392        options.entry_points || show_all,
393    )
394    .map(Some)
395    .map_err(|err| {
396        ProgrammaticError::new(err.message(), 2)
397            .with_code("FALLOW_PLUGIN_REGEX_FAILED")
398            .with_context("project_info.plugins")
399    })
400}
401
402/// Keep the entry points inside the changed-file scope, the way the listed
403/// files are kept. The analysis found them over the whole project.
404fn scoped_entry_points(
405    entry_points: Vec<EntryPoint>,
406    changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
407) -> Vec<EntryPoint> {
408    let Some(changed_files) = changed_files else {
409        return entry_points;
410    };
411    entry_points
412        .into_iter()
413        .filter(|entry| changed_files.contains(&entry.path))
414        .collect()
415}
416
417fn entry_points_to_output(entries: &[EntryPoint], root: &Path) -> Vec<ListEntryPointOutput> {
418    entries
419        .iter()
420        .map(|entry| ListEntryPointOutput {
421            path: display_relative(root, &entry.path),
422            source: entry.source.to_string(),
423        })
424        .collect()
425}
426
427fn collect_workspace_output(
428    root: &Path,
429    workspaces: &[fallow_config::WorkspaceInfo],
430    diagnostics: &[fallow_config::WorkspaceDiagnostic],
431) -> WorkspacesOutput<fallow_config::WorkspaceDiagnostic> {
432    let workspaces = workspaces
433        .iter()
434        .map(|workspace| {
435            let relative = workspace.root.strip_prefix(root).unwrap_or(&workspace.root);
436            WorkspaceOutputInfo {
437                name: workspace.name.clone(),
438                path: relative.display().to_string().replace('\\', "/"),
439                is_internal_dependency: workspace.is_internal_dependency,
440            }
441        })
442        .collect::<Vec<_>>();
443    WorkspacesOutput {
444        workspace_count: workspaces.len(),
445        workspaces,
446        // Project-relative like the sibling `workspaces[].path` and like every
447        // analysis envelope's `workspace_diagnostics[]`. The list envelope has
448        // no post-serialization `strip_root_prefix` pass, so it normalises here.
449        workspace_diagnostics: diagnostics
450            .iter()
451            .map(|diagnostic| diagnostic.clone().into_root_relative(root))
452            .collect(),
453    }
454}
455
456/// Compute boundary listing data from resolved config and optional discovery.
457#[must_use]
458pub fn compute_boundary_data(
459    config: &fallow_config::ResolvedConfig,
460    discovered: Option<&[DiscoveredFile]>,
461) -> BoundaryData {
462    let boundaries = &config.boundaries;
463
464    if boundaries.is_empty() {
465        return BoundaryData {
466            zones: vec![],
467            rules: vec![],
468            logical_groups: vec![],
469            is_empty: true,
470        };
471    }
472
473    let zones = build_boundary_zones(&config.root, boundaries, discovered);
474    let rules = build_boundary_rules(boundaries);
475    let logical_groups = build_logical_groups(boundaries, &zones);
476
477    BoundaryData {
478        zones,
479        rules,
480        logical_groups,
481        is_empty: false,
482    }
483}
484
485fn build_boundary_zones(
486    root: &Path,
487    boundaries: &ResolvedBoundaryConfig,
488    discovered: Option<&[DiscoveredFile]>,
489) -> Vec<ZoneInfo> {
490    let file_counts = count_boundary_files_by_zone(root, boundaries, discovered);
491
492    boundaries
493        .zones
494        .iter()
495        .map(|zone| ZoneInfo {
496            name: zone.name.clone(),
497            patterns: zone.matchers.iter().map(|m| m.glob().to_string()).collect(),
498            file_count: file_counts.get(zone.name.as_str()).copied().unwrap_or(0),
499        })
500        .collect()
501}
502
503fn count_boundary_files_by_zone<'a>(
504    root: &Path,
505    boundaries: &'a ResolvedBoundaryConfig,
506    discovered: Option<&[DiscoveredFile]>,
507) -> FxHashMap<&'a str, usize> {
508    let mut file_counts = FxHashMap::default();
509    let Some(files) = discovered else {
510        return file_counts;
511    };
512
513    for file in files {
514        let Ok(relative_path) = file.path.strip_prefix(root) else {
515            continue;
516        };
517        let relative_path = relative_path.to_string_lossy().replace('\\', "/");
518        if let Some(zone_name) = boundaries.classify_zone(&relative_path) {
519            *file_counts.entry(zone_name).or_default() += 1;
520        }
521    }
522
523    file_counts
524}
525
526fn build_boundary_rules(boundaries: &ResolvedBoundaryConfig) -> Vec<RuleInfo> {
527    boundaries
528        .rules
529        .iter()
530        .map(|rule| RuleInfo {
531            from: rule.from_zone.clone(),
532            allow: rule.allowed_zones.clone(),
533        })
534        .collect()
535}
536
537fn build_logical_groups(
538    boundaries: &ResolvedBoundaryConfig,
539    zones: &[ZoneInfo],
540) -> Vec<LogicalGroupInfo> {
541    let zone_count_by_name: FxHashMap<&str, usize> = zones
542        .iter()
543        .map(|zone| (zone.name.as_str(), zone.file_count))
544        .collect();
545
546    boundaries
547        .logical_groups
548        .iter()
549        .map(|group| logical_group_info(group, &zone_count_by_name))
550        .collect()
551}
552
553fn logical_group_info(
554    group: &LogicalGroup,
555    zone_count_by_name: &FxHashMap<&str, usize>,
556) -> LogicalGroupInfo {
557    let child_file_count: usize = group
558        .children
559        .iter()
560        .filter_map(|child| zone_count_by_name.get(child.as_str()).copied())
561        .sum();
562    let fallback_file_count = group
563        .fallback_zone
564        .as_deref()
565        .and_then(|fallback| zone_count_by_name.get(fallback).copied())
566        .unwrap_or(0);
567
568    LogicalGroupInfo {
569        name: group.name.clone(),
570        children: group.children.clone(),
571        auto_discover: group.auto_discover.clone(),
572        authored_rule: group.authored_rule.clone(),
573        fallback_zone: group.fallback_zone.clone(),
574        source_zone_index: group.source_zone_index,
575        status: group.status,
576        file_count: child_file_count + fallback_file_count,
577        child_file_count,
578        fallback_file_count,
579        merged_from: group.merged_from.clone(),
580        original_zone_root: group.original_zone_root.clone(),
581        child_source_indices: group.child_source_indices.clone(),
582    }
583}
584
585/// Convert boundary listing data to the stable output contract.
586#[must_use]
587pub fn boundary_data_to_output(data: &BoundaryData) -> BoundariesListing {
588    if data.is_empty {
589        return BoundariesListing {
590            configured: false,
591            zone_count: 0,
592            zones: Vec::new(),
593            rule_count: 0,
594            rules: Vec::new(),
595            logical_group_count: 0,
596            logical_groups: Vec::new(),
597        };
598    }
599
600    BoundariesListing {
601        configured: true,
602        zone_count: data.zones.len(),
603        zones: data
604            .zones
605            .iter()
606            .map(|zone| BoundariesListZone {
607                name: zone.name.clone(),
608                patterns: zone.patterns.clone(),
609                file_count: zone.file_count,
610            })
611            .collect(),
612        rule_count: data.rules.len(),
613        rules: data
614            .rules
615            .iter()
616            .map(|rule| BoundariesListRule {
617                from: rule.from.clone(),
618                allow: rule.allow.clone(),
619            })
620            .collect(),
621        logical_group_count: data.logical_groups.len(),
622        logical_groups: data
623            .logical_groups
624            .iter()
625            .map(logical_group_info_to_output)
626            .collect(),
627    }
628}
629
630fn logical_group_info_to_output(group: &LogicalGroupInfo) -> BoundariesListLogicalGroup {
631    BoundariesListLogicalGroup {
632        name: group.name.clone(),
633        children: group.children.clone(),
634        auto_discover: group.auto_discover.clone(),
635        status: group.status,
636        source_zone_index: group.source_zone_index,
637        file_count: group.file_count,
638        authored_rule: group.authored_rule.clone(),
639        fallback_zone: group.fallback_zone.clone(),
640        merged_from: group.merged_from.clone(),
641        original_zone_root: group.original_zone_root.clone(),
642        child_source_indices: group.child_source_indices.clone(),
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use std::process::Command;
649
650    use fallow_config::ResolvedZone;
651    use fallow_types::discover::FileId;
652    use globset::Glob;
653    use serde_json::json;
654
655    use super::*;
656
657    /// The `fallow workspaces` / `fallow list --workspaces` envelope and the
658    /// MCP `project_info` tool have no post-serialization `strip_root_prefix`
659    /// pass, so `collect_workspace_output` is what makes their diagnostic
660    /// paths project-relative like every other envelope's.
661    #[test]
662    fn workspace_output_emits_project_relative_diagnostic_paths() {
663        let root = Path::new("/project");
664        let output = collect_workspace_output(
665            root,
666            &[],
667            &[fallow_config::WorkspaceDiagnostic::new(
668                root,
669                root.join("packages/inner"),
670                fallow_config::WorkspaceDiagnosticKind::UndeclaredWorkspace,
671            )],
672        );
673
674        let value = serde_json::to_value(&output).expect("workspaces output serializes");
675        assert_eq!(value["workspace_diagnostics"][0]["path"], "packages/inner");
676    }
677
678    fn empty_boundary_data() -> BoundaryData {
679        BoundaryData {
680            zones: vec![],
681            rules: vec![],
682            logical_groups: vec![],
683            is_empty: true,
684        }
685    }
686
687    fn boundary_data_to_json(data: &BoundaryData) -> serde_json::Value {
688        serde_json::to_value(boundary_data_to_output(data))
689            .expect("boundary list output should serialize")
690    }
691
692    fn resolved_zone(name: &str, patterns: &[&str]) -> ResolvedZone {
693        ResolvedZone {
694            name: name.to_string(),
695            patterns: patterns
696                .iter()
697                .map(|pattern| (*pattern).to_string())
698                .collect(),
699            matchers: patterns
700                .iter()
701                .map(|pattern| {
702                    Glob::new(pattern)
703                        .expect("valid test glob")
704                        .compile_matcher()
705                })
706                .collect(),
707            root: None,
708        }
709    }
710
711    fn discovered_files(root: &Path, relative_paths: &[&str]) -> Vec<DiscoveredFile> {
712        relative_paths
713            .iter()
714            .enumerate()
715            .map(|(index, relative_path)| DiscoveredFile {
716                id: FileId(u32::try_from(index).expect("test file count fits in u32")),
717                path: root.join(relative_path),
718                size_bytes: 0,
719            })
720            .collect()
721    }
722
723    fn git(project: &Path, args: &[&str]) {
724        let status = fallow_engine::changed_files::clear_ambient_git_env(&mut Command::new("git"))
725            .args(args)
726            .current_dir(project)
727            .status()
728            .expect("git command should run");
729        assert!(status.success(), "git {args:?} failed");
730    }
731
732    fn setup_changed_boundary_project() -> tempfile::TempDir {
733        let project = tempfile::tempdir().expect("project");
734        std::fs::write(
735            project.path().join("package.json"),
736            r#"{"name":"changed-list-api","main":"src/app/index.ts"}"#,
737        )
738        .expect("write package");
739        std::fs::write(
740            project.path().join(".fallowrc.json"),
741            r#"{
742                "boundaries": {
743                    "zones": [
744                        { "name": "app", "patterns": ["src/app/**"] },
745                        { "name": "shared", "patterns": ["src/shared/**"] }
746                    ]
747                }
748            }"#,
749        )
750        .expect("write config");
751        std::fs::create_dir_all(project.path().join("src/app")).expect("create app");
752        std::fs::create_dir_all(project.path().join("src/shared")).expect("create shared");
753        std::fs::write(
754            project.path().join("src/app/index.ts"),
755            "export const app = 1;\n",
756        )
757        .expect("write app");
758        std::fs::write(
759            project.path().join("src/shared/index.ts"),
760            "export const shared = 1;\n",
761        )
762        .expect("write shared");
763
764        git(project.path(), &["init", "-q"]);
765        git(
766            project.path(),
767            &["config", "user.email", "test@example.com"],
768        );
769        git(project.path(), &["config", "user.name", "Test User"]);
770        git(project.path(), &["config", "commit.gpgsign", "false"]);
771        git(project.path(), &["add", "."]);
772        git(project.path(), &["commit", "-qm", "initial"]);
773        std::fs::write(
774            project.path().join("src/app/index.ts"),
775            "export const app = 2;\n",
776        )
777        .expect("modify app");
778        project
779    }
780
781    #[test]
782    fn project_info_default_sections_match_plain_list_contract() {
783        let project = tempfile::tempdir().expect("project");
784        std::fs::write(
785            project.path().join("package.json"),
786            r#"{"name":"project-info-api","main":"src/index.ts"}"#,
787        )
788        .expect("write package");
789        std::fs::create_dir_all(project.path().join("src")).expect("create src");
790        std::fs::write(
791            project.path().join("src/index.ts"),
792            "export const value = 1;\n",
793        )
794        .expect("write source");
795
796        let output = serialize_project_info_programmatic_json(
797            run_project_info(&ProjectInfoOptions {
798                analysis: AnalysisOptions {
799                    root: Some(project.path().to_path_buf()),
800                    no_cache: true,
801                    ..AnalysisOptions::default()
802                },
803                ..ProjectInfoOptions::default()
804            })
805            .expect("project info should run"),
806        )
807        .expect("project info should serialize");
808
809        assert_eq!(output["file_count"], 1);
810        assert_eq!(output["files"][0], "src/index.ts");
811        assert_eq!(output["entry_point_count"], 1);
812        assert_eq!(output["workspace_count"], 0);
813        assert!(output.get("kind").is_none());
814    }
815
816    #[test]
817    fn project_info_surfaces_malformed_root_package_json() {
818        let project = tempfile::tempdir().expect("project");
819        std::fs::write(project.path().join("package.json"), "{").expect("write package");
820
821        let err = run_project_info(&ProjectInfoOptions {
822            analysis: AnalysisOptions {
823                root: Some(project.path().to_path_buf()),
824                no_cache: true,
825                ..AnalysisOptions::default()
826            },
827            ..ProjectInfoOptions::default()
828        })
829        .expect_err("malformed root package.json must fail project info");
830
831        assert_eq!(err.exit_code, 2);
832        assert_eq!(err.code.as_deref(), Some("FALLOW_CONFIG_LOAD_FAILED"));
833        assert!(
834            err.message.contains("package.json"),
835            "error should name the malformed root package.json"
836        );
837    }
838
839    #[test]
840    fn project_info_default_sections_include_undeclared_workspace_diagnostic() {
841        let project = tempfile::tempdir().expect("project");
842        std::fs::write(
843            project.path().join("package.json"),
844            r#"{"name":"project-info-api","workspaces":["packages/*"]}"#,
845        )
846        .expect("write package");
847        std::fs::create_dir_all(project.path().join("packages/app")).expect("workspace dir");
848        std::fs::write(
849            project.path().join("packages/app/package.json"),
850            r#"{"name":"app","main":"src/index.ts"}"#,
851        )
852        .expect("write workspace package");
853        std::fs::create_dir_all(project.path().join("tools/extra")).expect("extra package dir");
854        std::fs::write(
855            project.path().join("tools/extra/package.json"),
856            r#"{"name":"extra"}"#,
857        )
858        .expect("write extra package");
859
860        let output = serialize_project_info_programmatic_json(
861            run_project_info(&ProjectInfoOptions {
862                analysis: AnalysisOptions {
863                    root: Some(project.path().to_path_buf()),
864                    no_cache: true,
865                    ..AnalysisOptions::default()
866                },
867                ..ProjectInfoOptions::default()
868            })
869            .expect("project info should run"),
870        )
871        .expect("project info should serialize");
872
873        let diagnostics = output["workspace_diagnostics"]
874            .as_array()
875            .expect("project info should include workspace_diagnostics");
876        assert!(
877            diagnostics.iter().any(|diagnostic| {
878                diagnostic["kind"].as_str() == Some("undeclared-workspace")
879                    // Project-relative, like every other envelope's
880                    // `workspace_diagnostics[].path` (issue #2366 follow-up).
881                    && diagnostic["path"].as_str() == Some("tools/extra")
882            }),
883            "project info must include undeclared workspace diagnostics from the reused session, got {diagnostics:#?}"
884        );
885    }
886
887    /// Issue #2366: the MCP `project_info` tool reads the same workspace value
888    /// the `fallow workspaces` envelope does, so a glob declared in both
889    /// `package.json` and `pnpm-workspace.yaml` must reach an agent as one
890    /// entry per directory, project-relative, exactly as the CLI reports it.
891    #[test]
892    fn project_info_reports_one_entry_per_directory_for_a_glob_in_two_manifests() {
893        let project = tempfile::tempdir().expect("project");
894        let root = project.path();
895        std::fs::create_dir_all(root.join("pkgs/aaa")).expect("first package-less dir");
896        std::fs::create_dir_all(root.join("pkgs/bbb")).expect("second package-less dir");
897        std::fs::create_dir_all(root.join("src")).expect("source dir");
898        std::fs::write(
899            root.join("package.json"),
900            r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
901        )
902        .expect("write root manifest");
903        std::fs::write(
904            root.join("pnpm-workspace.yaml"),
905            "packages:\n  - \"pkgs/*\"\n",
906        )
907        .expect("write pnpm workspace manifest");
908        std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
909            .expect("write source");
910
911        let output = serialize_project_info_programmatic_json(
912            run_project_info(&ProjectInfoOptions {
913                analysis: AnalysisOptions {
914                    root: Some(root.to_path_buf()),
915                    no_cache: true,
916                    ..AnalysisOptions::default()
917                },
918                ..ProjectInfoOptions::default()
919            })
920            .expect("project info should run"),
921        )
922        .expect("project info should serialize");
923
924        let reported: Vec<(&str, &str)> = output["workspace_diagnostics"]
925            .as_array()
926            .expect("project info should include workspace_diagnostics")
927            .iter()
928            .filter(|diagnostic| diagnostic["kind"] == "glob-matched-no-package-json")
929            .map(|diagnostic| {
930                (
931                    diagnostic["pattern"].as_str().unwrap_or_default(),
932                    diagnostic["path"].as_str().unwrap_or_default(),
933                )
934            })
935            .collect();
936
937        assert_eq!(
938            reported,
939            vec![("pkgs/*", "pkgs/aaa"), ("pkgs/*", "pkgs/bbb")],
940            "project_info agrees with the CLI envelopes: {}",
941            output["workspace_diagnostics"]
942        );
943    }
944
945    #[test]
946    fn list_runtimes_scope_files_and_boundary_counts_to_changed_since() {
947        let project = setup_changed_boundary_project();
948        let analysis = AnalysisOptions {
949            root: Some(project.path().to_path_buf()),
950            changed_since: Some("HEAD".to_string()),
951            no_cache: true,
952            ..AnalysisOptions::default()
953        };
954
955        let project_info = serialize_project_info_programmatic_json(
956            run_project_info(&ProjectInfoOptions {
957                analysis: analysis.clone(),
958                files: true,
959                boundaries: true,
960                ..ProjectInfoOptions::default()
961            })
962            .expect("project info should run"),
963        )
964        .expect("project info should serialize");
965        let files = project_info["files"].as_array().expect("files array");
966        assert_eq!(files, &[json!("src/app/index.ts")]);
967        assert_eq!(project_info["boundaries"]["zones"][0]["file_count"], 1);
968        assert_eq!(project_info["boundaries"]["zones"][1]["file_count"], 0);
969
970        let boundaries = serialize_list_boundaries_programmatic_json(
971            run_list_boundaries(&ListBoundariesOptions { analysis })
972                .expect("list boundaries should run"),
973        )
974        .expect("list boundaries should serialize");
975        assert_eq!(boundaries["boundaries"]["zones"][0]["file_count"], 1);
976        assert_eq!(boundaries["boundaries"]["zones"][1]["file_count"], 0);
977    }
978
979    #[test]
980    fn boundary_zone_counts_preserve_first_match_for_overlapping_zones() {
981        let root = Path::new("project");
982        let boundaries = ResolvedBoundaryConfig {
983            zones: vec![
984                resolved_zone("source", &["src/**"]),
985                resolved_zone("shared", &["src/shared/**"]),
986            ],
987            ..ResolvedBoundaryConfig::default()
988        };
989        let files = discovered_files(root, &["src/app.ts", "src/shared/index.ts"]);
990
991        let zones = build_boundary_zones(root, &boundaries, Some(&files));
992
993        assert_eq!(zones[0].name, "source");
994        assert_eq!(zones[0].file_count, 2);
995        assert_eq!(zones[1].name, "shared");
996        assert_eq!(zones[1].file_count, 0);
997    }
998
999    #[test]
1000    fn boundary_zone_counts_ignore_unmatched_and_out_of_root_files() {
1001        let root = Path::new("project");
1002        let boundaries = ResolvedBoundaryConfig {
1003            zones: vec![resolved_zone("matched", &["src/matched/**"])],
1004            ..ResolvedBoundaryConfig::default()
1005        };
1006        let mut files = discovered_files(root, &["src/matched/index.ts", "src/unmatched/index.ts"]);
1007        files.push(DiscoveredFile {
1008            id: FileId(2),
1009            path: PathBuf::from("outside/index.ts"),
1010            size_bytes: 0,
1011        });
1012
1013        let zones = build_boundary_zones(root, &boundaries, Some(&files));
1014
1015        assert_eq!(zones[0].file_count, 1);
1016    }
1017
1018    #[test]
1019    fn boundary_zone_counts_preserve_duplicate_name_semantics() {
1020        let root = Path::new("project");
1021        let boundaries = ResolvedBoundaryConfig {
1022            zones: vec![
1023                resolved_zone("shared", &["src/first/**"]),
1024                resolved_zone("shared", &["src/second/**"]),
1025            ],
1026            ..ResolvedBoundaryConfig::default()
1027        };
1028        let files = discovered_files(root, &["src/first/index.ts", "src/second/index.ts"]);
1029
1030        let zones = build_boundary_zones(root, &boundaries, Some(&files));
1031
1032        assert_eq!(zones[0].patterns, ["src/first/**"]);
1033        assert_eq!(zones[1].patterns, ["src/second/**"]);
1034        assert_eq!(zones[0].file_count, 2);
1035        assert_eq!(zones[1].file_count, 2);
1036    }
1037
1038    #[test]
1039    fn boundary_zone_output_is_deterministic_and_keeps_config_order() {
1040        let root = Path::new("project");
1041        let boundaries = ResolvedBoundaryConfig {
1042            zones: vec![
1043                resolved_zone("zeta", &["src/zeta/**"]),
1044                resolved_zone("alpha", &["src/alpha/**"]),
1045                resolved_zone("middle", &["src/middle/**"]),
1046            ],
1047            ..ResolvedBoundaryConfig::default()
1048        };
1049        let files = discovered_files(
1050            root,
1051            &[
1052                "src/middle/two.ts",
1053                "src/zeta/one.ts",
1054                "src/alpha/one.ts",
1055                "src/middle/one.ts",
1056            ],
1057        );
1058
1059        let first = build_boundary_zones(root, &boundaries, Some(&files));
1060        let second = build_boundary_zones(root, &boundaries, Some(&files));
1061        let signature = |zones: &[ZoneInfo]| {
1062            zones
1063                .iter()
1064                .map(|zone| (zone.name.clone(), zone.file_count))
1065                .collect::<Vec<_>>()
1066        };
1067
1068        assert_eq!(signature(&first), signature(&second));
1069        assert_eq!(
1070            signature(&first),
1071            vec![
1072                ("zeta".to_string(), 1),
1073                ("alpha".to_string(), 1),
1074                ("middle".to_string(), 2),
1075            ]
1076        );
1077    }
1078
1079    #[test]
1080    fn boundary_json_empty_includes_logical_groups_key() {
1081        let value = boundary_data_to_json(&empty_boundary_data());
1082
1083        assert_eq!(value["configured"], false);
1084        assert_eq!(value["zone_count"], 0);
1085        assert_eq!(value["rule_count"], 0);
1086        assert_eq!(value["logical_group_count"], 0);
1087        assert_eq!(value["logical_groups"], json!([]));
1088    }
1089
1090    #[test]
1091    fn boundary_json_logical_group_carries_all_fields() {
1092        let data = BoundaryData {
1093            zones: vec![ZoneInfo {
1094                name: "features/auth".to_string(),
1095                patterns: vec!["src/features/auth/**".to_string()],
1096                file_count: 3,
1097            }],
1098            rules: vec![],
1099            logical_groups: vec![LogicalGroupInfo {
1100                name: "features".to_string(),
1101                children: vec!["features/auth".to_string()],
1102                auto_discover: vec!["./src/features/".to_string()],
1103                authored_rule: Some(AuthoredRule {
1104                    allow: vec!["shared".to_string()],
1105                    allow_type_only: vec!["types".to_string()],
1106                }),
1107                fallback_zone: None,
1108                source_zone_index: 1,
1109                status: LogicalGroupStatus::Ok,
1110                file_count: 3,
1111                child_file_count: 3,
1112                fallback_file_count: 0,
1113                merged_from: None,
1114                original_zone_root: None,
1115                child_source_indices: vec![],
1116            }],
1117            is_empty: false,
1118        };
1119
1120        let value = boundary_data_to_json(&data);
1121        let group = &value["logical_groups"][0];
1122
1123        assert_eq!(value["logical_group_count"], 1);
1124        assert_eq!(group["name"], "features");
1125        assert_eq!(group["children"][0], "features/auth");
1126        assert_eq!(group["auto_discover"][0], "./src/features/");
1127        assert_eq!(group["status"], "ok");
1128        assert_eq!(group["source_zone_index"], 1);
1129        assert_eq!(group["file_count"], 3);
1130        assert_eq!(group["authored_rule"]["allow"][0], "shared");
1131        assert_eq!(group["authored_rule"]["allow_type_only"][0], "types");
1132        assert!(group.get("fallback_zone").is_none());
1133        assert!(group.get("merged_from").is_none());
1134        assert!(group.get("original_zone_root").is_none());
1135        assert!(group.get("child_source_indices").is_none());
1136    }
1137
1138    #[test]
1139    fn boundary_json_logical_group_optional_fields_round_trip() {
1140        let data = BoundaryData {
1141            zones: vec![],
1142            rules: vec![],
1143            logical_groups: vec![LogicalGroupInfo {
1144                name: "features".to_string(),
1145                children: vec!["features/auth".to_string(), "features/billing".to_string()],
1146                auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
1147                authored_rule: None,
1148                fallback_zone: Some("features".to_string()),
1149                source_zone_index: 0,
1150                status: LogicalGroupStatus::Empty,
1151                file_count: 2,
1152                child_file_count: 0,
1153                fallback_file_count: 2,
1154                merged_from: Some(vec![0, 3]),
1155                original_zone_root: Some("packages/app/".to_string()),
1156                child_source_indices: vec![0, 1],
1157            }],
1158            is_empty: false,
1159        };
1160
1161        let group = &boundary_data_to_json(&data)["logical_groups"][0];
1162
1163        assert_eq!(group["status"], "empty");
1164        assert_eq!(group["fallback_zone"], "features");
1165        assert_eq!(group["merged_from"][1], 3);
1166        assert_eq!(group["original_zone_root"], "packages/app/");
1167        assert_eq!(group["child_source_indices"][1], 1);
1168    }
1169}